1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

作って理解JavaScript:JOKE開発記その6 - break/continueおよびswitch

Last updated at Posted at 2020-06-24

今回のスコープ

前回の予告通り、以下を実装します。

  • breakとcontinue(JavaScriptはラベル指定breakができるがサポートしない)
  • breakを踏まえたうえでのswitch文

またfor文で1つ目/2つ目/3つ目の式を省略した場合のテストをしていなかったのですが、特に2つ目は本文中でbreakしないと無限ループしてしまうので今ステップでテストすることにしました(なおステップ7段階の実装はもちろん省略に対応できていませんでした)

ステップ8段階のコードは以下にあります(push後に開発記書いててバグってる箇所に気づきました・・・)
https://github.com/junjis0203/joke/tree/step0008-fix1

break/continueのための仕組み

前回、「if/while/forはジャンプする量を事前に計算できるがbreakはできない1、ほかの仕組みを作る必要がある」と書きましたが、その仕組みを実装しました。仕組みは以下のようになります。

  • breakやcontinueを行える制御構造の開始時に「breakされたらここに飛ぶ、continueされたらここに飛ぶ」という情報をスタックに積む。このスタックをbreakableStackと呼ぶ2
  • breakやcontinueが起きたらbreakableStackから情報を取り出して指定された位置にジャンプする
  • breakやcontinueされることなく本文(forなどのStatement部分)を終えたらbreakableStackを一つ下ろす

forはかなりややこしいのでwhileで説明します。ステップ7と比べてPUSH_BREAKABLEPOP_BREAKABLEが追加されています。

assembler.js抜粋
    case N.WHILE:
        {
            const exprInsns = [];
            assembleNode(node.expr, exprInsns);
            const stmtInsns = [];
            assembleNode(node.stmt, stmtInsns);

            const breakOffset = exprInsns.length + stmtInsns.length + 5; // to after POP_BREAKABLE
            const continueOffset = 0;
            makeInstruction(I.PUSH_BREAKABLE, {breakOffset, continueOffset});

            embedInsns(exprInsns);
            makeInstruction(I.UNI_OP, {operator: '!'});
            makeInstruction(I.JUMP_IF, {offset: stmtInsns.length + 2});

            embedInsns(stmtInsns);
            makeInstruction(I.JUMP, {offset: -(exprInsns.length + stmtInsns.length + 2)});

            makeInstruction(I.POP_BREAKABLE);
        }
        break;

PUSH_BREAKABLEの引数であるbreakOffsetcontinueOffsetは名前の通り「breakされたときの飛び先」「continueされたときの飛び先」です。またoffsetという名前が示す通りこの時点では「PUSH_BREAKABLE命令がある位置からの相対位置」です。

VMではPUSH_BREAKABLEを見つけると絶対位置を計算したうえでbreakableStackにpushします3continueOffsetで条件分岐しているのは「breakはできるけどcontinueはできない」ものがあるからですが詳しくはswitch文のところで説明します。

vm.js抜粋
    case I.PUSH_BREAKABLE:
        {
            // compute absolute address
            let ptrBreak = context.ptr + insn.breakOffset;
            let ptrContinue;
            if (insn.continueOffset != undefined) {
                ptrContinue = context.ptr + insn.continueOffset;
            }
            context.breakableStack.push({ptrBreak, ptrContinue});
        }
        break;

ともかくこれでbreakされたときにどこに飛べばいいのかわかるようになったので後はbreakableStackから取り出して絶対ジャンプをするだけです。

vm.js抜粋
    case I.BREAK:
        {
            const breakInfo = context.breakableStack.pop();
            return {type: 'JUMP_ABS', ptr: breakInfo.ptrBreak};
        }

for文の修正

for文は1つ目/2つ目/3つ目の式を省略することができますが、ステップ7段階では考慮していなかったので対応しました(結果、ステップ7に比べてアセンブル処理が長くなりました)。ステップ7に追試として入れようかなと思いましたが2つ目の式省略はbreakがないと無限ループになってしまうのでステップ8としてテストすることにしました。

まあその部分は淡々と長いだけなので今回の主題であるbreak/continue時の飛び先の計算の話をします。

assembler.js抜粋
            let breakOffset = stmtInsns.length + 3; // to POP_SCOPE
            let continueOffset = stmtInsns.length + 1; // to expr3
            if (node.expr2) {
                breakOffset += expr2Insns.length + 2;
                continueOffset += expr2Insns.length + 2;
            }
            if (node.expr3) {
                breakOffset += expr3Insns.length + 1;
            }
            makeInstruction(I.PUSH_BREAKABLE, {breakOffset, continueOffset});

