コンテンツにスキップ

Part 6 ハッシュと配列の型

前章(Part 5)ではUnionを場合分けで絞り込み、ifの枝ごとに型を細くしました。今度は値の中身に目を向けます。ハッシュと配列のリテラルに構造的な型(HashShapeTuple)をつけ、そこから値を読み出す型を求めます。Rubyのコードは「symbolキーのハッシュ」だらけなので、ここをうまく扱えると一気に実用的になります。


6-1. リテラルから型を起こす(HashShapeとTuple)

Section titled “6-1. リテラルから型を起こす(HashShapeとTuple)”

{ foo: 1, bar: "a" }の型は何でしょう。「Hash」では大ざっぱすぎます。どのキーに何の型が入っているかまで覚えたい。それがHashShapeです。

module Type
HashShape = Data.define(:fields) do # fields: { foo: Const[1], bar: Const["a"] }
def to_s = "{" + fields.map { |k, v| "#{k}: #{v}" }.join(", ") + "}"
end
Tuple = Data.define(:elements) do # 配列を「位置ごとの型」で覚える
def to_s = "[" + elements.map(&:to_s).join(", ") + "]"
end
end

type_ofに2つcaseを足すだけです。PrismではハッシュはHashNode(各ペアがAssocNode、symbolキーはSymbolNode)、配列はArrayNodeです。

when Prism::HashNode
fields = node.elements.to_h { |a| [a.key.unescaped.to_sym, type_of(a.value, scope, diag)] }
Type::HashShape[fields]
when Prism::ArrayNode
Type::Tuple[node.elements.map { |el| type_of(el, scope, diag) }]
type_of(parse(%q[{ foo: 1, bar: "a" }])) # => {foo: 1, bar: "a"}
type_of(parse(%q[[1, "x"]])) # => [1, "x"]

三つの視点で整理します。

  • ① 型理論:複数の値をラベルでまとめた型がレコード型です(『しくみ』5章)。
  • ② Rubyだと:symbolキーのハッシュが至る所に使われ、配列もタプル的に使われます([name, age])。
  • ③ RigorだとHashで丸めず、キーごと、位置ごとの型を覚えます(Part 1の「細かく覚える」の延長)。

型に「どのキーが何の型か」が入っているので、読み出しは素直です。h[:foo]はPrismでは[]というメソッド送信です(h.[](:foo))。引数がリテラルのsymbolまたは整数なら、型から引けます。

def read_index(receiver, arg_node)
if receiver.is_a?(Type::HashShape) && arg_node.is_a?(Prism::SymbolNode)
# 未知キーは nil(実 Ruby が nil を返すから。エラーにしない)
return receiver.fields.fetch(arg_node.unescaped.to_sym, Type::Const[nil])
end
if receiver.is_a?(Type::Tuple) && arg_node.is_a?(Prism::IntegerNode)
return receiver.elements.fetch(arg_node.value, Type::Const[nil])
end
nil # 特別扱いできない → 通常のディスパッチに回す
end
# h : {foo: 1, bar: "a"} のとき
h[:foo] # => 1 (Const[1])
h[:zzz] # => nil (★エラーにしない)
a[0] # => 1
a[9] # => nil

h[:zzz]エラーを出さないのがポイントです。理由は単純で、実際のRubyが{foo: 1}[:zzz]nilを返すからです。存在しないキーの読みは「バグ」ではなく「nilが返る」が正しい挙動です。型もそれに合わせてnilを返します。


6-3. openかclosedか(余分なキーを許す)

Section titled “6-3. openかclosedか(余分なキーを許す)”

ここがPart 6の山です。こういうRubyを考えます。

def greet(user) # user は { name: ... } を期待しているとする
"Hello, #{user[:name]}"
end
greet({ name: "Alice", admin: true }) # ★ name 以外に admin も入っている

greetが欲しいのはnameだけです。でも渡されたハッシュにはadminも入っています。これは適合とすべきでしょうか、不適合とすべきでしょうか。

型の等価なら「プロパティが完全一致していないとダメ」ですが、部分型なら話は別です。「{name:}が欲しい所に{name:, admin:}を渡せる」のは健全で、これを幅部分型(width subtyping)と呼びます。『しくみ』も7章でこの幅部分型を採り、余分なプロパティを許します。

