7-5. 単純な式に対する型推論

型検査では、式だけでなく、 その中に含まれる変数の型を「型環境」として入力として与えていた。 本節では、型環境を入力としては与えずに、 式だけから、「その式の型」と「型環境」を導く型推論 (type inference)について学ぶ。

まず、原理を理解するため、もう一度、式の構文を簡単にして、「関数」と「関数適用」を排除する。 型の定義においても、TArrow (関数型)を除く。

type exp =
  | Var of string
  | IntLit of int
  | BoolLit of bool
  | Plus of exp * exp 
  | If of exp * exp * exp 

type ty = TInt | TBool | TVar of string
ここで、型の定義には TVar(s) の形の型が追加されている。これは、 「未知の型をあらわす変数(型変数)」を後で使うためである。

型推論の考え方

型推論では、式 e の型を推論するとき、 その中に含まれる変数の型が (型推論の入力としては)与えられないので、推論する必要がある。

変数が1回しか出現しないとき、 その変数の型は何でもよいので、式e 全体の型推論が成功するように、都合の良い型をつければよい。 たとえば、(if x then 10 else 20) では x を bool型にすればよく、 (if true then x else 20) では x を int型にすればよい。 しかし、x が2回以上出現するときは、その全ての出現箇所で、 x は同じ型を持たなければならない。 この事を実現するためには、

  1. 最初は x の型は「未知の型」としておき、
  2. その後、x の型が(int や bool という)具体的な型であることをが判明したとき、 「未知の型」をその具体的な型で置き換える。
  3. その後は、x の型がその具体的な型であるとして使う。
という手順を踏むと良い。

ここで大事なことは、「x の型が未知の型から具体的な型に変更された」という情報を、受け渡すことである。 たとえば、(e1 + e2) の形の式の型検査では、単に e1 と e2 を独立に型検査するのではなく、 e1 を型推論した結果、変数x 等の型が具体的に判明したときは、 その情報を使って、e2 の型推論を行うのである。 上記のことを実現するため、型検査で使った「型環境」を使う。 結局、型推論器 tinf1 は、入力にも出力にも型環境をとり、以下の型をもつ。

  tinf1 : tyenv -> exp -> tyenv * ty 
なお、この型は、tinf1 : (tyenv * exp) -> (tyenv * ty) とすることもできる。

ちなみに、上記のような型では、tinf1 は tyenv型の引数を取るので、 「型推論は、式のみを受けとって、その型と型環境を返す」と予告したことに矛盾する、と思う人もいるだろう。 しかし、tinf1 の引数として現れる型環境 (tyenv型の引数)は、 tinf1 の処理の途中(再帰呼び出しを行っている最中)に必要になるのであって、 一番最初にtinf1 を呼びだす時には、空の型環境を与えるので、問題ない。 言い換えると、最初は、空の型環境(どの変数の型もわからない状況)から始めて、 型推論が進むにつれて、式の型が判明するとともに、変数の型も具体的に判明するのである。

上記で述べた型環境を扱う手順を整理すると、以下の通りである。

  1. 型推論において、変数 x が初めて出現した時、 x の型は「未知の型α」とする。 ただし、αは型変数であり、新しい変数があらわれるごとに新しい型変数を 取るものとする。
  2. その後、変数 x の型が具体的に判明したとき (x の型が TInt や TBool と一致する事がわかったとき)、 型環境における変数 x の型をその具体的な型に置き換える。
  3. その後の型推論で、x の型が必要になった時は、その具体的な型を使う。

問題(提出課題ではない)

上記の考え方に基づいた型推論システムを実装する前に、頭の中で動かしてみよう。 ただし、ここでは、ミニOCaml ではなく、OCamlの構文で記述している。

型推論の考え方(つづき)

上記の問題の最後のケースのように、型推論の結果が2通り以上あると 厄介である。たった2個ぐらい良いじゃないかと思う人もいるかもしれないが、 2通りの型をもつ変数を10個持つ式があったとすると、2の10乗 で1024通り もの結果をもってしまう。これでは、型推論結果を表示するだけで大変である。 実は、OCaml 等の型システムは、このような事態をうまく 避けられるように設計されている。具体的には、型推論の結果が複数あるとき でも、「それらの結果全体を示す代表」が1つ選べるようになっているのであ る。これを一般的に述べるのは、「計算論理学」などの授業に譲るとして、 ここでは、例で考える。

上記の問題の例である (if x then y else y) に対する 型推論の結果は複数あるが、そのどちらも、 「e3 の型が α、型環境が x:bool, y:α」というパターンで 表現されている。ここでαは型変数であり、 1つ目の結果は「α=bool」とすることにより得られるし、 2つ目は、「α=int」とすることにより得られる。 この場合、 「e3 : α, 型環境が x:bool,y:α」は、「最も一般的な結果(型付け)」 と呼ばれる。最も一般的な型付けは、OCaml の型システムでは(型が付く式に 対しては)常に唯一存在するこ とが保証されている。

