erg/doc/JA/compiler/inference.md
Shunsuke Shibayama 96132b20f6 initial commit
2022-08-10 23:02:27 +09:00

14 KiB
Raw Blame History

型推論アルゴリズム

Info: この項は編集中であり、一部に間違いを含む可能性があります。

以下で用いる表記方法を示します。

自由型変数(型、未束縛): ?T, ?U, ...
自由型変数(値、未束縛): ?a, ?b, ...
型環境(Γ): { x: T, ... }
型代入規則(S): { ?T -> T, ... }
型引数評価環境(E): { e -> e', ... }

以下のコードを例にして説明します。

v = ![]
v.push! 1
print! v

Ergの型推論は、大枠としてHindley-Milner型推論アルゴリズムを用いています。具体的には以下の手順で型推論が行われます。用語の説明などは後述します。

  1. 右辺値の型を推論する(search & get)。
  2. 得られた型を具体化する(instantiate)。
  3. 呼び出しならば型代入を行う(substitute)。
  4. 型変数値があれば評価・簡約する(eval)。
  5. 左辺値があり、かつCallableならば、引数型の一般化を行う(generalize)。
  6. 左辺値があれば、(戻り値)型の一般化を行う(generalize)。
  7. 代入ならば、シンボルテーブルに型情報を登録する(update)。

具体的な操作は以下になります。

line 1. Def{sig: v, block: ![]} get block type: get UnaryOp type: get Array type: ['T; 0] instantiate: [?T; 0] (substitute, eval are omitted) update: Γ: {v: [?T; 0]!} expr returns NoneType: OK

line 2. CallMethod{obj: v, name: push!, args: [1]} get obj type: Array!(?T, 0) search: Γ Array!(?T, 0).push!({1}) get: = Array!('T ~> 'T, 'N ~> 'N+1).push!('T) => NoneType instantiate: Array!(?T, ?N).push!(?T) => NoneType substitute(S: {?T -> Nat, ?N -> 0}): Array!(Nat ~> Nat, 0 ~> 0+1).push!(Nat) => NoneType eval: Array!(Nat, 0 ~> 1).push!({1}) => NoneType update: Γ: {v: [Nat; 1]!} expr returns NoneType: OK

line 3. Call{obj: print!, args: [v]} get args type: [[Nat; 1]!] get obj type: search: Γ print!([Nat; 1]!) get: = print!(...Object) => NoneType expr returns NoneType: OK

型変数の実装

型変数はty.rsTypeにて当初以下のように表現されていました。現在は違う形で実装されていますが、本質的には同じアイデアであるため、より素朴な表現であるこの実装で考えます。 RcCell<T>Rc<RefCell<T>>のラッパー型です。

pub enum Type {
    ...
    Var(RcCell<Option<Type>>), // a reference to the type of other expression, see docs/compiler/inference.md
    ...
}

型変数は、実体型を外部の辞書に持っておき、型変数自体はそのキーのみを持つように実装できます。しかし、RcCellを使用した実装の方が一般に効率的だといわれています(要検証, 一応の出典)。

型変数はまずType::Var(RcCell::new(None))のようにして初期化されます。 この型変数が、コードを解析していく中で書き換えられ、型が決定されます。 最後まで中身がNoneのままだと、(その場では)具体的な型に決定できない型変数となります。例えば、id x = xxの型がそうです。 このような状態の型変数を 未束縛型変数(Unbound type variable) と呼ぶことにします(正確な用語が不明)。対して、何らかの具体的な型が代入されているものは 連携型変数(Linked type variable) と呼ぶことにします。

両者はどちらも自由型変数という種類のものです(この用語は明らかに「自由変数」に因んで命名されていると考えられます)。これらは、コンパイラが推論のために使う型変数です。id: 'T -> 'T'Tなどのように、プログラマが指定するタイプの型変数とは異なるため、このように特別な名前がついています。

未束縛型変数は、?T, ?Uのように表すことにします。型理論の文脈ではαやβが使われる場合が多いですが、入力の簡便化のためこちらを採用します。 これは一般的な議論のために採用した記法で、実際に文字列の識別子を使って実装されているわけではないので注意してください。

未束縛型変数Type::Varは、型環境に入れられる際Type::MonoQuantVarへと置き換えられます。これは 量化型変数(quantified type variable) と呼ばれるものです。こちらは、プログラマが指定する'Tのような型変数と同種のものになります。中身は単なる文字列で、自由型変数のように具体的な型とリンクする機能はありません。

未束縛型変数を量化型変数に置き換える操作を 一般化(generalization) (または汎化)と言います。未束縛型変数のままだと一回の呼び出しで型が固定化されてしまう(例えば、id Trueの呼び出しの後id 1の戻り値型がBoolになってしまう)ため、一般化しなくてはならないのです。 このようにして量化型変数を含む一般化された定義が型環境に登録されます。

一般化、型スキーム、具体化

未束縛型変数?Tを一般化する操作をgenと表すことにします。このとき、得られる一般化型変数を'Tとします。 Ergの文法で一般化型変数は通常の型と同じ文法で表現されますが、ここではわかりやすさのために'を付けます。 型理論では、ある量化された型αは∀α.を付けて区別します(∀のような記号を(全称)量化子といいます)。 このような表現(e.g. ∀α.α, ∀α. α->α)を型スキームと呼びます。Ergでの型スキームは'T, 'T -> 'Tなどと表されるわけです。 型スキームは、通常は第一級の型とはみなされません。そのように型システムを構成すると、型推論がうまく動作しなくなる場合があるためです。ただしErgでは一定の条件下で第一級の型とみなせます。詳細はランク2型を参照してください。

さて、得られた型スキーム(e.g. 'T -> 'T (idの型スキーム))を使用箇所(e.g. id 1, id True)の型推論で使う際は、一般化を解除する必要があります。この逆変換を 具体化(instantiation) と呼びます。操作はinstと呼ぶことにします。

gen ?T = 'T
inst 'T = ?T (?T ∉ Γ)

重要な点として、どちらの操作も、その型変数が出現する場所すべてを置換します。例えば、'T -> 'Tを具体化すると、?T -> ?Tが得られます。 具体化の際は置換用のDictが必要ですが、一般化の際は?T'Tをリンクさせるだけで置換できます。

あとは引数なりの型を与えて目的の型を得ます。この操作を型代入(Type substitution)といい、substと表すことにします。 さらに、その式が呼び出しの場合に戻り値型を得る操作をsubst_call_retと表します。第1引数は引数型のリスト、第2引数は代入先の型です。

型代入規則{?T -> X}は、?TXを同一の型とみなすよう書き換えるという意味です。この操作を 単一化(Unification) といいます。Xは型変数もありえます。 単一化の詳しいアルゴリズムは別の項で解説します。単一化操作はunifyと表すことにします。

unify(?T, Int) == Ok(()) # ?T == (Int)

# Sは型代入規則、Tは適用する型
subst(S: {?T -> X}, T: ?T -> ?T) == X -> X
# 型代入規則は{?T -> X, ?U -> T}
subst_call_ret([X, Y], (?T, ?U) -> ?U) == Y

一般化

一般化は単純な作業ではありません。複数のスコープが絡むと、型変数の「レベル管理」が必要になります。 レベル管理の必要性をみるために、まずはレベル管理を導入しない型推論では問題が起こることを確認します。 以下の無名関数の型を推論してみます。

x ->
    y = x
    y

まず、Ergは以下のように型変数を割り当てます。 yの型も未知ですが、現段階では割り当てないでおきます。

x(: ?T) ->
    y = x
    y

まず決定すべきは右辺値xの型です。右辺値は「使用」なので、具体化します。 しかしxの型?Tは自由変数なのですでに具体化されています。よってそのまま?Tが右辺値の型になります。

x(: ?T) ->
    y = x (: inst ?T)
    y

左辺値yの型として登録する際に、一般化します。が、後で判明するようにこの一般化は不完全であり、結果に誤りが生じます。

x(: ?T) ->
    y(: gen ?T) = x (: ?T)
    y
x(: ?T) ->
    y(: 'T) = x
    y

yの型は量化型変数'Tとなりました。次の行で、yが早速使用されています。具体化します。

x: ?T ->
    y(: 'T) = x
    y(: inst 'T)

ここで注意してほしいのが、具体化の際にはすでに存在するどの(自由)型変数とも別の(自由)型変数を生成しなくてはならないという点です(一般化も同様です)。このような型変数をフレッシュ(新鮮)な型変数と呼びます。

x: ?T ->
    y = x
    y(: ?U)

そして得られた全体の式の型を見てください。?T -> ?Uとなっています。 しかし明らかにこの式は?T -> ?Tのはずで、推論に問題があるとわかります。 こうなったのは、型変数の「レベル管理」を行っていなかったからです。

そこで、型変数のレベルを以下の表記で導入します。レベルは自然数で表します。

?T<1>, ?T<2>, ...

では、リトライしてみます。

x ->
    y = x
    y

まず、以下のようにレベル付き型変数を割り当てます。トップレベルのレベルは1です。スコープが深くなるたび、レベルが増えます。 関数の引数は内側のスコープに属するため、関数自身より1大きいレベルにいます。

# level 1
x (: ?T<2>) ->
    # level 2
    y = x
    y

先に右辺値xを具体化します。先ほどと同じで、何も変わりません。

x (: ?T<2>) ->
    y = x (: inst ?T<2>)
    y

ここからがキモです。左辺値yの型に代入する際の一般化です。 さきほどはここで結果がおかしくなっていましたので、一般化のアルゴリズムを変更します。 もし型変数のレベルが現在のスコープのレベル以下なら、一般化しても変化がないようにします。

gen ?T<n> = if n <= current_level, then= ?T<n>, else= 'T
x (: ?T<2>) ->
    # current_level = 2
    y (: gen ?T<2>)  = x (: ?T<2>)
    y

つまり、左辺値yの型は?T<2>です。

x (: ?T<2>) ->
    #    ↓ not generalized
    y (: ?T<2>)  = x
    y

yの型は未束縛型変数?T<2>となりました。次の行で具体化します。が、yの型は一般化されていないので、何も起こりません。

x (: ?T<2>) ->
    y (: ?T<2>) = x
    y (: inst ?T<2>)
x (: ?T<2>) ->
    y = x
    y (: ?T<2>)

無事に、正しい型?T<2> -> ?T<2>を得ました。

もう1つの例を見ます。こちらは更に一般的なケースで、関数・演算子適用、前方参照がある場合です。

f x, y = id x + y
id x = x

f 10

1行ずつ見ていきましょう。

fの推論中、後に定義される関数定数idが参照されています。 このような場合、fの前にidの宣言を仮想的に挿入し、自由型変数を割り当てておきます。 このときの型変数のレベル=current_levelであることに注意してください。これは、他の関数内で一般化されないようにするためです。

id: ?T<1> -> ?U<1>
f x (: ?V<2>), y (: ?W<2>) =
    id(x) (: subst_call_ret([inst ?V<2>], inst ?T<2> -> ?U<2>)) + y

型変数同士の単一化では、高いレベルの型変数が低いレベルの型変数に置き換えられます。 レベルが同じ場合はどちらでも構いません。

f x (: ?T<1>), y (: ?W<2>) =
    # ?V<2> -> ?T<1>
    id(x) (: ?U<2>) + y (: ?W<2>)
f x (: ?T<1>), y (: ?W<2>) =
    (id(x) + x): subst_call_ret([inst ?U<1>, inst ?W<2>], inst |'L <: Add('R, 'O)| ('L, 'R) -> 'O)
f x (: ?T<1>), y (: ?W<2>) =
    (id(x) + x): subst_call_ret([inst ?U<1>, inst ?W<2>], (?L(<: Add(?R<2>, ?O<2>))<2>, ?R<2>) -> ?O<2>)
id: ?T<1> -> ?U<1>
f x (: ?T<2>), y (: ?R<2>) =
    # ?L<2> -> ?U<1>
    # ?W<2> -> ?R<2>
    # ?U<1> <: Add(?R<2>, ?O<2>) (レベルが違うので単一化はせず、部分型関係を追加する)
    (id(x) + x) (: ?O<2>)
# current_level = 1
f(x, y) (: gen ?T<1>, gen ?R<2> -> gen ?O<2>) =
    id(x) + x
id: ?T<1> -> ?U<1>
f(x, y) (: (?T<1>, 'R) -> gen ?O<2>) =
    id(x) + x
# ?U<1> <: Add('R, 'O)
f(x, y) (: (?T<1>, 'R) -> 'O =
    id(x) + x

定義の際には一般化できるようにレベルを上げます。

# ?T<1 -> 2>
# ?U<1 -> 2>
id x (: ?T<2>) -> ?U<2> = x (: inst ?T<2>)

戻り値型が既に割り当てられている場合は、得られた型と単一化します(?U<2> -> ?T<2>)。

# ?U<2> -> ?T<2>
f(x, y) (: ?T(<: Add('R, 'O))<2>, 'R -> 'O) =
    id(x) + x
# current_level = 1
id(x) (: gen ?T<2> -> gen ?T<2>) = x (: ?T<2>)
f(x, y) (: |'T <: Add('R, 'O)| 'T, 'R -> 'O) =
    id(x) + x
id(x) (: 'T -> gen 'T) = x
f x, y (: |'T <: Add('R, 'O)| 'T, 'R -> 'O) =
    id x + y
id(x) (: 'T -> 'T) = x

f(10, 1) (: subst_call_ret([inst {10}, inst {1}], inst |'T <: Add('R, 'O)| ('T, 'R) -> 'O))
f(10, 1) (: subst_call_ret([inst {10}, inst {1}], (?T(<: Add(?R<1>, ?O<1>))<1>, ?R<1>) -> ?O<1>))

型変数は、デフォルトではクラスまで拡大されます。

# ?T<1> -> Nat
# ?R<1> -> Nat
# Nat <: Add(Nat, ?O<1>)
f(10, 1) (: ?O<1>)

型制約を解決します。詳しくは型制約の解決を参照してください。

# ?O<1> -> Nat
f(10, 1) (: Nat)