Part 6 ハッシュと配列の型
前章(Part 5)ではUnionを場合分けで絞り込み、ifの枝ごとに型を細くしました。今度は値の中身に目を向けます。ハッシュと配列のリテラルに構造的な型(HashShape/Tuple)をつけ、そこから値を読み出す型を求めます。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(", ") + "]" endendtype_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の「細かく覚える」の延長)。
6-2. 読み出す(h[:foo]とa[0])
Section titled “6-2. 読み出す(h[:foo]とa[0])”型に「どのキーが何の型か」が入っているので、読み出しは素直です。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] # => 1a[9] # => nilh[: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(余分は許し、不足だけ咎める)
三つの視点(パースペクティブ)で整理します。
- ① 型理論:レコードの幅部分型では、キーが多い方が部分型です(『しくみ』7章も同じ幅部分型)。
- 逆に見えるかもしれませんが、「
{name:}が欲しい所には{name:, admin:}を渡せる(nameはちゃんとある)。逆は渡せない」という関係です。 - キーが多い方が、より多くの要求に通るので部分型になります。「部分型」は次のPart 7で『箱に入るか』として扱います。
- 逆に見えるかもしれませんが、「
- ② Rubyだと:optionsハッシュに余分なキーは日常です。完全一致を強いると現実に合いません。
- ③ Rigorだと:期待はopen(「少なくとも」)です。余分は許し、不足だけ咎めることで誤検知を避けます。
6-4. この章のまとめ
Section titled “6-4. この章のまとめ”足したものは、型キャリアHashShape/Tuple、type_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: 13: "a"4: nilhは各キーの型を覚えるHashShapeです。h[:foo]とh[:bar]は覚えた型をそれぞれ返し、未知のキーh[:zzz]は咎めずにnilを返します。「少なくとも」を許すopen方針によるものです。
この章の三つの視点:
| 内容 | |
|---|---|
| ① 型理論(『しくみ』5章 / 『TAPL』11章 §11.8) | 値をラベルでまとめる=レコード型。キーが多い方が部分型 |
| ② Ruby/RBS | symbolキーのoptionsハッシュが多用される。完全一致は現実に合わない |
| ③ Rigor実装の問題 | 期待はopen(少なくとも)。余分を許し不足だけ咎める=幅部分型を動的ハッシュに適用し誤検知回避 |
続編に送ったもの:
- キーワード引数(
def f(name:, **opts))の本格対応。本編はハッシュ値としての扱い止まり。 mapとfilter_mapの型の差:Rigorではtuple.map { |x| f(x) }は位置ごとの型を保ちます(fの戻り型をそれぞれ適用)。一方filter_mapは結果サイズが述語次第で変わるため、位置ごとの情報を保てずArray[T]に強制的に拡大(widen)します。「位置を変えない操作だけがTupleの精度を保てる」という型理論の自然な帰結です。- レコード部分型の深さ(値の型まで再帰的に比べる)、read-onlyなどRBS recordの細部。
Struct/Data.defineから起こす型(実RigorのDataClass/DataInstance)。
- ネストしたハッシュ
{ a: { b: 1 } }の型は何になるか、annotateで確かめよ。 a = [1, "x"]\na[99]がnilになることを確かめ、なぜエラーにしないのかを説明せよ。- 文字列キー
{ "a" => 1 }は今どう扱われるか(symbolキーのみ対応)。対応を広げるとどんな注意が要るか。
次章予告(Part 7):いよいよ「型同士が合うか」を判定するacceptsを作ります。:yes/:no/:maybeの三値で、ここで決めた「open」方針も実際に効いてきます。
この章の実装(演習の答え合わせにも) →
impls/dist/part6/lib
© 2026 TypedDuck. Licensed under CC BY-SA 4.0.