コンテンツにスキップ

Part 2 メソッド送信とディスパッチ

この章のゴールは、メソッド呼び出しの型付けを、手書きの「ディスパッチ表」に委ねることです。Rubyは何でもメソッド送信なので、ここが土台になります。

『しくみ』3章「関数型」(『TAPL』9章「単純型付きラムダ計算」)に対応します。同書は関数の型を{ params, retType }というデータで持ちました。私たちもほぼ同じ情報を、ただしメソッドごとに表で持ちます。


メソッドが増えると型キャリアも増えるので、Part 1でChibirigor直下に置いたConst/Nominal/Dynamicを、Chibirigor::Typeモジュールにまとめておきます。以降はType::Constのように書きます。diagnosticヘルパはPart 1で作ったものをそのまま使います。

module Chibirigor
module Type
Const = Data.define(:value) { def to_s = value.inspect }
Nominal = Data.define(:name) { def to_s = name.to_s }
Dynamic = Data.define { def to_s = "untyped" }
end
end

これで土台が揃いました。本題に入ります。


Part 1で1 + 2+を特別扱いしたとき、こう書きました。「+はメソッド送信(1.+(2))です」と。これは+に限った話ではありません。

1 + 2 # 1.+(2)
"ab".length # "ab".length()
"a" * 3 # "a".*(3)

全部、レシーバ(受け手)にメッセージを送っているだけです。ここでレシーバという言葉を押さえます。多くの言語はlength("ab")のように関数を単独で呼びますが、Rubyは違います。"ab".lengthのように、必ず誰か(レシーバ)に対してメソッドを呼びます。

1 + 2も裏では1.+(2)、つまり「1というレシーバに、+というメッセージを、引数2を添えて送る」わけです。引数のない"ab".lengthですら"ab"がレシーバです。Rubyにレシーバのない裸の関数はほぼ無く、fooと書けばそれも暗黙のselfへのself.fooです。この性質はPart 3でもう一度効きます。

これは型付けに直結します。同じ+でも、レシーバがIntegerなら「整数+整数→整数」、Stringなら「文字列+文字列→文字列」というふうに、意味はレシーバで変わります。だから「このメソッドは何を返すか」はメソッド名だけでは決まらず、レシーバの型とセットで初めて決まります。

「式の型を求める」の大半が、結局「このレシーバのこのメソッドは、何を返すか」を知ることに尽きるのは、このためです。Part 1の+専用コードを捨てて、ここを一般化します。


1.+(2)のように「レシーバにメッセージを送る」と、Rubyは実行時に「そのレシーバにとって+とはどの実装か」を選びます。この送られたメッセージから実際のメソッドを選ぶ仕組みをディスパッチ(dispatch、振り分け)と呼びます。章題の後半はこれです。

私たちがやるのはその型版です。Rubyが実行時にメソッド本体を選ぶのに対し、私たちは型チェック時に「そのメソッドは何を返すか(戻り型)」を選びます。1.+(2)を実際に走らせる代わりに、「(Integer, +)は何を返すか」を表から選びます。だから、手書きのディスパッチ表を用意します。

「どのクラスの、どのメソッドが、どんな引数を取り、何を返すか」を、素朴な表で持ちます。

module Dispatch
I = Type::Nominal[:Integer]
S = Type::Nominal[:String]
# [レシーバのクラス, メソッド名] => { params: [引数の型...], returns: 戻り型 }
METHODS = {
%i[Integer +] => { params: [I], returns: I },
%i[Integer to_s] => { params: [], returns: S },
%i[String +] => { params: [S], returns: S },
%i[String length] => { params: [], returns: I },
# ...
}.freeze
end

表を引くには、型を「クラス名」に丸める道具が要ります(Const[1]Nominal[:Integer]:Integerに)。

