コンテンツにスキップ

Part 8 RBS と型シグネチャ

この章のゴールは、型を「コードの外」に出し、そして「コードの中」から取り戻すことです。前半で、手書きのMETHODS表を別ファイル(RBS)から読み込んだ表に差し替えます。差し替えても診断は1つも変わらない、それが正しさの証拠です。

後半で、そのRBSの記法を手本に、注釈ゼロのメソッドのdefを読み、本体から戻り型を合成してRBS風のシグネチャを出力します。ここでchibirigorが推論を土台にしていることが一番はっきりします。注釈ゼロのメソッドでも、本体の最後の式から戻り型が決まるのです。


8-1. 型は「別ファイル」に書く(RBS)

Section titled “8-1. 型は「別ファイル」に書く(RBS)”

ここまで、メソッドの型はRubyのコード(METHODS表)に直接書いてきました。でもRuby本来のやり方は違います。Rubyのコードには型注釈を書きません。代わりに、型はRBSという別ファイル(.rbs)に書きます。こうすると、動いているRubyのコードを1文字も変えずに、後から型情報だけを足せるのが狙いです。

class Integer
def +: (Integer) -> Integer
def to_s: () -> String
end

初めて型を「書いた」記法かもしれないので、読み下しておきます。def +: (Integer) -> Integerは「Integer#+Integerを1つ受け取り、Integerを返す」という意味です。:の右が型、(...)が引数の型、->の右が戻り型です。def to_s: () -> Stringなら「引数なしでStringを返す」です。Rubyのdefの頭に、引数と戻りの型を書き添えただけ、と読めば十分です。

Rubyだと->はラムダを作る記号ですが〔square = ->(x) { x * x }〕、RBSでは「引数を受け取り→戻り型を返す」という別物の矢印です。同じ記号で意味が違う、と割り切ってください。

これがRuby/RBSの世界観です。「コードは型のことを知らない。型は外から与える」。RigorはこのRBSをとして読み、その上にさらに精度を足していきます。

  • ① 型理論:宣言された型を引いて使います(『しくみ』9章の型代入の遠縁)
  • ② Rubyだと:コードに型注釈は無く、型は.rbsに別書きします
  • ③ Rigorだと:RBSを真実の源として読みます。手書き表は、そのRBSのミニ版でした

本物のrbs gemを使うのが理想ですが、ここではchibirigor流に最小限を自前で読みます。依存を増やさず、何が起きているか全部見えるようにするためです。扱う形はclassdef名: (引数) -> 戻りの2種類だけです。

module Rbs
CLASS_LINE = /\A\s*class\s+(\S+)\s*\z/
DEF_LINE = /\A\s*def\s+(\S+):\s*\((.*)\)\s*->\s*(\S+)\s*\z/
def load(source)
table = {}
current = nil
source.each_line do |line|
if (m = CLASS_LINE.match(line))
current = m[1].to_sym
elsif current && (m = DEF_LINE.match(line))
params = m[2].split(",").map(&:strip).reject(&:empty?).map { |t| Type::Nominal[t.to_sym] }
table[[current, m[1].to_sym]] = { params: params.freeze, returns: Type::Nominal[m[3].to_sym] }
end
end
table.freeze
end
end

def +: (Integer) -> Integerの1行が[:Integer, :+] => { params: [Integer], returns: Integer }になる、それだけです。本物のRBSはもっと豊かですが、骨は同じ「宣言を表にする」です。


8-3. 手書き表をRBS由来に差し替える

Section titled “8-3. 手書き表をRBS由来に差し替える”

DispatchMETHODSを、手書きリテラルからRBS読み込みに差し替えます。

module Dispatch
# 以前は手書きリテラル。いまは RBS テキストから生成。
METHODS = Rbs.load(Rbs::CORE)
end

Rbs::COREには、ディスパッチに必要なコア型のメソッドをRBSテキストで書いておきます。Part 2の手書き表と同じ内容に、後の章で使う*upcaseも含めた完全版です。

