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: () -> Stringend初めて型を「書いた」記法かもしれないので、読み下しておきます。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のミニ版でした
8-2. ごく小さなRBSを読む
Section titled “8-2. ごく小さなRBSを読む”本物のrbs gemを使うのが理想ですが、ここではchibirigor流に最小限を自前で読みます。依存を増やさず、何が起きているか全部見えるようにするためです。扱う形はclassとdef名: (引数) -> 戻りの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 endenddef +: (Integer) -> Integerの1行が[:Integer, :+] => { params: [Integer], returns: Integer }になる、それだけです。本物のRBSはもっと豊かですが、骨は同じ「宣言を表にする」です。
8-3. 手書き表をRBS由来に差し替える
Section titled “8-3. 手書き表をRBS由来に差し替える”DispatchのMETHODSを、手書きリテラルからRBS読み込みに差し替えます。
module Dispatch # 以前は手書きリテラル。いまは RBS テキストから生成。 METHODS = Rbs.load(Rbs::CORE)endRbs::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 RBSend内容が手書き表と同じなので、差し替えても診断は1つも変わりません。Part 1から7のテストが全て緑のまま、というのがその証拠です。ふるまいを変えずに土台だけ入れ替える、安全なリファクタです。
$ 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_ofにdefを足します。
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) } lastendこれでdefの本体も型チェックされるようになりました。checkがdef bad; 1 + "x"; endの中のエラーを拾います。引数はuntypedなので、def ok(x); x + 1; endは誤検知しません。untyped + Integerは:maybeで黙ります。
8-5. RBS風に見せる
Section titled “8-5. RBS風に見せる”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 endend
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$ printf 'def greet\n "hi".upcase\nend\n' | ruby exe/chibirigor annotate /dev/stdin1: def greet: () -> Stringcheckとannotateは同じ推論エンジン(type_of/method_return_type)を使います。推論が土台で、チェックも表示もその出力を使います。これがPart 0で言った「推論を土台にした型チェッカー」の姿です。
8-6. untypedがどこに出るか=推論の弱点
Section titled “8-6. untypedがどこに出るか=推論の弱点”引数をuntypedにしているので、それが戻りまで流れるとuntypedが顔を出します。nがuntyped → n * 2もuntypedのように伝播します。
1: def double: (untyped) -> untyped1: def mystery: (untyped) -> untypedこのuntypedの出方そのものが「推論が型を見失った場所」です。どこを直せば型が通るようになるかが、ひと目で分かります。
これはRigorのsig-gen(RBSを生成する機能)の発想の芽です。生成されたRBSのuntypedは「人間が型を足すべき場所」を指しています。
- ① 型理論:本体から戻り型を合成します(注釈なしでも型が立ちます)
- ② Ruby/RBS:メソッドに注釈は無いですが、戻りは本体から分かることが多いです
- ③ Rigor実装の問題:合成した型をRBS風に見せ、
untypedで推論の穴を可視化します
8-7. この章のまとめ
Section titled “8-7. この章のまとめ”足したもの(前半)は、Rbs.load(ごく小さなRBSリーダー)とRbs::COREです。Dispatch::METHODSの出どころだけが変わり、ふるまいは変わりませんでした。足したもの(後半)は、type_ofのDefNode対応(本体チェックと戻り型合成)とannotateのmethod_signatureです。型を「外」から読む土台の上に、型を「中」から立ち上げる仕掛けが乗りました。
この章の三つの視点:
| 内容 | |
|---|---|
| ① 型理論(『しくみ』9章 / 『TAPL』22、23章) | 宣言された型を引いて使う(型代入の遠縁)/本体から戻り型を合成(注釈ゼロでも型が立つ) |
| ② Ruby/RBS | 型はコードに書かず別ファイル.rbsに書く/メソッドに注釈は無いが戻りは本体から分かる |
| ③ Rigor実装の問題 | RBSを真実の源に(挙動を変えず土台だけ差し替え)/RBS風sigで見せuntypedで推論の弱点を可視化(sig-genの芽) |
前編で組んだのは「型は別ファイル、戻りは本体から」という骨格までです。残りは後編で正式な名前とともに扱い直す宿題です。前編の最後に、その行き先を一望しておきます。
続編/後のPartに送ったもの:
- 引数の推論(本体での使われ方から
xの型を当てる)。本編は引数=untyped止まりです。この型推論の本丸は後編Part 5で扱います - 本物の
rbsgemを使った完全なRBS読み込み(union、optional、ブロック、ジェネリクス)と型変数の置換(Array[Elem]→Array[String])、継承チェーンのメソッド解決 - 複数
returnをまたぐ戻り型の合流と、生成したRBSの書き出し(erasure)。後編Part 3で詳しく扱います
Rbs::COREにString#downcase: () -> Stringを足し、"A".downcaseが通ることを確かめよ。- 自前ミニRBSリーダーが扱えない RBS構文を1つ挙げよ(例:union型
Integer | String、 optionalの?、ブロック)。扱うにはDEF_LINEの正規表現に何が要るか。 - 表をRBS由来に差し替えてもPart 1〜7のテストが緑のままであることを確かめ、「ふるまいを 変えずに土台を差し替える」とはどういうことか、自分の言葉で説明せよ。
def f\n 1 + 2\nendのシグネチャをannotateで確かめよ。def g(x)\n x.upcase\nendの戻り型はなぜuntypedか。Stringを出すには何が必要か (ヒント:引数の型推論=後編Part 5の話)。- 本体にエラーのある
def bad\n 1 + "x"\nendをcheckし、診断の行番号が本体の行を指す ことを確かめよ。
次章予告(Part 9、最終章):ここまでをgradualの哲学で締めます。untypedの伝播を仕上げ、untyped/void/neverの「特別な型3種」を総括します。「chibirigorはわざと見逃すことで動くコードを脅かさない」を語り切り、『しくみ』が結びで発展先の一つに挙げたgradual typingへと接続して、本編を閉じます。
この章の実装(演習の答え合わせにも) →
impls/dist/part8/lib
© 2026 TypedDuck. Licensed under CC BY-SA 4.0.