def class_of(type)
case type
when Type::Const then type.value.class.name.to_sym
when Type::Nominal then type.name
end # Dynamic などは nil(=引けない)
end
  • ① 型理論:関数(メソッド)の型は「引数の型 → 戻りの型」です(『しくみ』3章{params, retType})。
  • ② Rubyだと+lengthも全部メソッド送信です。型情報はメソッドごとに要ります。
  • ③ Rigorだと:関数型のキャリアは持たず、(クラス,メソッド) → 型表で引くやり方です。本物のRigorはこの表がRBSです。本書ではいまは「素朴な表引き」に留めます。実物はもっと凝った解決をしますが、その全貌は2-6のまとめで案内します。

ディスパッチの流れはこうです。レシーバとメソッド名で表を引き、見つかれば引数を確かめて戻り型を返し、見つからなければ黙ってuntypedを返します。

1 + "x"
│ レシーバの型 = Integer、メソッド = :+、引数の型 = [String]
METHODS[[:Integer, :+]] ─ 見つかる ─→ 引数を accepts で確認 ─ 合わない ─→ 診断
│ └ 合う ─→ 戻り型 Integer
└─ 見つからない(未知メソッド)─→ untyped(脅かさない)

つまり出口は3つです。表に無ければ黙ってuntyped(脅かさない)、有って引数が合えば戻り型、有って引数が合わなければ診断です。どの道も出発点は同じ「レシーバの型とメソッド名で表を引けたか」です。

図2-1 メソッド送信のディスパッチ

▼ 図2-1 メソッド送信のディスパッチ

type_ofのメソッド呼び出し部分は、レシーバと各引数の型を求めて表に渡すだけになります。

def type_of_call(node, diagnostics)
receiver = node.receiver ? type_of(node.receiver, diagnostics) : Type::Dynamic.new
arg_types = (node.arguments&.arguments || []).map { |arg| type_of(arg, diagnostics) }
Dispatch.dispatch(receiver, node.name, arg_types, node, diagnostics)
end

Part 1では+の引数しか見ませんでしたが、いまは全部の引数をtype_ofにかけます。おかげでputs(1 + true)のように奥に潜んだエラーも見つかります。puts自体は知らなくても、引数1 + trueを型付けする途中で気づきます。


dispatchの中身です。表が見つかったら、引数の数と型を確かめます。

def dispatch(receiver_type, name, arg_types, node, diagnostics)
signature = METHODS[[class_of(receiver_type), name]]
return Type::Dynamic.new unless signature # 知らないメソッド → 脅かさない(2-5)
if arg_types.size != signature[:params].size
diagnostics << Chibirigor.diagnostic(
node, "wrong number of arguments for #{name} (#{signature[:params].size} expected, #{arg_types.size} given)"
)
return signature[:returns]
end
signature[:params].zip(arg_types).each do |param, arg|
next if matches?(param, arg)
diagnostics << Chibirigor.diagnostic(node, "expected #{param} but got #{arg}")
end
signature[:returns]
end

引数の型が合うかは、いまは素朴に「クラスが一致するか」で見ます。

def matches?(param, arg)
return true if param.is_a?(Type::Dynamic) || arg.is_a?(Type::Dynamic) # 不明は通す
class_of(param) == class_of(arg)
end
check('"a" + 1') # ["expected String but got 1"]
check('"ab".length(1)') # ["wrong number of arguments for length (0 expected, 1 given)"]

2-5. 知らないメソッドは脅かさない

Section titled “2-5. 知らないメソッドは脅かさない”

表に無い[クラス,メソッド]、あるいはレシーバがDynamic(型を見失っている)のときは、診断を出さずDynamicを返します(dispatchの最初のreturn)。

check("foo.bar(1, 2)") # [] ← foo も bar も知らない。黙って通す

これはRubyの現実への態度です。Rubyはオープンクラス(既存クラスにメソッドを足せる)で、method_missingもあり、メソッドは無数にあります。全部を表に書くのは不可能です。

だから「表に無い=怪しい」とは絶対にしません。知らないものは知らないまま、untypedで先へ進みます。

  • ① 型理論:未知の呼び出しをどう型付けするか。
  • ② Rubyだと:オープンクラス、無数のメソッド、method_missingがあります。表は必ず不完全です。
  • ③ Rigorだと:未知はDynamicにdegradeします。本物のRigorは手書き表の代わりにRBS+継承チェーン解決で表を「本物」に近づけます(Part 8でひとさじ、本格解決は続編)。
