型推論を実装・改善していく3 純粋関数型な単相型推論(後編)
前々回、前回に続いて純粋関数型な単相型推論の実装を詳細に見ていく。コードの全容はこちら。
型推論・型検査の関数であるtypeof
:
let typeof env new_tvar constr e = let (t, new_tvar, constr1) = infer env new_tvar e in let constr = unify (constr @ constr1) in let t = applysubst constr t in (t, new_tvar, constr)
この後半で使われているunify
とapplysubst
を見ていく。
unify
はinfer
から返ってくる型制約のリストを処理してapplysubst
に渡せる形に変換する関数。(正確には型制約のリストはtypeof
の引数としても渡されるが、こちらは基本的に空リスト(よりリッチな言語の場合はビルトイン関数の型制約があらかじめ渡される))
applysubst
はある型に対して「unify
で処理された型制約のリスト」を順次に適用して変換する。その結果がtypeof
に渡された式の型だと判定される。
unify
unify
関数は型制約のリストに対する再帰的なパターンマッチでできている。型制約のリストはtype constr = (ty * ty) list
である。
unify
の返り値もまた(ty * ty) list
だが、タプルの最初のty
は必ず型変数となる。引数の(ty * ty)
が双方向的な「この二つの型は同値である」という主張なのに対して、返り値の(ty * ty)
は「最初の型変数は2番目の型に変換される」という方向性のあるマッピングである。
let rec unify = function | [] -> [] | (TVar s1, TVar s2)::rest when s1 = s2 -> unify rest
まったく同じ型変数同士に関する型制約は自明として無視される。
どちらか片方(あるいは両方)が型変数だが、まったく同一ではないケース:
| (t, TVar s)::rest | (TVar s, t)::rest -> if occursin s t then failwith "Unification failed due to occurs check" else List.append (unify (substinconstr s t rest)) [(TVar s, t)]
「型変数'aが型Bと同値である」という制約を考えるとき、型Bの中に型変数'aが出てくるような再帰性があった場合型推論がうまくいかない。なのでoccurs checkを行なって「型Bの中に型変数'aが出てこない」ということを確認する必要がある(occursin
関数の実装は後で見ていく)。
再帰性がないことが確認できた場合、残っている型制約すべてに対してsubstinconstr
関数(この関数についても後ほど詳細を見ていく)で「この型制約の型変数と型」の変換を行った上で再帰的にunify
を行い、その結果の最後に「この型制約の型変数と型」をappendしている。
この部分はかなり効率が悪い。残った型制約のリストに対してO(n)な処理を行なっているのでunify
がO(n**2
)になる。また再帰的にunify
を使った結果に[(TVar s, t)]
をappendしているというのも再度O(n)な処理で、さらに非末尾再帰的だ。この部分はaccumulatorを使えばO(1)な末尾再帰として解決できるはずだが、どうせsubstinconstr
でO(n**2
)になってるんだから、と開き直っているのだろうか・・・。
最後に関数型同士の型制約に関するケース:
| (TArrow(tparam1,tret1),TArrow(tparam2,tret2))::rest -> unify ((tparam1,tparam2)::(tret1,tret2)::rest)
引数の型同士、戻り値の型同士、と二つの型制約に分解した上で再帰的にunify
を呼び出す。
現在扱っている言語の型が型変数と関数型しかないので、以上ですべてのケースが網羅された。
unify
の返す結果は入力の順番にかなり左右される。
例えば
unify [(TVar "a", TVar "b); (TVar "c", TArrow(TVar"b", TVar "d"))] ---> [(TVar "c", TArrow(TVar"a", TVar "d")); (TVar "a", TVar "b)]
となる(関数型の中の型変数が"b"から"a"に変換されている)のだが、順番が逆の場合
unify [(TVar "c", TArrow(TVar"b", TVar "d")); (TVar "a", TVar "b)] ---> [(TVar "a", TVar "b); (TVar "c", TArrow(TVar"b", TVar "d"))]
変換が行われずに返ってくる。
実際には入力の順番によってunify
が失敗することはないのだが、そのかわり出力の順番を正しく扱う必要は出てくる。後で詳しく見ていくapplysubst
関数で使われるわけだが、まずList.rev
をしてから型に対して順次に変換をしていかなくてはいけない。
ちなみに何故わざわざList.rev
しなくてはいけないような順番にしているのかがわからない(この部分はTaPL準拠)。unify
の| (t, TVar s)::rest | (TVar s, t)::rest ->
のケースでわざわざList.append (unify (substinconstr s t rest)) [(TVar s, t)]
などとしているからList.rev
が必要なのであって、素直に(TVar s, t)::(unify (substinconstr s t rest))
を返していればいいのではないかと思うのだが・・・。
それではここまでで登場したヘルパ関数を見ていく。
occursin
ある型変数'aを別の型Bと同値だと見做すためには、'aがBの中に登場しないことを確認する必要がある(そうでないと型推論が壊れるため)。その検査をするoccursin
関数:
let rec occursin s = function | TVar s2 -> s = s2 | TArrow(tparam,tret) -> occursin s tparam || occursin s tret
型の種類が二つしかないのでパターンマッチが非常に簡単。ちなみに第一引数は型ではなく型変数の中の文字列。
substinconstr
型制約のリストconstr
に対して、ある型変数が起きる箇所をすべて別の型に入れ替えるsubstinconstr
関数(Substitute inside Constraintsの略):
let substinconstr s t constr = List.map (fun (t1,t2) -> (substinty s t t1, substinty s t t2)) constr
型制約ひとつひとつの右辺、左辺ともにsubstinty
関数で型変数TVar s
を型t
に入れ替える。
substinty
ある型に出てくる特定の型変数を特定の型に入れ替えるsubstinty
(Substitute inside Type)関数:
let rec substinty s t t1 = match t1 with | TVar(s1) -> if s = s1 then t else t1 | TArrow(tparam1,tret1) -> TArrow(substinty s t tparam1, substinty s t tret1)
やはり型の種類が二つしかないのでパターンマッチが非常に簡単。
applysubst
最後にinfer
から返ってきた型にunify
で用意した型制約を適用し、単一化された形の型を返すapplysubst
関数:
let applysubst constr t = let f t = function | (TVar(s),t1) -> substinty s t1 t | _ -> assert false in List.fold_left f t (List.rev constr)
まず型に個別の型制約を適用する内部関数f
を定義している。unify
を通した型制約はタプルのfst
が型変数であるはずなので、パターンマッチで確認してエラーケースはassert false
になっている。f
の正常系ではunify
の解説でも出てきたsubstinty
関数で型の中の型変数を違う型に変換している。
その上でList.fold_left
で型制約のリストを次々とt
に(f
を使って)適用していく。前述のとおり、unify
から返ってきた型制約リストは逆順に適用する必要があるのでList.rev
している。
まとめ&次回
これでTaPL準拠(ほぼ)の純粋関数型な単相型推論の実装は完了。
コードを見てきて感じる問題点としては
などがあり、これらを改善していく。
次回は手始めにnew_tvar
を破壊的に変更される状態を持つ関数にすることでコードを少しスッキリさせる。