前回の復習として、for文は以下のような命令列にアセンブルされます。breakされた場合は「6.の無条件ジャンプ」の一つ先へ、conitinueされた場合は「5.の更新処理命令列」に飛ぶ必要があります。それを計算しているのが上記のコードです。4

  1. forの1つ目(初期化)の命令列
  2. forの2つ目(条件)の命令列
  3. 条件を否定し、否定したものがtrueならループ末尾へ
  4. ループ本体の命令列
  5. forの3つ目(更新処理)の命令列
  6. forの2つ目の先頭への無条件ジャンプ

switch文の実装

以上でbreakが実装できたのでようやくswitch文です。
まずはいつも通り仕様を確認。

CaseClause :
    case Expression : StatementList

なんとJavaScriptのcaseは「任意の式」が書けるようです!つまり以下のように書くこともできます!

switch (a) {
case b + c:
    console.log('aはbとcを足したものに等しい');
    break;
}

まあ「できる」と「意味がある」は別問題ですが。
JavaScriptではswitchに書かれた「式」とcaseに書かれているものは===で比較されるので書けてもおかしくはないですね。

アセンブル処理

ともかく構文解析を行い、アセンブル処理です。今までで最長。4
まず各部分のアセンブルを行います。

  1. switchの式をアセンブル
  2. 各caseについて式部分(Expression)を別命令配列にアセンブルしておく
  3. 同様に本文部分(StatementList)を別命令配列にアセンブルしておく

次に各部分の命令数を使って「条件にマッチした場合にどれだけジャンプすればいいか」のオフセットを計算します。
言葉だけでは絶対伝わらないので図解すると以下のようになります。ちなみに「case 1の本文」とかからジャンプがないのは間違いではありません。breakは「本文内のこと」なのでswitch自体に「本文実行したから末尾にジャンプ」という機能はありません。5

assemble_switch.png

assembler.js抜粋
            // calculate jump offset(any cool implementation?)
            for (let i = 0; i < labelInfoList.length; i++) {
                let insnsCount = 0;
                if (!labelInfoList[i].default) {
                    for (let j = i + 1; j < labelInfoList.length; j++) {
                        if (!labelInfoList[j].default) {
                            insnsCount += labelInfoList[j].exprInsns.length + 3;
                        }
                    }
                    insnsCount += 1;
                }
                for (let j = 0; j < i; j++) {
                    insnsCount += labelInfoList[j].stmtsInsns.length;
                    if (!labelInfoList[j].default) {
                        insnsCount += 1;
                    }
                }
                labelInfoList[i].stmtsOffset = insnsCount + 1;
            }

最後に「受験分岐を行う命令列」「どの条件にもマッチしなかった場合のデフォルト(または末尾)へのジャンプ」「条件分岐の飛び先の命令列」を埋め込んでいきます。最終的に先に図解したような命令列になります。

assembler.js抜粋
            const labelInfoListWithoutDefault = labelInfoList.filter(c => !c.default);
            const labelInfoForDefault = labelInfoList.find(c => c.default);

            makeInstruction(I.PUSH_SCOPE);
            makeInstruction(I.PUSH_BREAKABLE, {breakOffset});
            for (const labelInfo of labelInfoListWithoutDefault) {
                // dup switch.expr result for remain case
                makeInstruction(I.DUP);
                embedInsns(labelInfo.exprInsns);
                makeInstruction(I.BIN_OP, {operator: '==='});
                makeInstruction(I.JUMP_IF, {offset: labelInfo.stmtsOffset});
            }
            if (labelInfoForDefault) {
                // jump to default label if no match
                makeInstruction(I.JUMP, {offset: labelInfoForDefault.stmtsOffset});
            } else {
                // jump to switch end
                let offset = labelInfoList.reduce((sum, curr) => sum + curr.stmtsInsns.length + 1, 0);
                offset += 1;
                makeInstruction(I.JUMP, {offset});
            }
            for (const labelInfo of labelInfoList) {
                if (!labelInfo.default) {
                    // pop duplicated switch.expr result
                    makeInstruction(I.POP);
                }
                embedInsns(labelInfo.stmtsInsns);
            }
            makeInstruction(I.POP_BREAKABLE);
            makeInstruction(I.POP_SCOPE);
            // pop unused switch.expr result
            if (!labelInfoForDefault) {
                makeInstruction(I.POP);
            }

テストでは以下のように「わざとbreak入れないで次のcase 3部分が実行(出力)されるか」を確認していますが、このほぼバグな動作を実現することがこれほど大変とは思いませんでした(笑)