1: String
1: Integer
expected Integer but got "x"

足したものは、Dispatchモジュール(METHODS表、class_ofmatches?dispatch)です。type_of側はむしろ短くなりました+専用コードが消え、表に委ねるだけです。

この章の三つの視点:

内容
① 型理論(『しくみ』3章 / 『TAPL』9章)メソッドの型は「引数の型 → 戻りの型」
② Ruby/RBS何でもメソッド送信。オープンクラスで全部は表に書けない
③ Rigor実装の問題(クラス,メソッド)→型を表で引き、未知はDynamicにdegrade。引数判定は手書き(Part 7でacceptsに格上げ)

素朴な一段の表引きで止めたのは、易しさのためです。ここから先は「その表をどう太らせ、どう正しく引くか」という深さの話です。いまは行き先だけ置いておきます。

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

  • 手書き表 → RBSからの本物の引き(Part 8)
  • 継承チェーンやモジュールmixinをたどったメソッド解決、method_missing、オープンクラスの本格対応(続編)
  • 引数判定の三値化(accepts)とrobustness(Part 7)
  • 実物のdispatch 5段カスケード(定数畳み込み → shape → RBS → in-source → fallback)の全貌は付録a3で扱います

2-7. 発展:定数畳み込み(畳めれば畳む)

Section titled “2-7. 発展:定数畳み込み(畳めれば畳む)”

Part 1で1 + 2Integerに丸めてきました。でも12既知の値です。なら実際に足してConst[3]に畳めるはずです。「値そのもの」をもう一段保てれば、annotateの精度が上がります(実RigorのConstant<3>リテラル精度の縮図)。

やることは「両オペランドが既知値のConstなら計算する、ただし大きくなりすぎたら丸める」だけです。+の解決にひとさじ足すと、こうなります。丸める前に一度だけ畳みを試します。

# 両方が既知値の Const なら計算して畳む。予算(大きさ)を超えたら丸めに任せる。
if recv.is_a?(Type::Const) && arg.is_a?(Type::Const)
result = recv.value + arg.value
return Type::Const[result] if result.abs <= 1_000_000 # 予算内 → 畳む
end
return Type::Nominal[:Integer] # 畳めない → 丸める

これでannotateはこう変わります。

1 + 2 # => 3 (畳めた)
1 + 2 + 3 # => 6 (再帰で 1+2→3、3+3→6 と畳み続く)
"a" * 3 # => "aaa" (文字列も畳める)
100000 * 100 # => Integer (1,000,000 超 = 予算超過 → 丸める)
1 + x # => Integer (x が値不明 = 畳めない → 丸める)

ポイントは2つです。

  • 拡大(widening):際限なく大きなConstを抱えないよう、閾値を超えたら丸めます
    • この「いつ畳むのをやめるか」を実Rigorがどう体系立てるかは、後編でくわしく扱います
  • 誤検知ゼロ:畳み込みは精度を足すだけです
    • Const[3]Integerの所に通るので、新しい診断は一切増えません(1 + "x"のように畳めない式は、これまで通り丸めて元の挙動のまま)

そしてここが本筋への回収です。実際のchibirigorでは、この畳み込みは+の特別扱いではなく、メソッドの表(この章のDispatch)側に置いてあります。だから表を引くどの演算でも効き、x = 1; 1 + xのように変数が既知のConstを運んでいれば、それも2に畳めます。手元のexe/chibirigor1 + 23と出るのは、畳み込みがDispatch側にいるからです。


  1. Integer#*METHODS表に足し、check("2 * 3")が空配列になることを確かめよ。
  2. 1.to_s(2)(引数過剰)をcheckし、出るメッセージを読め。アリティ判定はdispatchのどの分岐か。
  3. 表に無いメソッド呼び出しが「黙って通る」例を3つ作り、なぜ脅かさないのかを説明せよ。

次章予告(Part 3):ローカル変数と文を扱います。x = 1で型を覚え、xを読めるようにします。ここで「型環境=Scope」が登場します。


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

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