module Rbs
CORE = <<~RBS
class Integer
def +: (Integer) -> Integer
def -: (Integer) -> Integer
def *: (Integer) -> Integer
def to_s: () -> String
end
class String
def +: (String) -> String
def *: (Integer) -> String
def length: () -> Integer
def upcase: () -> String
end
RBS
end

内容が手書き表と同じなので、差し替えても診断は1つも変わりません。Part 1から7のテストが全て緑のまま、というのがその証拠です。ふるまいを変えずに土台だけ入れ替える、安全なリファクタです。

Terminal window
$ ruby test/test_part1.rb # … 緑
$ ruby test/test_part7.rb # … 緑(表の出どころが変わっただけ)
  • ① 型理論:型の出どころを宣言(RBS)に一元化します
  • ② Rubyだと.rbsが型の単一の源です
  • ③ Rigorだと:手書き表からRBS由来へ差し替えます。ふるまいは変わりません。外から見た挙動を変えずに、内部実装だけを差し替えるわけです

ここまでで、型を「コードの外」(RBS)から読む土台ができました。次は逆向きです。注釈の無いメソッドのコードを読んで、そのRBS記法のシグネチャをこちらから合成してみせます。


8-4. 戻り型は本体から合成できる

Section titled “8-4. 戻り型は本体から合成できる”

Rubyのメソッドには型注釈がありません。でも戻り型は本体から分かることが多いです。

def greet
"hi".upcase # String を返す
end

"hi".upcaseの型は(前節までのRBS表から)Stringです。メソッドの戻り型は本体の最後の式の型そのものです。だから合成できます。type_ofdefを足します。

when Prism::DefNode then type_of_def(node, scope, diagnostics)
def type_of_def(node, scope, diagnostics)
method_return_type(node, scope, diagnostics) # 本体を型チェック(診断も集まる)
Type::Const[node.name] # def 式の値はメソッド名シンボル
end
def method_return_type(node, scope, diagnostics)
# 仮引数は untyped(本編は引数推論しない=続編)
body_scope = method_param_names(node).reduce(scope) { |s, n| s.with_local(n, Type::Dynamic.new) }
type_of_body(node.body, body_scope, diagnostics)
end

ここで使った小さな道具が2つあります。method_param_namesは必須の仮引数名を取り出すだけです。type_of_bodyは「文の並びを上から評価して、最後の文の型を返す」ヘルパで、Part 3のeval_statementを使い回します。eval_statementは文を1つ評価して[型,スコープ]を返すメソッドです。ifの枝の本体やdefの本体は、どれも「文の並び」なので同じ道具で扱えます。

def method_param_names(node)
node.parameters&.requireds&.map(&:name) || []
end
# 文の並びを評価し、最後の文の型を返す(枝の中でもスコープを縫う)
def type_of_body(statements_node, scope, diagnostics)
return Type::Const[nil] if statements_node.nil? # 空の本体は nil
last = Type::Const[nil]
statements_node.body.each { |stmt| last, scope = eval_statement(stmt, scope, diagnostics) }
last
end

これでdefの本体も型チェックされるようになりました。checkdef bad; 1 + "x"; endの中のエラーを拾います。引数はuntypedなので、def ok(x); x + 1; endは誤検知しません。untyped + Integer:maybeで黙ります。


annotateは、文がdefのときだけシグネチャ文字列を、それ以外は今までどおり推論した型を返します。文の種類で分岐するだけです。

def annotate(source)
program = Prism.parse(source).value
scope = Scope.new
ignored = []
program.statements.body.map do |stmt|
if stmt.is_a?(Prism::DefNode)
{ line: stmt.location.start_line, type: method_signature(stmt, scope, ignored) }
else
type, scope = eval_statement(stmt, scope, ignored)
{ line: stmt.location.start_line, type: type }
end
end
end
def method_signature(node, scope, diagnostics)
params = method_param_names(node).map { "untyped" }.join(", ")
"def #{node.name}: (#{params}) -> #{method_return_type(node, scope, diagnostics)}"
end
Terminal window
$ printf 'def greet\n "hi".upcase\nend\n' | ruby exe/chibirigor annotate /dev/stdin
1: def greet: () -> String

checkannotate同じ推論エンジンtype_ofmethod_return_type)を使います。推論が土台で、チェックも表示もその出力を使います。これがPart 0で言った「推論を土台にした型チェッカー」の姿です。