そこで、型推論システムの実装では、「最も一般的な型付け」を 求めるように設計すればよい。 実は、今回の対象言語では、 特に難しい事を考えずに普通にアルゴリズムを書くと、 最も一般的な型付けが得られるので、あまり難しく考えなくてもよい。

以上の考え方で,型推論器 tinf1 を実装する. まず、準備として、「新しい型変数を作る」関数を定義する。 本節の範囲では、(通常の値をあらわす)変数1つにつき、型変数1つが生成される だけなので、通常の変数をそのまま使ってもよいのだが、 ディバッグなどの際に区別しやすくするため、 通常の変数名の前に "'" (quotattion mark)という文字をいれたものを型変数の名前とする。

(* new_typevar : string -> ty *)
let new_typevar s = TVar("'" ^ s)
そこで、早速 tinf1 を書きはじめよう。まずは、型推論の対象の式e が 整数定数や真偽値定数のときであるが、これは簡単である。
(* tinf1 : tyenv -> exp -> tyenv * ty *)
let rec tinf1 te e =
  match e with
  | IntLit(_)    -> (te, TInt)
  | BoolLit(_)  -> (te, TBool)
このように、型検査では TInt や TBool だけを返していたが、ここでは、 型環境との対について返す。これらの推論では、変数の型が新たに具体的に判 明することはないため、型環境自体は変更ない。よって、tinf1 の引数で あった型環境 te をそのまま、(te, TInt) 等という形で返している。

次に、式 e が変数の場合である。変数名を s とする。 この場合は、s が型環境 te に含まれているときは、その型を返せばよい だけであるが、s が型環境に te に含まれていない時( x が初めて出現した時)、 新しい型変数αを作り、(s : α) という情報を型環境に追加する必要がある。 この措置を以下に書く。

  | Var(s)    ->  
        (try 
          let t1 = lookup s te in
            (te, t1)
	 with Failure(_) ->   (* 変数s が型環境 te に含まれなかった時 *)
            let tvar = new_typevar s in
            let te1 = ext te s tvar in
               (te1, tvar))
まず、try e1 with Failure(_) -> e2 という構文を説明する。 これは、OCaml において、「Failure による例外」を受け止めるための構文であり、 Java の try-catch と同じである。上記のプログラムでは、 lookup s te の計算が(s が te環境にはいっていないために) failwith によるエラーを発生させたとき (これは、正確には「エラー」ではなく、Failure 例外であるので)、 これを受け止める処理を Failure(_)-> の後に書いているのである。 この時、new_typevar s により、新しい型変数をつくって、それを tvar に格納し、 型環境 te に (s,tvar) を追加して (関数ext は環境を操作するものであり lookup等とともにずっと以前に定義した)、追加後の型環境 te1 と 式e の型(すなわち変数sの型)とを対にして返す。 なお、Failure が発生しないときは、変数s の型が、型環境te にはいってい た、ということなので、その型を teと対にして返せばよい。(e1 で例外が発生しな いとき、try e1 with e2 は単に e1 の値を返す。)

最後に、(e1+e2) の形の式の処理である。 e1 や e2 の型推論をおこない、その結果を使って (e1+e2) の型推論を行う、 という方針は型検査のときと同じであるが、意外に面倒である。なぜなら、 e1 の型推論の結果が TInt や TBool という具体的な型とは限らず、 型変数であるかもしれないからである。 ともかく処理を書くと以下のようになる。

  | Plus(e1,e2)  -> 
       let (te1, t1) = tinf1 te e1 in
       let te2 = 
         begin
          match t1 with
          | TInt -> te1
          | TVar(s) -> substitute t1 TInt te1
          | _ -> failwith "type error in Plus"
         end
       in 
       let (te3, t2) = tinf1 te2 e2 in
       let te4 = 
         begin
          match t2 with
          | TInt -> te3
          | TVar(s) -> substitute t2 TInt te3
          | _ -> failwith "type error in Plus"
         end
       in 
         (te4, TInt)
上記の処理を少し解説しよう。 まず、(tinf1 te e1) により、e1 の型を推論し、その結果(新しい型環境 te1 と、e1 の型t1 の対)をもらう。 次に、型t1 により場合分けする。型検査のときは、t1=TInt でなければいけ なかったが、今やっている型推論では、t1=TInt 以外に、t1が型変数(未定)で ある場合も考慮しなければならない。すなわち、処理としては、 (1) t1=TInt のときは、特に問題ない(何もしない)が、(2) t1が型変数のとき は、新たに「t1はTIntだった」ということがわかるので、その事実を型環境 te1 に伝える。(型環境te1 が t1 を含む可能性があるので、その部分を TInt で書きかえる。) (3) t1 がそれら以外(TBoolのとき)は、型が整合しないの で、failwith でエラーにする。

このように、最初の 6行は、e1 の処理であり、その結果として te2 という型 環境を得た。その次の6行で、e2 の処理を同様に行ない、その結果として te4 という型環境を得る。最終的に返すのは、(te4, TInt) である。 e1 と e2 に対して同様の処理をやっているので、結構長くなってしまった。

