Haskellのforallについて理解したことを書いておく(ランクN多相限定)。
Haskellのforallについて理解したことを書いておくyo!(ランクN多相限定*1 )。
前提知識のおさらい: 型・多相型・型検査・型推論…
最初に基本概念を整理しておきます。
- IntやInt->Intは単相型、aやa->aは多相型である。ここでaを型変数と呼ぶ。型変数を含む型が多相型ってわけです。
- 言語処理系の実装上、型という概念は型変数や型コンストラクタのツリー構造として表現される。Int,Char,[],->,(,),(,,,),IO aなどが型コンストラクタ。 a,bが型変数。組合せて(a->[Int])->[b]->(a,b)とか。::の右に書くやつです。
- 型は、プログラムの字面上に直接的実体がある関数や変数だけではなく、値を生じさせる部分式すべてに付随し、コンパイル時に決定されるべき情報である(値あるところに型がある。*2 )。それを決定しようというのが(静的)型検査である。Haskellでは基本としてhindley-milner(以降、HMと記載するときもある)と呼ばれる型推論機構をベースにして多々拡張したもので型推論を行なう。この記事はhindley-milnerのロジック(こちらにscalaのコードがある)を読んで理解したことのみをベースにしHaskellに対して類推したものである*3。hindley-milnerでは型検査は型推論と一体化しているような気がするのでこの記事では「型検査」には「型推論」が含んでいるというつもりで「型検査」という用語を用いる。なお、Hindley-milner型推論の実装については、こちらの記事「Hindley-Milner型推論アルゴリズムをGroovyで書いてみた」もどうぞ。
- 多相型は、1個以上の型変数を含んだ型であり、多相型を解決するための仕組み(後述)を「型スキーマ」もしくは「型抽象」と呼ぶ。型スキーマ中の特定型変数を置換し(結果、単相型が得られるかもしくは単相型に近づく)、その結果を得る操作を型スキーマのインスタンス化と呼ぶ。これは、λ抽象に引数の値を与えると、具体的な結果の値が得られるのと同じ。値の世界のλ抽象は、値を与えると値が返るが、型の世界の型抽象は、型を与えて型が返るような型の世界の関数である。多相型というのは「パラメタを持った型」であり「パラメタに具体値をあてはめることで初めて利用できるような型」なわけです。まあ直感的にそれはそうでしょう。(Javaのジェネリクスも同じだと言える)
HM型検査器の構造と実行
- 「環境」という概念を導入する。環境は識別子*4から型スキーマへの対応表であり型検査に使用する(コンパイル一般のためには、環境は他の情報も含むのかもしれない。ASTとか)。環境は、処理開始時のトップレベルは「組み込み定義」情報だけを含んでいるところから始まり、関数を定義したり、あるいは他モジュールから識別子をインポートすることによって、あるいはラムダ束縛やlet束縛で識別子が定義されるたびに、エントリが拡張されそれぞれのネストレベルで使用される。
- オリジナルhindley-milnerではトップレベルの関数のみ*5に型スキーマを明示的に付与できる*6。
例: func :: a-> Int。これはfunc :: forall a. a -> Intの略記法である。
forallと型スキーマ・型抽象、インスタンス化のタイミング
- forallは型スキーマ(型抽象)を表わし、型の世界のλである。forallをλに機械的に置換してみると良い。先の例func :: λa.(a->Int)について
- (λa.(a->Int)) Int の返り値は、Int -> Int、
- (λa.(a->Int)) [String]の返り値は、[String] -> Int
- 型検査の過程において、プログラム字面上の「識別子の出現」が型スキーマのインスタンス化のタイミングである。なおインスタンス化一発ですべての型変数が消去できるわけではない。構文木をツリーとして再帰的に辿るhindley-milner型検査過程の各ステップで、型変数を媒介として双方向的・全体的に型がきまっていく*7。こんなことが可能なのはある意味驚きであるが、決まるそうなのである。これがHindley-milnerの凄いところである。処理対象プログラム中に識別子が現われたら、環境から型スキーマを引っぱってきてインスタンス化して、構文木構造(関数適用、let、lambda..)ごとに決められたルールで双方向パターンマッチ(ユニフィケーション、単一化)して型を決めていく。
ちなみに型検査器を外部から見て一言で言うと
- 外部から見ると、hindley-milner型検査器は、型変数を含んだ連立程式を解く、制約問題ソルバとみなせる。
ランクN多相は型スキーマを一級市民の型として扱うこと
forallの機能
- 型スキーマのインスタンス化について機能面で言及すべきことがあって、それは型変数名をリネームすることによる衝突回避機能を持つということである(これもλ抽象でできるのと同じ話だが)。型変数に単相型をマッチさせる、という行為に関して、型変数はそれを媒介とした型の一致・不一致の判別の一意性を判定するものであるからして、一致させないためにリネームしたり、リネームの効果がおよぶ特定の範囲では一致させる、というようなコントロールができる。これを利用しているのがかの有名なST Monadで、詳しくはこちら「Lazy Functional State Threads 」(って原論文を読むしかないのか…。しかしweb上では他に理解可能な情報を見付けられませんでした。)。
HMの限界
- hindley-milnerの型検査には双方向性があるため、「ある範囲」を対象とした「型変数の連立方程式」というか制約問題を解く必要があるのだが、そしてGHCはおそらくそれを「トップレベルに定義した関数」という単位でやる*8のだが、hindley-milnerオリジナルでは関数引数の型のインスタンス化について、「関数そのものに付随する型スキーマの実体化」1回分の中で決定しようとするため、識別子の複数回の使用それぞれで異なるインスタンス化ができない。このことは、多相関数を引数にとる関数を定義する際に多相性が1回限りしか発揮できないということなので不便なときがある。例えば、[a]->Intを引数にとる関数([a]->Int)->Intがあったとする。
func:: ([a]->Int)->Int
これは型エラーになる。なぜならf[Int]、f[ [Char] ]というfの出現2つにそれぞれ対応してインスタンス化されることで生成された、f::[Int]->Intとf::[ [ Char ] ] -> Intというボトムアップで定まる2つの型制約を、引数としての識別子fの型が同時に満せないからである。
func f = f [1,2,3] + f["a","b","c"]
ランクN多相の意義と動作例
名前が悪いぞforall
あとは非生産的な話。
- プログラミング言語としてのHakellにおける、forallというキーワードで起動される言語機構を理解するのに、全称量化を理解する必要はないし形式論理学の知識は不要である。「全称量化」という単語を知る必要すら一切ない。それは理解に寄与しない。ていうか理解の妨げになるだけだッ。名称としてそれしかないので「全称量化子」とか「全称量化された」とか呼ぶしかないので、しょうがない面もあるのだろうが、ミスリーディングなだけである。forallというキーワードから「all(全ての)」という含意を読みとる必要すらない。必要があるってのなら値に関して同型であるλについてもどういう意味でallなのか、allを付けないのかプログラミングレベルでの説明を聞かせてほしい。 ∀が型抽象の型表記で慣例的に使われてるのを敬意を表してなのか論理学者は慣れてるからなのか、結果的には無批判に導入しただけだろ、っていう。最終的にはカリー・ハワード同型対応で、そういう方面に応用できるのだとは思うが、Haskellをプログラミングのみに使いたいプログラマにとっては不要なことである。迷惑なことである。
*1:ここを読むと、ランクN多相を実現する以外に2つぐらい意味があると。ExistentialQuantificationについてはきちんと理解してないが理解したら何か書くかも。
*2:加えて、値が無いところにもたまに型がある。
*3:本当は「型システム入門」とかにさぞ詳しく書いてあるであろう。でも読めてません。ごめんなさい。
*4:環境のキーとなる「識別子」はプログラム本体で使われる識別子である。型側でのみ使われる「型変数」の名前ではない。混同しないこと。両者は混在することは決してない。
*5:Haskellでは::でlambda変数とかにも他にも付与できる
*6:例のscalaのコードから読む限りでだが、letで定義する識別子には暗黙に形成された型スキーマが付与される。なのでletを介入させるとランクN多相を導入しなくても型スキーマを複数回インスタンス化できそう(未確認)(2014.01.26追記できない)。この意味でletはlambda+関数適用のシンタックスシュガーではない。haskellもそうなってるようだ。不思議。
*7:最終的にもすべてが単相型に落ちずに、型変数が残ることもあるだろう。つまり多相関数はそれである。
*8:これも予想でしかない。前述のScalaのコードでは、任意の構文木の枝に対して型チェック(tp() )を起動可能だが、それは正しい環境を引数に渡せる前提である。正しい環境を構築するためには、上の方から解析を結局していかなければならないよね。だから関数単位だろうと予想しました。遅延評価とかを使って全然違う単位・順序で解析するのかもしれませんが。
*9:この動作は裏をとってない。SystemFなりHaskellのランクN拡張でどう実装しているかは不明。「私だったらそう実装したい」というレベルの話である。