8-6. untypedがどこに出るか=推論の弱点

Section titled “8-6. untypedがどこに出るか=推論の弱点”

引数をuntypedにしているので、それが戻りまで流れるとuntypedが顔を出します。nがuntyped → n * 2もuntypedのように伝播します。

1: def double: (untyped) -> untyped
1: def mystery: (untyped) -> untyped

このuntypedの出方そのものが「推論が型を見失った場所」です。どこを直せば型が通るようになるかが、ひと目で分かります。

これはRigorのsig-gen(RBSを生成する機能)の発想の芽です。生成されたRBSのuntypedは「人間が型を足すべき場所」を指しています。

  • ① 型理論:本体から戻り型を合成します(注釈なしでも型が立ちます)
  • ② Ruby/RBS:メソッドに注釈は無いですが、戻りは本体から分かることが多いです
  • ③ Rigor実装の問題:合成した型をRBS風に見せ、untypedで推論の穴を可視化します

足したもの(前半)は、Rbs.load(ごく小さなRBSリーダー)とRbs::COREです。Dispatch::METHODSの出どころだけが変わり、ふるまいは変わりませんでした。足したもの(後半)は、type_ofDefNode対応(本体チェックと戻り型合成)とannotatemethod_signatureです。型を「外」から読む土台の上に、型を「中」から立ち上げる仕掛けが乗りました。

この章の三つの視点:

内容
① 型理論(『しくみ』9章 / 『TAPL』22、23章)宣言された型を引いて使う(型代入の遠縁)/本体から戻り型を合成(注釈ゼロでも型が立つ)
② Ruby/RBS型はコードに書かず別ファイル.rbsに書く/メソッドに注釈は無いが戻りは本体から分かる
③ Rigor実装の問題RBSを真実の源に(挙動を変えず土台だけ差し替え)/RBS風sigで見せuntypedで推論の弱点を可視化(sig-genの芽)

前編で組んだのは「型は別ファイル、戻りは本体から」という骨格までです。残りは後編で正式な名前とともに扱い直す宿題です。前編の最後に、その行き先を一望しておきます。

続編/後のPartに送ったもの

  • 引数の推論(本体での使われ方からxの型を当てる)。本編は引数=untyped止まりです。この型推論の本丸は後編Part 5で扱います
  • 本物のrbs gemを使った完全なRBS読み込み(union、optional、ブロック、ジェネリクス)と型変数の置換(Array[Elem]Array[String])、継承チェーンのメソッド解決
  • 複数returnをまたぐ戻り型の合流と、生成したRBSの書き出し(erasure)。後編Part 3で詳しく扱います
  1. Rbs::COREString#downcase: () -> Stringを足し、"A".downcaseが通ることを確かめよ。
  2. 自前ミニRBSリーダーが扱えない RBS構文を1つ挙げよ(例:union型Integer | String、 optionalの?、ブロック)。扱うにはDEF_LINEの正規表現に何が要るか。
  3. 表をRBS由来に差し替えてもPart 1〜7のテストが緑のままであることを確かめ、「ふるまいを 変えずに土台を差し替える」とはどういうことか、自分の言葉で説明せよ。
  4. def f\n 1 + 2\nendのシグネチャをannotateで確かめよ。
  5. def g(x)\n x.upcase\nendの戻り型はなぜuntypedか。Stringを出すには何が必要か (ヒント:引数の型推論=後編Part 5の話)。
  6. 本体にエラーのあるdef bad\n 1 + "x"\nendcheckし、診断の行番号が本体の行を指す ことを確かめよ。

次章予告(Part 9、最終章):ここまでをgradualの哲学で締めます。untypedの伝播を仕上げ、untypedvoidneverの「特別な型3種」を総括します。「chibirigorはわざと見逃すことで動くコードを脅かさない」を語り切り、『しくみ』が結びで発展先の一つに挙げたgradual typingへと接続して、本編を閉じます。


この章の実装(演習の答え合わせにも)impls/dist/part8/lib

© 2026 TypedDuck. Licensed under CC BY-SA 4.0.