コンテンツにスキップ

Part 7 受理判定と「たぶん」

この章のゴールは、「この型、ここに渡して合う?」を判定するacceptsを作ることです。ただし答えは「はい」か「いいえ」の二択ではなく、:yes:no:maybeの三択にします。この「:maybe」が、Rigorがやさしくいられる最大の理由です。

『しくみ』では7章「部分型付け」(『TAPL』15章「部分型付け」)が対応します。同書は「合う/合わない」をtrue/falseの二択で答えました。私たちはそこに一つ足します。


7-1. 「合う?」という新しい問い

Section titled “7-1. 「合う?」という新しい問い”

これまでtype_ofは、式から型を上向きに求めるだけでした(1Const[1])。でもエラーを見つけるには、別の問いが要ります。

このメソッドはIntegerを欲しがっている。あなたが渡したのはStringだ。これは合う?

これが受理判定です。『しくみ』7章ではこれをsubtype(a, b)(aはbの部分型か)として作り、答えはtrue/falseの二択でした。

ところがRubyでこれを二択にすると、すぐ困ります。type_ofは、わからない式に対してDynamic(untyped)を返すのでした。Part 1でそう決めました。では:

Integerを欲しがっている所に、untypedが来た。これは合う?

trueと答えれば、本当はバグでも見逃すかもしれません。falseと答えれば、ちゃんと動くコードを怒ってしまうかもしれません。どちらも嘘です。本当の答えは「わからない(たぶん)」です。

だからacceptsの答えは三択にします。:yes:no:maybeです。


7-2. まず白黒がつく所(確実に合うか、確実に合わないか)

Section titled “7-2. まず白黒がつく所(確実に合うか、確実に合わないか)”

「合う」とは何か。むずかしく考えず、「渡した値が、欲しがっている型のに入るか」と思ってください。Integerという箱には12も入ります。String"x"は入りません。

ただしConst[1]Nominal[:Integer]をそのまま比べても一致しません。値そのものの型とクラスの型は別物だからです。だから比べる前に、値の型をクラスに丸めてそろえます(widen)。Part 1の「丸め」がここでも効きます。

# Const[1] のような「値そのもの」は、いったんクラスに丸めてから比べる
def widen(t) = t.is_a?(Type::Const) ? Type::Nominal[t.value.class.name.to_sym] : t
def accepts(expected, actual)
widen(expected) == widen(actual) ? :yes : :no
end
accepts(Type::Nominal[:Integer], Type::Const[1]) # => :yes (1 は Integer の箱に入る)
accepts(Type::Nominal[:Integer], Type::Const["x"]) # => :no ("x" は入らない)

この「箱に入るか」が、『しくみ』7章の部分型付けそのものです。用語は覚えなくて大丈夫です。小さい箱は大きい箱に入る、とだけ押さえておいてください。『しくみ』は箱の大小を丁寧に階段状に定義しましたが、私たちはまず「クラスが同じか」という一番素朴な所から始めます。

この章の三つの視点(パースペクティブ):

  • ① 型理論:値が期待の型に入るかが部分型付けです(『しくみ』7章)。
  • ② Rubyだと1Integer"x"String。クラスで大まかに判定できます。
  • ③ RigorだとConst[1]のような細かい型は、比べる前にクラスに丸めてから判定します。Part 1の「丸め」がここでも効きます。

7-3. 「たぶん」が出るとき(untypedが混ざる)

Section titled “7-3. 「たぶん」が出るとき(untypedが混ざる)”

さて、ここからが本題です。Dynamic(untyped)が絡んだら、白黒つけません。:maybeを返します

def accepts(expected, actual)
return :maybe if expected.is_a?(Type::Dynamic) || actual.is_a?(Type::Dynamic) # ★追加
widen(expected) == widen(actual) ? :yes : :no
end
accepts(Type::Nominal[:Integer], Type::Dynamic.new) # => :maybe (わからないものは、わからない)

たった1行ですが、これがRigorを「動的言語にやさしい型チェッカー」にしている大黒柱の一本です。untypedは「型を見失った」という印です。見失ったものについて「合う」とも「合わない」とも言い張らない。それが誠実というものです。

  • ① 型理論:未知(untyped)が混ざると、判定は片側に倒せません。
  • ② Rubyだと:型注釈が無いコードは普通です。foo.barの戻りなんて誰にもわかりません。
  • ③ Rigorだと:わからない所は:maybeにします。これが次の節で「脅かさない」に直結します。

7-3a. Unionが引数に来るとき(一番弱い答えを採る)

Section titled “7-3a. Unionが引数に来るとき(一番弱い答えを採る)”

Union(「IntegerStringのどちらか」のような型。Part 4、5で作りました)が渡されることもあります。渡す側(actual)がUnionのときは、実際にどのメンバの値が来るか分からないので、全メンバが通って初めて安全です。だからメンバを全部acceptsにかけて、一番弱い答えを採るだけです。一個でも:noなら:no:noが無くても:maybeがあれば:maybe、全部:yesなら:yesです。

def accepts(expected, actual)
return :maybe if expected.is_a?(Type::Dynamic) || actual.is_a?(Type::Dynamic)
if actual.is_a?(Type::Union) # 全メンバが通って初めて :yes(一番弱い答え)
results = actual.members.map { |m| accepts(expected, m) }
return :no if results.include?(:no)
return :maybe if results.include?(:maybe)
return :yes
end
if expected.is_a?(Type::Union) # どれか一つに合えば :yes(一番強い答え)
results = expected.members.map { |m| accepts(m, actual) }
return :yes if results.include?(:yes)
return :maybe if results.include?(:maybe)
return :no
end
widen(expected) == widen(actual) ? :yes : :no
end