なお、(substitute t1 TInt te1) というのは、「型環境 te1 における 型変数t1 を TInt に変更した型環境」を返すものであり、 以下のように実装される。

(* substitute : ty -> ty -> tyenv -> tyenv *)
let rec substitute tvar t te =
   match te with
   | [] -> []
   | (x,t2) :: te2 -> 
       let t3 = (if t2 = tvar then t else t2) in
       (x,t3) :: (substitute tvar t te2)
さて、Plus と同様の方針で、If の処理を書くと以下のようになる。
  | If(e1,e2,e3) -> 
       let (te1, t1) = tinf1 te e1 in
       let te2 = 
         begin
          match t1 with
          | TBool -> te1
          | TVar(s) -> substitute t1 TBool te1
          | _ -> failwith "type error in IF"
         end
       in 
       let (te3, t2) = tinf1 te2 e2 in
       let (te4, t3) = tinf1 te3 e3 in
         begin
          match (t2,t3) with
          | (TInt,TInt) -> (te4, TInt)
          | (TBool,TBool) -> (te4, TBool)
          | (TInt,TVar(_)) -> 
	      let te5 = substitute t3 TInt te4 in (te5, TInt)
          | (TVar(_),TInt) -> 
	      let te5 = substitute t2 TInt te4 in (te5, TInt)
          | (TBool,TVar(_)) ->
	      let te5 = substitute t3 TBool te4 in (te5, TBool)
          | (TVar(_),TBool) -> 
	      let te5 = substitute t2 TBool te4 in (te5, TBool)
          | (TVar(_),TVar(_)) -> 
	      let te5 = substitute t2 t3 te4 in (te5, t3)
          | _ -> failwith "type error in IF"
         end
簡単に解説する。 まず、e1 の型推論を行ない (te1, t1) を得る。 この t1 は真理値型でなければいけないのだが、型変数である可能性もある。 その場合は、subsitute により TBool と一致させる。 そのあと、e2 と e3 の型推論を行う。 if-then-else の型が整合するためには、e2 と e3 の型が一致している必要がある (一致してさえすればよく、TInt でも TBool でもよい)ので、 それらの型である t2, t3 で場合分けする。両方とも TInt や TBool のときは簡単であるが、 どちらか一方が型変数のときは、substitue を呼ぶ必要がある。

一番最後のケースとして、t2も t3 も型変数、ということがある。たとえば、 (if true then x else y) という式の型推論をしているときに、 このケースになる。この場合「x の型」と「yの型」が一致しなくてはいけないが、 それらが具体的に TIntでもTBool でもよい(そのどちらであるかは、現段階では未定) であるので、「xの型」を「yの型」で置きかえている。 上記のプログラムでは、(subsitute t2 t3 te4) といった計算をしている。 ここでは、もちろん、(subsitute t3 t2 te4) としても良い。 ただし、その場合、残る型変数が t2 になるので、 返すものが (te5, t3) でなく (te5, t2) となる。

本実験の最初に、「OCaml ではアルゴリズムを簡潔に記述できる」と述べたにもかかわらず、 match文の処理がこんなに長くなってしまったのは、 おかしいと思う人もいるかもしれない。たしかにそうであるが、 実は、ここでは、次の節で述べる「単一化」の考え方を使わずに実装しようとしたため長くなった、 というのが真相である。

最後に、関数 tinf1 の使いかたであるが、 型環境の初期値は空の型環境であるので、以下のようにすればよい。

   tinf1 [ ] e 
この計算は、正常終了して 「型環境と型の対」を返すか、あるいは、エラー(正確には Failure例外)となって終了するかのどちらかである。前者のときは、型環境は、式e に含まれる自由変数の型を与え、型は、式e の型を与える。これらは「最も一般的な型付け」であり、型変数が含まれることがある。一方、エラー終了したときは、式e は(その中の自由変数にどんな型を与えても) 型が整合しない。

発展課題 7-5. 前半

ちょっと横道: リストに対する Iterator

関数substitute の定義は、上記のものでもよいが、ライブラリにある関数 List.map を使うと以下のようにも書ける。
(* substitute : ty -> ty -> tyenv -> tyenv *)
let rec substitute tvar t te =
   List.map (fun (x,t2) ->
             if t2 = tvar then (x,t) else (x,t2))
            te
List.map という関数は、いわゆる iterator (繰返しを行うもの)であり、(List.map f [a1;a2;a3;...]) は、[(f a1); (f a2); (f a3); ...] と同じ意味を持つ。つまり、関数 f をリストの全ての要素に適用するものである。また、(fun (x,t2) -> e) という関数は、引数が1つだけであるのだが、その引数が (x,t2) という対の形に分解されるものである。これは、(fun y -> let (x,t2) = y in e) と、同じ意味である。

発展課題 7-5. 後半


トップ, 前へ. 次へ.

亀山 幸義