8-2. 仮想機械

コンパイラ作成にあたって、 最初に決めるべきは、どのような言語のプログラムに変換するかである。 この「行き先の言語」をターゲット言語と言う。

Java では通常、JVM (Java Virtual Macihne) と呼ばれる抽象化された(実在しない)機械の上で動く機械語がターゲット言語である。 この場合、実在のハードウェアでそのまま実行できないので、 JVM を動かすためのインタープリタを用意する。 Intelだろうと他のCPUだろうと、 このインタープリタさえ用意すれば、 (Javaコンパイラ自身は作り変えることなく)、 どのようなマシンでも動かすことができる。

実は OCaml も、普通にコンパイルすると、 本当の機械語 (Intel x86 の機械語など)のプログラムへ変換されるのではなく、 OCaml 用の仮想機械の機械語に変換される。 その仮想機械のインタープリタが、 コンパイルされた(仮想的な)機械語のプログラムを実行している。 (もっとも、OCaml でも Java でも、 本当の機械語に変換するコンパイラも存在しており、native compiler と呼ぶ。 通常は、native compiler でコンパイルしたプログラムの方が、 仮想機械のプログラムよりも高速に動作する。 その2つの実行速度の差は、大きいこともあるし、 ほとんどないこともある(プログラムの性質による)。

ごちゃごちゃ書いてきたが、今回の実験でのコンパイルは、 最適化を(ほとんど)やらないので、 ターゲット言語は、本当の機械語ではなく、 もう少し抽象的なレベルの仮想的な機械でよい。 このような機械を、仮想機械(virtual machine) と言う。 (なお、抽象機械(abstract machine)という言葉もあり、 プログラム言語の理論では「抽象機械」の方をよく使う。 両者は似ているが、若干異なる言葉であり、ここでは「仮想機械」という。) 仮想機械の侯補も非常に多数のものがあるが (JVM のほか、LLVM など) ここでは、コンパイル方式を理解するという目的のため、 以下の教科書の ASEC機械を採用する。

  Dowek & Levy, "Introduction to the Theory of Programming Languages"
  Undergraduate Topics in Computer Science, Springer, 2011.
筑波大学ネットワークからは、 この本の第4章(Compilation)の電子版を入手することができる。

ASEC機械というのは変な名前であり、実は上記の教科書でもそのような 名前は使っていないのだが、この機械がもつ要素が、 以下の4つであることから、名前をつけた。

実行時のある瞬間のASEC機械の状態を図で表現する。(図は、TAの宮部君作成)
この図は、Accumulator の値が3で、スタックには、空リストや true などの 値が積まれ、現在の環境は [4; true; 2; 1; <closure>; false; ...] であり、 実行すべきコードが [Add; Push; Ldi(2); Push; Ldi(4); ...]である状態を示している。 なお、<closure> というのは関数クロージャのことであり、 実際には複雑な構造であるが、図示の都合上、単に<closure>と書いた。

仮想機械の「環境」: 変数名の消去

インタープリタとコンパイラの両方で「環境」がある。 両者は、概念的には同じものだが、一点だけ違いがあるので説明する。

インタープリタにおける環境は、[("x",35);("y",true)] のように、 変数名とその値の対のリストとして表現していた。 一方、仮想機械では、これを [35; true]のように表現する。(ただし、もちろん、 こんなリストはOCamlでは直接表現できないので、 実装の上では、タグをつけて、 [AM_IntVal(35); AM_BoolBal(true)]というようにするのだが、 ここでは説明を簡単にするため、タグをはずして記述する。)

ポイントは、仮想機械での環境には、変数名がない、という点である。 なぜ変数名がなくてよいかといえば、今回のコンパイラでは、 「変数xが、環境の中で、何番目に格納された変数であるか」 をあらかじめ計算しておき、 その数(インデックス)を機械語のコードに埋めこんでおくという方式を取っているからである。 従って、「変数x の変数名が "x"であった」という情報は機械語では不要になり、 かわりに、「環境の 3番目の変数の値を取ってくる」といった命令を持つ。

実例を見よう。let x = 1 in let y = 2 in x + 5 というミニOCamlプログラムをコンパイルして、仮想機械で動かしているとする。 このx+5を実行しているときの(インタープリタでの)環境は、[("y",2); ("x", 35)] である。 コンパイラでは、「x+5を実行しているときに y は環境中の0番目の要素であり、 x は1番目の要素である」という情報を覚えておく、対応する機械語に埋めこむ。 これにより (x+5) を計算する命令例は、 [Ldi(5); Push; Search(2); Add]といった形になる。 この Search(1) が、「環境の中の2番目の要素の値を取り出す」という命令である。 (1番目が0であるので、2番目が1となる。) この操作が、「環境の中の"x" に対応する値を取り出す」より高速に実装できそうだ、というのは、 容易に想像がつくだろう。つまり、コンパイラが頑張って計算することにより、 仮想機械の実行速度を上げているのである。

このための変換は、コンパイラの一部として学生が実装するので、 次の節で、もう少し丁寧な解説をつける。

仮想機械の「値」

インタープリタでは、実行時の値(value)は、整数値、真理値、クロージャ、 再帰クロージャの4種類であった。 この仮想機械でも、ほぼそれと同様であるが、ちょっとした都合で、「環境」 をまるごと「値」と見なしたいことがでてくるので、以下の定義となる。 また、「再帰関数に対応するクロージャ」さえあれば、「再帰関数でないクロー ジャ」は不要なので、そちらに統合する。
type am_value =  
  | AM_IntVal  of int   (* 仮想機械の値に対するタグにはAM_ をつける *)
  | AM_BoolVal of bool       
  | AM_Closure of code * am_env  (* 再帰関数に対応するクロージャ *)
  | AM_Env of am_env  (* 「環境」を1つの値とする *)
and
  am_env = am_value list (* 環境は、1つのスタックフレームに相当する。 *)

仮想機械の命令

命令は、実際の機械語に似せて設計しているが、一部、抽象度の高い(高級言語に近い)命令もある。
type instr =    
  | I_Ldi of int            (* I_Ldi(n) は、整数nを Accumulator に置く (loadする) *)
  | I_Ldb of bool           (* I_Ldb(b) は、真理値bを Accumulator に置く (loadする) *)
  | I_Push                  (* Accumulatorにある値をスタックに積む *)
  | I_Extend                (* Accumulatorにある値を環境に積む (環境を拡張する) *)
  | I_Search of int         (* I_Search(i) は、環境の i 番目の値を取っきて Accumulatorに置く *)
  | I_Pushenv               (* 現在の環境を、スタックに積む *)
  | I_Popenv                (* スタックのトップにある環境を、現在の環境とする *)
  | I_Mkclos of code        (* I_Mkclos(c) は、関数本体のコードが c で、
			     * その環境が、現在の環境であるような関数
			     * クロージャを生成し、それをAccumulatorに置く。
			     * なお、関数の引数は、変数名を除去してい
			     * るので、このクロージャに含まれず、0番目
			     * の引数として指定される。
			     * なお、この関数クロージャは、再帰関数で
			     * あるとして処理される。
			     *)
  | I_Apply                 (* Accumulatorに関数ある値が関数クロージャ
			     * であるとき、その関数を、スタックトップ
			     * にある値に関数適用した計算を行なう。
			     *)
  | I_Test of code * code   (* I_Test(c1,c2)は、Accumulatorにある値が
			     * true ならば、コードc1 を実行し、false
			     * ならばコード c2 を実行する。
			     *)
  | I_Add                   (* Accumulatorにある値にスタックトップの値
			     * を加えて結果をAccumulator にいれる
			     *)
  | I_Eq                    (* Accumulatorにある値とスタックトップの値
			     * が同じ整数であるかどうかをテストして結
			     * 果をAccumulator にいれる
			     *)
and
  code = instr list  (* コードは、命令の列である *)
コードの簡単な例: ((1+2)+3)+4 に対応する命令列
  [I_Ldi 4; I_Push, I_Ldi 3; I_Push; I_Ldi 2; I_Push; I_Ldi 1; 
   I_Add; I_Add; I_Add]
もっと複雑なコード例は、後述の課題にあらわれる。

仮想機械の実行

コードが与えられたとき、ASEC機械をどのように実行するか (機械の実行を「遷移(transition)」と呼ぶこともある)を見てみよう. これは,上記の本の1ページを引用したものである。

この表の読み方: 「-」の記号ではじまる各行が1つの遷移を表す. たとえば,1行目は,ASEC機械が(a,s,e,((Mkclos i), c)) という状態にあるとき, 次の状態は,(<i,e>,s,e,c) であることを示す. ((Mkclos i), c) というのは,この実験テキストの表記では, I_Mkclos(i)::c という命令列のことであり, 要するに,「命令列の先頭が I_Mkclos(i) である場合」を意味する. これは,クロージャを生成してAccumulator に置く命令であるので, 次の状態では,<i,e> というクロージャ (実験テキストの表記では AM_Closure(i,e)となる)が Accumulator に置かれている. また,このとき,スタックと環境は変化しないので,s,e がそれぞれそのまま置かれている. さらに,コード(命令列)は,先頭のMkclos(i) が消費され, 残りのコードc だけとなっている. 他の命令の意味も同様に解釈してほしい. ただし,本実験では,ミニOCaml言語にあわせて, 上記テキストのASEC機械を若干変更している.

ASEC機械は、上記の遷移を繰返してコード(命令列)を消費していき, コードが空になったら Accumulator にある値を最終結果として返す。 ただし、その時、スタックが空にもどっていなかったり、環境が空でなければ、 何らかのエラーなので(ミニOCamlのプログラムを正しくコンパイルしたコード であれば、そのようなことは起きないはずなので)、エラーとする。

仮想機械の実装

ASEC機械に対するインタープリタ、 つまり、ASEC機械の状態を受けとると、それを上記の実行方式に従って実行し、 最終的な答えを返す関数 trans の実装例はここにある。 transは、4つの引数(A,S,E,C に対応する)を受けとり、ASEC機械の実行による 最終的な計算結果を返す。

また、関数 am_eval は、Accumulator の値が 0, スタックが空スタック、 環境が空環境である状態から始めて、コードを実行したときに得られる最終結果を返す関数である。

課題 8-2.


トップ, 前へ, 次へ.

亀山幸義