Hindley-Milner型推論の仕組み
型注釈ゼロでも全式の型が決まる秘密が分かる。単一化とlet多相という二本柱で、ML/Haskellがどう型を導くかを原理から押さえます。
- 1.Hindley-Milner(HM)は型注釈なしで最も一般的な型を一意に決められる推論方式。MLやHaskellの土台。
- 2.中心は単一化(Unification)。式から型の等式制約を集め、型変数を矛盾なく解いて確定させる。
- 3.let多相により、定義した関数を複数の型で使い回せる。一般化(generalize)と具体化(instantiate)が鍵。
なぜ注釈なしで型が決まるのか
ML や Haskell では let id = \x -> x と書くだけで、コンパイラが id の型を a -> a と割り出します。注釈は一切いりません。これを支えるのが Hindley-Milner(HM)型推論です。HM の凄みは、書ける式に対して必ず最も一般的な型(principal type、主要型)が存在し、しかもそれを機械的に一意に求められる点にあります。動的型付けのように実行時へ検査を遅らせるのではなく、コンパイル時に全式の型を決め切ります。
原理は二つの柱に分解できます。式から型の等式制約を集めて解く**単一化(Unification)と、let で束縛した定義を多くの型で使い回せるようにするlet多相(let-polymorphism)**です。順に分解します。
制約を集める:型変数を置く
HM はまず、型が未知の箇所に型変数(慣例で a, b, t0, t1)を仮置きします。そして式の構造から、型が満たすべき等式を機械的に書き出します。たとえば関数適用 f x を見たら、「f の型は 引数の型 -> 結果の型 でなければならない」という制約が生まれます。
-- 推論したい式
\f -> \x -> f (f x)
ここで f : t_f、x : t_x と置くと、内側の適用 f x から「t_f = t_x -> t1」、外側の f (f x) から「t_f = t1 -> t2」という制約が出ます。型推論とは、こうして集めた制約をすべて満たす型変数への代入を求める作業に他なりません。
教科書的な Algorithm W は制約生成と単一化を同時に進めますが、現代の実装は「式全体から制約を集める」段階と「集めた制約を一括で解く」段階を分離することが多いです。エラーメッセージの質や拡張性が上がるためです。原理を理解する上では、まず制約を全部出してから解く、と捉えると単純化できます。
単一化(Unification):制約を解くエンジン
単一化とは、二つの型を「等しくするための最小限の代入」を見つけるアルゴリズムです。t_x -> t1 と t1 -> t2 を単一化するなら、対応する位置を突き合わせて t_x = t1、t1 = t2 を導きます。規則はごく単純です。
- 同じ型構築子同士(
->と->、ListとList)なら、引数の型を再帰的に単一化する。 - 型変数
aと任意の型tなら、a := tという代入を作る(aをtに束縛)。 - 構築子が食い違う(
IntとBool、Intとa -> b)なら型エラー。これが型不一致の正体です。
求まる代入は **MGU(most general unifier、最汎単一化子)**と呼ばれ、「等式を満たす中で最も束縛が少ない=最も一般的な解」になります。HM が主要型を導けるのは、単一化が常に MGU を返すからです。
型変数 a を a -> b のような「自分自身を含む型」へ束縛しようとすると、a = (a -> b) となり、展開が止まらない無限型が生まれます。これを防ぐのが occurs check(出現検査)で、束縛前に「右辺の型に左辺の変数が現れていないか」を確認します。検査を省くと \x -> x x のような式を誤って受理してしまいます。
let多相:同じ定義を複数の型で使う
ここからが HM の核心です。次の式を考えます。
let id = \x -> x
in (id 1, id "hello")
id は整数にも文字列にも適用されています。もし id の型変数を単一化で固定すると、id 1 で a = Int に縛られ、続く id "hello" が型エラーになってしまいます。これを救うのが let多相です。
let で束縛を作るとき、HM はその定義の型に含まれる「他で固定されていない型変数」を**一般化(generalize)し、forall a. a -> a という型スキーム(多相型)へ持ち上げます。そして id を使うたびに、forall の変数を新しい型変数で具体化(instantiate)**します。id 1 では a := Int、id "hello" では別個に a := String と、呼び出しごとに独立した型が割り当てられるため、両立します。
一般化の対象は、「現在の型環境(外側の束縛)に自由出現していない型変数」に限ります。たとえば \y -> let f = \x -> y in ... の f は、本体に外側の y の型が絡むため、その型変数は一般化できません。固定すべき変数まで一般化すると、本来エラーにすべき式を通してしまいます。
let多相とラムダ束縛の違い
同じ「変数に値を束縛する」でも、let と**ラムダ引数(関数の仮引数)**では多相の扱いが正反対です。ここは資格試験でも実務でも誤解されがちな要点です。
| 観点 | let束縛 | ラムダ引数(仮引数) |
|---|---|---|
| 多相化 | 一般化され多相になり得る | 単相のまま(モノモーフィック) |
| 複数型での利用 | 呼び出しごとに具体化でき両立 | 1つの型に固定される |
| 代表例 | let id = \x -> x | \id -> (id 1, id "a") は型エラー |
| 理由 | 値が確定してから一般化できる | 引数の型は呼び出し側に依存し未確定 |
\id -> (id 1, id "hello") が通らないのは、引数 id の型を一般化できず、id 1 で Int に固定された瞬間に id "hello" が矛盾するためです。第一級の多相を引数に渡したいときは rank-N 多相という拡張が要りますが、それは推論の決定可能性を犠牲にし、明示注釈を要求します。素の HM が型注釈なしで決定可能なのは、多相を let の位置に限定しているからこそです。
Algorithm W の全体像
ここまでの部品を組み上げたのが、Damas と Milner による Algorithm W です。式を再帰的にたどり、次を行います。
- 変数参照なら、型環境から型スキームを引き、
forallを新しい型変数へ具体化する。 - ラムダ
\x -> eなら、xに新鮮な型変数を割り当て(単相のまま)本体を推論し、引数型 -> 本体型を組む。 - 適用
f xなら両者を推論し、fの型をx型 -> 新変数と単一化する。 let x = e1 in e2ならe1を推論し、その型を一般化して環境に入れ、e2を推論する。
結果として得られる代入を全体に適用すれば、注釈ゼロでも全式の型が確定します。複雑な値の分類自体は変数とデータ型、検査をいつ行うかという軸は型システム(静的型付け vs 動的型付け)で扱っています。
試験・面接で問われるのはほぼこの三つです。(1) 単一化がMGUを返すから主要型が一意に決まる。(2) occurs check が無限型を防ぐ。(3) 多相は let の位置でのみ一般化され、ラムダ引数は単相。この区別を言えるかが理解の分かれ目です。
HM の限界と実務での位置づけ
HM は強力ですが万能ではありません。素の HM は部分型(サブタイピング)や型クラス、第一級多相(rank-N)を直接は扱えません。Haskell は型クラスを、辞書渡しと制約付き型スキーム(Ord a => a -> a -> Bool の形)で HM に上乗せして実現しています。また let を再帰や相互再帰へ広げると、単一化が単純には終わらず、束縛グループごとに一般化する工夫が要ります。
実務的には、HM 系の推論は「注釈は要所だけ、残りは導出」という快適さの源泉です。型を引数化する発想はジェネリクス(総称型)と地続きで、forall a. a -> a はまさに総称型の数理的な姿です。コンパイル時に型を解き切るという点では、解析と検査のタイミングを論じたコンパイルとインタプリタとも響き合います。原理を押さえれば、推論が詰まったときに「どの制約が衝突したか」を自力で追えるようになります。
プログラミング Article
Hindley-Milner型推論の仕組みを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
型推論
比較で見る軸
難易度: advanced / カテゴリ: プログラミング / タグ数: 5
導入後に効く点
中心は単一化(Unification)。式から型の等式制約を集め、型変数を矛盾なく解いて確定させる。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- プログラミング
- タグ数
- 5
判断チェックリスト
- 自社の用途が「型推論 / 型システム」に近いか確認する。
- 強みである「Hindley-Milner(HM)は型注釈なしで最も一般的な型を一意に決められる推論方式。MLやHaskellの土台。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。