step0008_21.js
    switch (a) {
    case 1:
        console.log('Apple');
        break;
    case 2:
        console.log('Banana');
        // fallthrough(intendedly)
    case 3:
        console.log('Cherry');
        break;
    default:
        console.log('Other');
        break;
    }

switch文でのcontinue

さて伏線しておいた「breakはできるけどcontinueはできない」ものの話です。具体的にはswitchがそれに該当します。以下のようなプログラムを考えてみましょう6。コメントにあるようにswitch文内に書かれているcontinueはswitch文に作用するのではなくfor文に作用する必要があります。

step0008_22.js
for (let i = 1; i <= 5; i++) {
    switch (i) {
    case 2:
        console.log('foo');
        continue; // goto next for
    case 4:
        console.log('bar');
        break; // goto switch end
    }
    console.log(i);
}

これを実現するために、PUSH_BREAKABLEを再掲すると以下のようにcontinueOffsetは「設定されていたら計算する」ようにしてあります。switchに対応するPUSH_BREAKABLEではこのプロパティは設定されていません(つまりundefined

vm.js抜粋
    case I.PUSH_BREAKABLE:
        {
            // compute absolute address
            let ptrBreak = context.ptr + insn.breakOffset;
            let ptrContinue;
            if (insn.continueOffset != undefined) {
                ptrContinue = context.ptr + insn.continueOffset;
            }
            context.breakableStack.push({ptrBreak, ptrContinue});
        }
        break;

先ほどは飛ばしたcontinue処理ではbrekableStackをたどってptrContinueが設定されているものを使います。なお無限ループになっていますが別途「ptrContinueのあるbreakInfoが積まれる」ことは確認しているので実際に無限ループすることはありません(はず)

vm.js抜粋
    case I.CONTINUE:
        while (true) {
            const breakInfo = context.breakableStack.pop();
            if (breakInfo.ptrContinue) {
                return {type: 'JUMP_ABS', ptr: breakInfo.ptrContinue};
            }
        }

言語機能以外の改造

breakが出てくると命令ポインタの動きが頭の中で追いきれなくなってくるのでデバッグ情報として命令ポインタも出すようにしました。
ここら辺、「どのinsnsListを実行しているのか7」のような情報もいるかなと思いますが未対応です。また、「構文解析結果やアセンブル結果は見たいけど実行はしなくていい」のようなdry runみたいな機能もいるかなと思っていますがいいオプション文字列を思いついていないため未対応です。

その他

最終目標のセルフホスティングについて、「何ができたらセルフホスティングできたと言えるか」を定義していなかったのでテストを追加しました。このテストが通ったらセルフホスティング完了とします。8

step0100.js
import JokeEngine from 'joke';

const program = `
function hello(callback) {
  console.log("Hello");
  callback();
}

hello(() => {
  console.log("World");
});
`;

const joke = new JokeEngine();
joke.run('<program>', program);

あとがき

以上、今回はbreakとcontinueの仕組みをメインに説明してきました。これで制御構造はひとまず終わりです9
次は配列かなと思っていますが、メソッドのことを考えると先にオブジェクトとprototypeを作るべきか?ということも検討中です。

  1. ちなみにCRubyやCPythonはbreakに対して一旦ブロック末尾に対応するラベルに飛ぶようにしておき、後からラベルとbreakが書いてある位置の距離を計算してたはずです(末尾の位置はまだ決まってないのでbreakを見つけた時点では計算できない)。ラベル絶対使いたくないマンというわけではないですがJOKEではこの方法は採用しませんでした。

  2. ちなみに仕様ではwhile/forなどのIterationStatementとswitch(SwitchStatement)はBreakableStatementとしてまとめられています。

  3. このようにしているのはPUSH_BREAKABLEを出力する時点では相対位置しかわからない(whileのstmtなどは別の命令列配列に書き込ませた後マージしている)ためですが書いててやはりラベル埋め込み後から計算法の方が楽かもとも思ってきました(笑)

  4. else ifについてはJavaScriptでは「else節の文」として再帰処理されるためシンプルです。 2

  5. これにより無数のバグが生まれたのはまた別のお話。

  6. 意味があるかは別として。ふと思いついてしまったのでテストしました。

  7. insnsは関数単位になります。詳しくは開発記その4をご覧ください。

  8. すでにコミットしているので、セルフホスティング完了まで延々とテストが失敗します(笑)

  9. 例外処理はクラスを作るまでできない、クラスは実質式ですし、for-ofは配列と合わせて実装するつもりです。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?