RigorもHashShape適合にします。静的に書くレコードを健全性のために扱うのではなく、相手はRubyのオプションハッシュで、狙いは誤検知を出さないことです。

  • Rubyでは「大きなオプションハッシュを作って、各メソッドが必要なキーだけ拾う」のが定石です。
  • 余分なキーがあるたびに怒っていたら、ちゃんと動いているコードが真っ赤になります。

つまりRigorのHashShapeは、期待する側から見ると「少なくともこれらのキーがあればよい」(open)という設計です。余分は気にしません。「必要なキーが無い」ときだけ問題にします。これが「動くコードを脅かさない」の、構造的な型での現れ方です。

図6-1 openなHashShape(余分は許し、不足だけ咎める)

▼ 図6-1 openなHashShape(余分は許し、不足だけ咎める)

三つの視点(パースペクティブ)で整理します。

  • ① 型理論:レコードの幅部分型では、キーが多い方が部分型です(『しくみ』7章も同じ幅部分型)。
    • 逆に見えるかもしれませんが、「{name:}が欲しい所には{name:, admin:}渡せるnameはちゃんとある)。逆は渡せない」という関係です。
    • キーが多い方が、より多くの要求に通るので部分型になります。「部分型」は次のPart 7で『箱に入るか』として扱います。
  • ② Rubyだと:optionsハッシュに余分なキーは日常です。完全一致を強いると現実に合いません。
  • ③ Rigorだと:期待はopen(「少なくとも」)です。余分は許し、不足だけ咎めることで誤検知を避けます。

足したものは、型キャリアHashShapeTupletype_ofの2 case、読み出しread_indexです。新しい判定ロジックはほぼ無く(読みはfetchの第2引数だけ)、難しさは概念「openという方針」に集約しました。

動かすとこうなります。

Chibirigor.annotate("h = {foo: 1, bar: \"a\"}\nh[:foo]\nh[:bar]\nh[:zzz]\n").each { |a| puts "#{a[:line]}: #{a[:type]}" }
1: {foo: 1, bar: "a"}
2: 1
3: "a"
4: nil

hは各キーの型を覚えるHashShapeです。h[:foo]h[:bar]は覚えた型をそれぞれ返し、未知のキーh[:zzz]は咎めずにnilを返します。「少なくとも」を許すopen方針によるものです。

この章の三つの視点:

内容
① 型理論(『しくみ』5章 / 『TAPL』11章 §11.8)値をラベルでまとめる=レコード型。キーが多い方が部分型
② Ruby/RBSsymbolキーのoptionsハッシュが多用される。完全一致は現実に合わない
③ Rigor実装の問題期待はopen(少なくとも)。余分を許し不足だけ咎める=幅部分型を動的ハッシュに適用し誤検知回避

続編に送ったもの

  • キーワード引数(def f(name:, **opts))の本格対応。本編はハッシュ値としての扱い止まり。
  • mapfilter_mapの型の差:Rigorではtuple.map { |x| f(x) }は位置ごとの型を保ちますfの戻り型をそれぞれ適用)。一方filter_mapは結果サイズが述語次第で変わるため、位置ごとの情報を保てずArray[T]強制的に拡大(widen)します。「位置を変えない操作だけがTupleの精度を保てる」という型理論の自然な帰結です。
  • レコード部分型の深さ(値の型まで再帰的に比べる)、read-onlyなどRBS recordの細部。
  • Struct/Data.defineから起こす型(実RigorのDataClass/DataInstance)。
  1. ネストしたハッシュ{ a: { b: 1 } }の型は何になるか、annotateで確かめよ。
  2. a = [1, "x"]\na[99]nilになることを確かめ、なぜエラーにしないのかを説明せよ。
  3. 文字列キー{ "a" => 1 }は今どう扱われるか(symbolキーのみ対応)。対応を広げるとどんな注意が要るか。

次章予告(Part 7):いよいよ「型同士が合うか」を判定するacceptsを作ります。:yes/:no/:maybeの三値で、ここで決めた「open」方針も実際に効いてきます。


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

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