今回のスコープ
今までは一つの開発記で一つのステップについて説明していましたが今回は関連の深い以下のステップをまとめて説明します。1
- ステップ5
- 比較演算と論理演算
- ステップ6
- if文
- ステップ7
- while文とfor文
ステップ5:比較演算と論理演算
条件分岐のためには比較演算を実装しないといけない。
また条件分岐のためには必須ではないけど論理演算もあるとよい。
というわけでこれらを実装するわけですが問題として、&&
と||
(短絡評価)のためには条件分岐(を行う命令)が必要という鶏が先か卵が先かな状況・・・
まあ悩んでいても仕方がないので論理演算を先に実装しました。ステップ5時点でのコードは以下にあります。
https://github.com/junjis0203/joke/tree/step0005
比較演算
比較演算自体は特に難しくありません。
ところで==
と===
の挙動の違いを確認するために仕様を見てみる。ここであっと思ったのですが本筋ではないのであとがきで書きます。
論理演算
さて問題の論理演算です。||
の方で言うと、
-
||
の左側の式を評価する - 左側がtruthyであれば、それを式の値とする
- 左側がtruthyでなければ、右側を評価しそれを式の値とする
何度も書いていますがこれを実現するためには条件分岐が必要です。
というわけでVMに条件分岐命令を実装しました。
case I.JUMP_IF:
{
const val = stack.pop();
if (val) {
return {type: 'JUMP', offset: insn.offset};
}
}
break;
JUMP
を返された側はoffset
の値で次に実行する命令を調整します。
const ret = executeInsn(context, insn);
if (ret) {
if (ret.type == 'EXIT') {
break;
} else if (ret.type == 'JUMP') {
ptr = ptr + ret.offset;
}
}
順序が逆ですが次にこのJUMP_IF
を使って&&
と||
の動作を「実装」するAssembler
の処理です。
&&
と||
はノードを分けようかなと思いましたがBINARY_OPERATOR
には変わりがないのでAssembler
内で分岐するようにしました。
case N.BINARY_OPERATOR:
assembleNode(node.left, insns);
// custom operations is need
if (node.operator == '&&' || node.operator == '||') {
// dup for operation result
makeInstruction(I.DUP);
const rightInsns = [];
assembleNode(node.right, rightInsns);
if (node.operator == '&&') {
makeInstruction(I.UNI_OP, {operator: '!'});
}
makeInstruction(I.JUMP_IF, {offset: rightInsns.length+1});
// dupped value is no need if false
makeInstruction(I.POP);
insns.splice(insns.length, 0, ...rightInsns);
} else {
assembleNode(node.right, insns);
makeInstruction(I.BIN_OP, {operator: node.operator});
}
break;
条件ジャンプのオフセットを得るために「右側」部分を一度別配列rightInsns
を使って「アセンブル」しているのがポイントです。2
やっていることとしては(&&
はひと手間多いので||
で説明します)、
- 「左側」を評価する命令列を実行
- 「左側評価結果」を
DUP
(理由は後で説明します) - 「左側評価結果」がtruthyなら(右側を評価する必要はないので)、右側を評価する命令列を飛ばす(
JUMP_IF
) - 「左側評価結果」がtruthyでないなら、スタックトップを
POP
した後で右側を評価する命令列を実行する - 最終的にスタックには「truthyな左側評価結果」もしくは「右側評価結果」が残っている
実際に、1 || 2;
というプログラムを変換してみると以下の命令列になります。3
[
{ command: 'PUSH', operand: 1 },
{ command: 'DUP' },
{ command: 'JUMP_IF', offset: 2 },
{ command: 'POP' },
{ command: 'PUSH', operand: 2 },
{ command: 'POP' }
]
DUP
をしているのは「左側評価結果」を条件ジャンプにも「式全体の結果」にも使うためです。また、ジャンプしない場合は「左側評価結果」が余計に積まれている状態なのでPOP
を入れています。
undefined
比較演算/論理演算とは関係ないのですが「未定義」を表すundefined
は特殊ですね。
true
、false
、null
はリテラルですが、undefined
は値であり、グローバルオブジェクトのプロパティです。
JOKEは今のところ「グローバルオブジェクト」は用意していないのでグローバルスコープの変数として定義しておきました。4
ステップ6:if文
if文はここまで作ってきたものを使えばできるので難しくありません。
あっ、「無条件ジャンプ」は追加していますね。
https://github.com/junjis0203/joke/tree/step0006
if文を「アセンブル」する処理は以下のようになっています。
case N.IF:
{
assembleNode(node.expr, insns);
const thenInsns = [];
assembleNode(node.thenStmt, thenInsns);
const elseInsns = [];
if (node.elseStmt) {
assembleNode(node.elseStmt, elseInsns);
}
// jump to thenInsns
makeInstruction(I.JUMP_IF, {offset: 1});
// jump to elseInsns
makeInstruction(I.JUMP, {offset: thenInsns.length+1});
// embed thenInsns
insns.splice(insns.length, 0, ...thenInsns);
// jump to last
makeInstruction(I.JUMP, {offset: elseInsns.length});
// embed elseInsns
insns.splice(insns.length, 0, ...elseInsns);
}
break;
少し長いですがやっていることは論理演算のときとほぼ同じです。
- 「条件」を評価する命令列
- 「条件評価結果」がtruthyなら2つ先5に飛ぶ。つまり次の無条件ジャンプを跳び越す
- 条件が成り立たないので、then節(に当たる命令列)を跳び越してelse節(に当たる命令列)に無条件ジャンプ
- then節の命令列
- 最後(今後命令が追加されるところ)に無条件ジャンプ(else節を跳び越す)
- else節の命令列
例えばif (1) 2; else 3;
は以下のように変換されます(出力を短くするためなので、プログラム自体の意味は無視してください)
[
{ command: 'PUSH', operand: 1 },
{ command: 'JUMP_IF', offset: 1 },
{ command: 'JUMP', offset: 3 },
{ command: 'PUSH', operand: 2 },
{ command: 'POP' },
{ command: 'JUMP', offset: 2 },
{ command: 'PUSH', operand: 3 },
{ command: 'POP' }
]
ステップ7:while文とfor文
条件ジャンプと無条件ジャンプができればwhile文とfor文もすぐに実装できます。
https://github.com/junjis0203/joke/tree/step0007
while文
オフセットの計算がめんどくさくなってきますが難しいことはありません。
- 「条件」を評価する命令列
- 1.の値を
!
する(条件がfalsyのときにループを抜けるため) - 「条件の否定」がtrueならループの先に飛ぶ
- ループ本体の命令列
- 条件評価の命令列先頭に飛ぶ無条件ジャンプ
while (i < 10) i++;
を変換すると以下のようになります。
[
// 条件評価先頭
{ command: 'PUSH', operand: 'i' },
{ command: 'LOOKUP' },
{ command: 'PUSH', operand: 10 },
{ command: 'BIN_OP', operator: '<' },
// 評価結果を否定後、trueならループの先の飛ぶ
{ command: 'UNI_OP', operator: '!' },
{ command: 'JUMP_IF', offset: 11 },
// ループ本体
{ command: 'PUSH', operand: 'i' },
{ command: 'LOOKUP' },
{ command: 'DUP' },
{ command: 'PUSH', operand: 1 },
{ command: 'BIN_OP', operator: '+' },
{ command: 'PUSH', operand: 'i' },
{ command: 'ASSIGN' },
{ command: 'POP' },
{ command: 'POP' },
// 条件評価先頭に飛ぶ
{ command: 'JUMP', offset: -15 }
]
for文
for文の仕様(正確にはIeration Statementsと言ってwhileもその一つです)を見て「おや?」と思ったのは以下の部分です。あっ、JOKEはvar
とか知らない子なので省きます。
for ( Expression ; Expression ; Expression ) Statement
for ( LexicalDeclaration Expression ; Expression ) Statement
ちょっと不思議な感じです。2つ目が初期化での変数宣言を表しているのは雰囲気でわかるのですが;
が足りなくないでしょうか。
答えは、LexicalDeclaration
自体が;
を含んでいるので書かれていないというオチでした。
LexicalDeclaration :
LetOrConst BindingList ;
ともかくfor文についても同じ発想で実行コードにアセンブルが行えます。
- forの1つ目(初期化)の命令列
- forの2つ目(条件)の命令列
- 条件を否定し、否定したものがtrueならループ末尾へ
- ループ本体の命令列
- forの3つ目(更新処理)の命令列
- forの2つ目の先頭への無条件ジャンプ
for (let i = 0; i < 10; i++) sum += i;
を変換すると以下のようになります。説明していないPOP
とかもいますがスタックを調整するために入れています。
[
// ループ中のみで有効なスコープを作る
{ command: 'PUSH_SCOPE' },
// forの1つ目
{ command: 'DEFINE', operand1: 'i', operand2: 'let' },
{ command: 'PUSH', operand: 0 },
{ command: 'PUSH', operand: 'i' },
{ command: 'INITIALIZE' },
// forの2つ目
{ command: 'PUSH', operand: 'i' },
{ command: 'LOOKUP' },
{ command: 'PUSH', operand: 10 },
{ command: 'BIN_OP', operator: '<' },
// 評価結果を否定後、trueならPOP_SCOPEまで飛ぶ
{ command: 'UNI_OP', operator: '!' },
{ command: 'JUMP_IF', offset: 19 },
// ループ本体
{ command: 'PUSH', operand: 'sum' },
{ command: 'LOOKUP' },
{ command: 'PUSH', operand: 'i' },
{ command: 'LOOKUP' },
{ command: 'BIN_OP', operator: '+' },
{ command: 'PUSH', operand: 'sum' },
{ command: 'ASSIGN' },
{ command: 'POP' },
// forの3つ目
{ command: 'PUSH', operand: 'i' },
{ command: 'LOOKUP' },
{ command: 'DUP' },
{ command: 'PUSH', operand: 1 },
{ command: 'BIN_OP', operator: '+' },
{ command: 'PUSH', operand: 'i' },
{ command: 'ASSIGN' },
{ command: 'POP' },
// 3つ目の式結果が余計に積まれているのでPOP(一つ上のPOPは別の理由で入る)
{ command: 'POP' },
// forの2つ目先頭に飛ぶ
{ command: 'JUMP', offset: -23 },
{ command: 'POP_SCOPE' }
]
あとがき
以上、今回は条件分岐のための命令を中心に論理演算、if文、while文、for文を実装しました。
ようやく条件分岐とループを実装したので九九やFizzBuzzプログラムを作ることも可能になりました!
ところで、ここまで読まれた方は「何か足りていない」ことに気づかれたでしょうか。
そう、「条件分岐」と言ったら普通紹介されるswitch文を実装していません。
というのもswitch文をまともに動かすためにはbreakが要りますが、breakは今回説明したような「事前に計算できるオフセット」でのジャンプでは実装できない(できたとしてもおそらく簡単にはできない)ためです。「こんな風にすればいいだろ」というアイデアはあるのですがVMの変更が結構必要そうなので先に「固定オフセット」で実装できるif/while/for文までを実装しました。
さてもう一つ、比較演算のところで伏線しておきましたが「演算子の仕様(処理手順)」を見ていてあることに気づきました。
JOKEでは論理演算以外の演算子の動作をJOKE自身を動かしてるJavaScript処理系に丸投げしているのです。
ジョークな処理系とは言えこれは手抜きし過ぎたと反省しました。今から直す気はありませんが
ということで、前回「いやーGC自力で実装してたら死ぬほど大変だった」と発言した反省も含めて、「本気の(演算子の動作等も仕様に書かれている通りに実行する)」JavaScript処理系を作ろうと思いました。名前から決める人間なのでコードネームはもう決まっていて
MAJI: More Adventurous Javascript Interpreter
です。JOKEに対してMAJIですね6。「マジなもの作ります」の意味ですが、作る前から「実装マジめんどくさい」の略ではないかとも言われています。
あ、JOKEの開発はまだ当分、セルフホスティングを達成するまで続けます。次回はbreakとcontinueを実装した後、switch文も実装する予定です。
-
そもそも開発記と開発ステップは一対一対応の予定でもなかったので。 ↩
-
CRubyとかCPythonは初めは「飛び先ラベル」を指定しておき、後から「命令とラベルの距離」を計算してオフセットに置き換えるということをしています。JOKEもそうしようと思ったのですが別配列に作っておくことでラベルを使わなくてもいいことに気づきました。 ↩
-
offset: 2
というのはJUMP_IF
を読み込んだ時点で命令ポインタは4番目のPOP
に移動しているのでそこから2進む(結果最後のPOP
に行く)という意味です。ややこしかったのでHEADでは「自分から見て3つ先」とするように変更しています。 ↩ -
変数なので代入できてしまいます。そこを対応するのはそのうち。 ↩
-
offset: 1
となっていますが脚注3でも述べたようにこの時点では「今の命令ポインタ値」に足される値をオフセットにしています。 ↩ -
MAJIってライブラリ自体は他にあるみたいですが。 ↩