前章で構文解析まで終了しているので、今回はシンボルテーブルの管理とコード生成の部分を完成させていく。
11 章 コンパイラ#2: コード生成
シンボルテーブルの実装
- まずは変数の管理から。本文に記載されている通り、
- サブルーチンスコープ
- argument: サブルーチンの入力を指定する仮引数 (パラメータ変数)
- local: サブルーチン内のみで有効なローカル変数。
varで宣言
- クラススコープ
- field: C++ や Python でいうクラスのメンバ変数
- static: クラスの先頭で宣言される変数であり、そのクラスから生成される全てのオブジェクトで共有される
に Python の dict の形で格納していく。参照するときは当然サブルーチンスコープを優先する。
- サブルーチンスコープ
- 次に関数の方を何とかしなくてはならない。Jack 言語には 3 種類の Subroutine があり、
- function: クラスに附属し、インスタンスに依存しない。呼び出すときは
className.subRoutineName() - constructor: 慣例的に new という名前が用いられる。呼び出すときは function と同じ
- method: C++ や Python でいうメンバ関数。インスタンス依存。呼び出すときは、同一クラス内なら単に
subRoutineName()、そうでなければinstanceName.subRoutineName()
となる。
- function: クラスに附属し、インスタンスに依存しない。呼び出すときは
- サブルーチンの種類が method である場合、call 時にインスタンスの参照を隠れ引数として push しなければならないため、call をコンパイルする際にはサブルーチンの種類判定が必要になる。ここで、コンパイラが関数の call に遭遇した際の挙動を考えてみる。
subRontineName()の際は method で確定。hoge.subRoutineName()の際は、hogeが既に宣言されており上記の変数シンボルテーブルに登録されていればインスタンス (つまりこのサブルーチンは method)、そうでなければhogeはクラス名でサブルーチンは constructor あるいは function であることが確定する。 - 呼び出し時にサブルーチンが method であるかどうかさえ判定できればよいので、サブルーチンのシンボルテーブルをわざわざ作成しなくてもとりあえずコンパイルは可能である。当該クラスにそのサブルーチンが本当に存在するかどうかの判定は行わないことになるため、ない関数を書いても一切のエラーを吐かない激ヤバコンパイラが出来上がることになるが……。
コード生成
ここからは、各処理の概要とそれぞれの躓きポイントをメモしていこうと思う。実際の作業は、公式で提供されているコンパイラで .jack から .vm の正解ファイルを生成し、自分のコンパイラから出力されたファイルと正解のファイルを比較することにより行った。
- サブルーチン宣言のコンパイル
- サブルーチンの種類が何であれ、VM コード上では
function表記になるので注意。Mainクラス内にあるhogeというサブルーチンがあり、内部でn個のローカル変数が定義される場合の宣言はfunction Main.hoge nとなる。nの値自体は subRoutineBody をコンパイルしてみないと分からないため、とりあえず空欄にしておき処理後に追記する方針とした。 - サブルーチンの種類が method のときは
thisがオブジェクトのベースアドレスを指すようにしなくてはならないため、push argument 0pop pointer 0を宣言後すぐに書く。 - サブルーチンの種類が constructor である場合は field 変数のためにメモリ割り当て作業も行う必要があるため、
push constant ncall Memory.alloc 1pop pointer 0を宣言後すぐに書くことになる。
- サブルーチンの種類が何であれ、VM コード上では
letのコンパイルlet a[1] = ...を処理するときは、次のようなコードを生成することになる。1 2 3 4 5 6 7 8
push constant 1 // 添字 push local 2 // a が 3 番目に宣言されたローカル変数の場合 add // これで、スタックトップが a[1] のアドレスになる // (右オペランドを処理してスタックトップに持ってくる) pop temp 0 // 右オペランドの処理結果を temp 0 に退避する pop pointer 1 // that の指す先をスタックトップ、つまり a[1] にする push temp 0 // 退避しておいた右オペランドの処理結果をスタックトップに戻す pop that 0 // 右オペランドの処理結果を that の指すアドレス、つまり a[1] の位置に格納
whileのコンパイルwhile 文のコンパイル結果は以下のようになるので、これを目標にコード生成をすればよい。
1 2 3 4 5 6 7
label WHILE_EXP0 // while () のカッコ内に入る expression を処理 not if-goto WHILE_END0 // カッコ内が false の場合、ここが true になり末尾に跳ぶ // {} 内の statements を処理 goto WHILE_EXP0 label WHILE_END0
while 文の始まりと終わりに固有のラベルを振って制御することになるが、ここで他の while 文のラベルと被らないようにラベルの末尾にカウンタを振ることにする。ここで注意すべきは while 文が多重になっているときであり、この場合 statements 処理時に再帰で呼び出しまくっていると while 文のカウンタがめちゃくちゃになりうる。今回のコードでは、クラスのメンバ変数で管理しているカウンタとは別に関数内でカウンタの値を保持しておく方針とした。
ifのコンパイルコンパイル結果は以下のようになる。while 同様、多重になっている場合の処理に注意が必要。
1 2 3 4 5 6 7 8 9
// (expression) の処理 if-goto IF_TRUE0 goto IF_FALSE0 label IF_TRUE0 // {true のときの statements} goto IF_END0 label IF_FALSE0 // {false のときの statements} label IF_END0
returnのコンパイルreturn hoge;の場合はhogeを処理してスタックトップに載せてから単にreturnと書けばよい。- void の関数であり単に
return;の場合でも、簡単のためpush constant 0returnと疑似的に 0 を返すようにする。
doのコンパイルdo subRontineName()の場合- 上述の通りその関数は method であることは確定なので、
push pointer 0をすることにより this の指す先、つまりオブジェクトの参照を隠れ引数として push してから他の引数を push し、その後call className.subRoutineName nする必要がある (nはパラメータの数)。
- 上述の通りその関数は method であることは確定なので、
do hoge.subRoutineName()の場合hogeがインスタンスの場合その関数は method であるため、まずpush local 0などでインスタンスを隠れ引数として push する。他の引数を push したのち、インスタンスの型名 (つまりそのインスタンスの生成元であるクラス名) を取得してcall className.subRoutineName nとする。- それ以外の場合
hogeはクラス名であるため、単にcall className.subRoutineName nとすればよい。
- call の末尾につけるべき
nはパラメータ処理の後でないと判明しないため、とりあえず空欄にしておいて後で埋める方針を採った。method の場合は +1 すべきなので注意。 - 上述の通り、void の関数を実行したとしてもスタックトップには 0 が残ってしまう。これを解決するため、いずれの場合でも末尾に
pop temp 0を追記して値を捨てる。
- expression のコンパイル
- 例えば
a - bのコンパイルの場合、push apush bsubとすればよい。 - ややこしいのが、単なる
-bのときはpush bnegとしなければならないこと。-記号が登場した場合は、それが expression の先頭に位置する token でないかどうかをチェックする必要がある。
- 例えば
- term のコンパイル
letのコンパイル時にも問題になったが、a[1]のような term の処理は以下のようにする。
1 2 3 4 5
push constant 1 // 添字 push local 2 // a が 3 番目に宣言されたローカル変数の場合 add // これで、スタックトップが a[1] のアドレスになる pop pointer 1 // that が a[1] を指すようにする push that 0 // that の指す先、つまり a[1] の中身をスタックトップに持ってくる
- 文字列処理
- まず文字列の文字数
lを取得し、push constant lcall String.new 1とする。 - アルファベットの指定は Unicode コードポイントを用いて行うことになり、これは Python では組み込み関数の
ord()で実現できる。末尾に a を追加した場合、push constant 97call String.appendChar 2を呼べばよい。
- まず文字列の文字数