expectedがUnionの場合(accepts(Integer | String, Integer)など)は一番強い答えを採ります。どれか一つに合えば:yesです。「IntegerStringのどちらかを期待」していて、渡されたのがIntegerなら問題ない、という直感です。

これで1 + (c ? 1 : 2)のようにUnionが引数に来ても、中身が全部Integerなら:yesで黙ります。誤検知しません。Integer | StringのようにStringが混じれば:noで報告します。

  • ① 型理論:Unionのメンバ全員が通って初めて:yesです(一番弱い結論を採る、union-subtyping)。
  • ② Rubyだとc ? 1 : 2Integer | Integerだから問題ありません。c ? 1 : "a"はUnionになり、Integerを期待する式に渡すと:noになります。
  • ③ Rigorだと:Unionへの照合でも「分からなければ:maybe」です。全員:yesのときだけ怒れます。

図7-1 acceptsの三値判定(untypedは 、Unionは一番弱い答え)

▼ 図7-1 acceptsの三値判定(untypedは:maybe、Unionは一番弱い答え)


7-4. 「たぶん」は罰しない(この章の山場)

Section titled “7-4. 「たぶん」は罰しない(この章の山場)”

acceptscheckで使います。エラーを報告する場所は、「欲しい型が決まっている所」だけです。Part 2で作った手書きディスパッチ表を思い出してください。Integer#+は「Integerを1つ欲しい」と書いてありました。そこが「欲しい型が決まっている所」です。

# Part 2 の表(再掲)。param に「欲しい型」が書いてある
METHODS = {
[:Integer, :+] => { params: [Type::Nominal[:Integer]], returns: Type::Nominal[:Integer] },
# ...
}
def check_call(recv_type, name, arg_types, diagnostics)
sig = METHODS[[class_of(recv_type), name]]
return Type::Dynamic.new unless sig # 知らないメソッド → 脅かさない(Part 2 の方針)
sig[:params].zip(arg_types).each do |want, got|
case accepts(want, got)
when :no
diagnostics << "expected #{want} but got #{got}"
when :maybe, :yes
# 何もしない ← ここが全て
end
end
sig[:returns]
end

見てのとおり、怒るのは:noのときだけです。:yesはもちろん、:maybeでも黙ります

check("1 + 2") # 文句なし(:yes)
check('1 + "x"') # ["expected Integer but got \"x\""](:no)
check("1 + foo.bar") # 文句なし! foo.bar は untyped → :maybe → 黙る
Integer | Integer: OK (no errors)
expected Integer but got 1 | "a"

ここで、この本で一番大事な一文を置きます。専門用語は要りません。

『しくみ』7章にも、よく似た問題意識のコラムがありました。「健全な型システムが良いプログラムを弾くのは誤検知だ」と。『しくみ』は誤検知を減らす方向で工夫しました。Rigorはもう一歩進めて、「わからない=たぶん=黙る」を仕組みの真ん中に置くことで、誤検知をそもそも生みにくくしています。

そして実は、Part 1、2で+のときに書いた場当たり的なintegerish?チェック、あれはこのacceptsの手書き版でした。いまそれを正式な仕組みに置き換えたわけです。


7-5. 返りは厳しめ、引数は緩め(小さなコラム)

Section titled “7-5. 返りは厳しめ、引数は緩め(小さなコラム)”

最後に小さな観察を一つ。いま私たちは、

  • 引数を見るときはacceptsで緩く判定しました(:maybeを通す)
  • 一方、戻り型type_ofできっちり求めています(Integer#+ならIntegerと断言)

この非対称、「返すものは厳しく正確に、受け取るものは緩く寛容に」は偶然ではなく、Rigorがわざと守っている作法です。受け取りを厳しくすると、呼ぶ側が無駄な型変換を書かされて窮屈になります。返しを緩くすると、その値を使う側が損をします。だから逆にするのです。1


足したものは、関数accepts:yes/:no/:maybeを返す)と、checkがそれを使って:noのときだけ怒る仕組みです。コードはaccepts本体4行+Unionのおまけ数行+checkの差し替えだけです。新しい型キャリアは増えていません。

この章の三つの視点:

内容
① 型理論(『しくみ』7章 / 『TAPL』15章)値が期待の型に入るか=部分型付け
② Ruby/RBSダックタイピング、型注釈の無いコード、untypedが普通に混ざる
③ Rigor実装の問題二択だと誤検知or見逃しになる → 三値(:maybe)で「わからない」を表に出し、罰しない

続編に送ったもの(ここで扱うと易しさが壊れるため):

  • 箱の大小をきちんと階段状に定義する本物の部分型(『しくみ』7章のwidth/depth)
  • 変性(関数を渡すとき、引数の向きだけ逆になる「反変」)。『しくみ』7章の山場
  • type_of(求める)とaccepts(確かめる)の2方向をまとめて「双方向型付け」と呼ぶ話
  1. accepts(Nominal[:Integer], Union[[Const[1], Const["x"]]]):noになることを確かめよ(一番弱い答え)。
  2. expectedがUnionのとき(accepts(Integer | String, Integer))の挙動を上記の実装で確かめよ。:yesを返すのはなぜか、:noとはならない理由とあわせて説明せよ。
  3. :maybeが出るのにcheckが黙る例を1つ作り、「:maybeは罰しない」を確かめよ。

次章予告(Part 8):手書きのMETHODS表を、本物のRBSから引くように差し替えます。「型は別ファイルに書く」というRuby/RBSの世界観に初めて触れます。


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

  1. この「返りは厳密、引数は寛容」という作法には名前も付いていますが、覚えなくて構いません。なぜこの非対称が正しいのか(型理論やオブジェクト指向の置換可能性と、別々の出発点から同じ規則に至ること)は、後編Part 2で確認します。

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