作って理解JavaScript:JOKE開発記その4 - 関数


今回のスコープ

いよいよ関数の定義と呼び出しを実装します。機能としては

  • 宣言としての関数定義(ただし、巻き上げは行わない)
  • 関数式での定義(アロー関数はまだ実装しない)
  • デフォルト引数はサポート。分割代入やrest引数は今後の課題

テストプログラム

というわけでテストプログラムです(合計5個)

step0004_01.js
/// 関数を宣言して実行
function square(x) {
    return x * x;
}

console.log(square(3));
console.log(square(4));

JavaScriptの特徴として定義されているよりも少ない引数で呼べるというものがあります。というわけでそれもテストします。1
undefinedと表示されるだけでは何もおもしろくないのでデフォルト引数も実装することにしました。

step0004_02.js
// 少ない引数での呼び出しとデフォルト引数のテスト
function func(a, b=1) {
    console.log(a, b);
}

func();
func(2);
func(2, 3);

step0004_01.jsと同じことを関数式で書いたものがstep0004_03.jsです。

step0004_03.js
// 関数式
const square = function(x) {
    return x * x;
};

console.log(square(3));
console.log(square(4));

と、ここまでできれば開発記その1に書いていたコールバック呼び出しもできるはずなのでstep0004_04.jsとしました(開発記その1ではアロー関数で書いていますが)

step0004_04.js
// コールバック(関数を引数として渡す)
function hello(callback) {
  console.log("Hello");
  callback();
}

hello(function() {
  console.log("World");
});

関数が「自分の外」で定義されている変数にアクセスできるかも確認したい、それってクロージャというのではということでクロージャのテストも書きました。2

step0004_05.js
// クロージャ
function makeCounter() {
    let counter = 0;
    return function() {
        return counter += 1;
    };
}

const counter1 = makeCounter();
const counter2 = makeCounter();

console.log(counter1());
console.log(counter2());
console.log(counter2());

以上5つを通すのが今回の目標です。
ただし、「言語仕様」的にはstep0004_03.jsまで動けばstep0004_04.jsstep0004_05.jsは普通に動くはずです(ということもあり、一つのステップ内に入れました)

ステップ2のバグ修正

前回のあとがきで、

言語仕様以外で結構改造しましたがエンバグしていないかはテストがちゃんと通るかで確認しました。テスト書くの大事ですね。
なおテストを書いていないところが変なことになっている可能性はある。

フラグ立てたつもりはなかったのですがステップ4を検討してるうちに「あれ?これちゃんと動くかな」というものを思いつきました。

step0002_04.js
// {}の外で宣言されている変数を変更
let s = "foo";
{
    s = "bar";
}
console.log(s);

まあ書いていることからわかるようにバグってました。
言い訳すると代入を実装したのはステップ2-2でその時点ではまだ{}によるスコープの入れ子は存在していなかったためです。

というわけでステップ4に進む前にステップ2をバグフィックスしました。
まずstep0002に戻ってブランチを生やします。

git checkout step0002
git checkout -b step0002-fix

バグ修正してコミットしてタグ付けてorigin(github)にもpush。

git commit
git tag step0002-fix1
git push origin step0002-fix
git push origin step0002-fix1

修正をステップ3にも適用します。
まだステップ4を作り始めていないのでmasterにマージしました。

git checkout matser
git merge step0002-fix
ステップ2修正分にステップ3で入れた変更を追記
git commit
git tag step0003-fix1
git push origin step0003-fix1

期せずして最新ではないバージョンにバグが見つかったときのブランチルール等を考える機会が持てました(笑)

関数機能の仕様と実装

それでは改めて関数に関わる部分について説明していきます。実装コードは以下にあります。
https://github.com/junjis0203/joke/tree/step0004

構文解析

構文解析は基本的に仕様に書かれている通りに行うだけです。
少しだけ注意が必要なのは「関数宣言」が「関数式」として解析されてしまわないようにStatementListItemでDeclarationを先にしないといけない点です。関数宣言を実装した後に関数式を実装したところテストがNGになったことで発覚しました。

ともかく、構文解析して以下のようなノードが作れるようになりました。

step0004_01.jsの構文解析結果抜粋
  {
    type: 'STATEMENTS',
    statements: [
      {
        type: 'FUNCTION_DECLARATION',
        name: 'square',
        params: [
          {
            type: 'IDENTIFIER',
            identifier: 'x',
          }
        ],
        body: {
          type: 'STATEMENTS',
          statements: [
            {
              type: 'RETURN',
              expr: {
                type: 'BINARY_OPERATOR',
                operator: '*',
                left: {
                  type: 'IDENTIFIER_REFERENCE',
                  identifier: 'x',
                },
                right: {
                  type: 'IDENTIFIER_REFERENCE',
                  identifier: 'x',
                },
              },
            }
          ],
        },
      }
    ],
  }

関数の「実行コード」

問題は構文解析でできたノードをどのような「実行コード」に変換するかです。
これについては過去に読んだCRuby、CPythonを参考に以下のようにしました。

  • 関数に対して一つの「命令列」を対応づける
  • つまり、今まではトップレベルに対応する一つの命令列だけだったが今後は一つのノードから複数の命令列(命令列の配列)が返されることになる
  • 「関数本体」の命令列には通番を振り、「関数を作成する」命令では関数本体の番号を参照する(関数本体を関数作成命令の引数とすることはJavaScriptで書いているので可能だが、入れ子になってダンプが見にくくなる等の理由により参照形式にした3

実際に実行コードへの変換が行われた結果は以下のようになります。結局「ref: 1ってどれやねん」ということを探さないといけない罠となりましたが。

step0004_01.jsに対応する命令列抜粋
  [
    // トップレベルに対応する命令列
    [
      // 「関数オブジェクト」のpush。refは関数本体への参照
      {
        command: 'PUSH',
        operand: { name: 'square', params: [ { name: 'x' } ], ref: 1 },
      },
      // 関数を「作る」命令
      {
        command: 'MAKE_FIUNCTION',
      },
      // ここから3つは「関数名の変数」への代入
      {
        command: 'DEFINE',
        operand1: 'square',
        operand2: 'let',
      },
      {
        command: 'PUSH',
        operand: 'square',
      },
      {
        command: 'INITIALIZE',
      }
    ],
    // square関数に対応する命令列
    [
      {
        command: 'PUSH',
        operand: 'x',
      },
      {
        command: 'LOOKUP',
      },
      {
        operand: 'x',
      },
      {
        command: 'LOOKUP',
      },
      {
        command: 'BIN_OP',
        operator: '*',
      },
      {
        command: 'RETURN',
      }
    ]
  ]

関数の「実行」

関数の「作成」

先ほど「関数オブジェクト」として載せたもの

関数オブジェクト?
{ name: 'square', params: [ { name: 'x' } ], ref: 1 }

しかしこれだけでは関数を「実行」するには情報が足りません。
JavaScriptでは「自分が定義されたコンテキストからアクセスできる変数への参照」が必要です。つまりクロージャですね。

というわけで「実行コードを実行するコード」では現在のスコープを取り込んでいます。4
insnsList(上で説明した「命令列の配列」)を入れているのは実装上の都合です(理由は後ほど出てきます)

vm.js抜粋
    case I.MAKE_FIUNCTION:
        {
            const funcInfo = stack.pop();
            const funcInsns = insnsList[funcInfo.ref];
            const func = {
                name: funcInfo.name,
                params: funcInfo.params,
                insns: funcInsns,
                // include scopes and insnsList to execute function
                scopes: scopes.slice(),
                insnsList
            };
            stack.push(func);
        }
        break;

あらためて関数の「実行」

関数を「オブジェクト」として定義したので、後はそのオブジェクトから情報を取り出して実行することで「関数を実行」が実現できます。
その処理を行っているのがcallFunction関数です。デフォルト値の話は後でするのでその部分は除いたコードを抜粋。

vm.js抜粋
function callFunction(context, func, args) {
    const {stack} = context;
    const {params, insns, scopes, insnsList} = func;

    // function has self context(defined context)
    const funcContext = {
        insnsList,
        stack,
        scopes
    };

    const argsScope = createScope();
    scopes.unshift(argsScope);
    for (let i = 0; i < params.length; i++) {
        argsScope.defineObject(params[i].name, 'let');
        if (args[i] != undefined) {
            argsScope.setObject(params[i].name, args[i]);
        }
    }

    runInsns(funcContext, insns);
    scopes.shift();

    return stack.pop();
}

contextは名前通り「命令列を動かす際のコンテキスト」です5。特に重要なのはscopesで表されているスコープスタックです。このscopesとして「関数作成時」に保存しておいたスコープスタックを渡すことで「関数が定義されたコンテキストでアクセスできる変数」にアクセスできるようになっています。6
runInsnsはトップレベルを実行しているのと同じ「命令列を実行する関数」です。ここら辺CRubyだと手が込んでいて「Rubyの関数呼び出しとC的な関数呼び出しが対応していない」というテクニックが使われていましたが(昔読んだものなので今どうなってるかはわかりません)、JOKEは性能は追い求めていないので普通に関数を再帰的に呼び出すようにしています。

デフォルト値に関する実装

何度か伏線したデフォルト値の実装について説明します。デフォルト値に対するBNFは以下のようになっています。

SingleNameBinding :
    BindingIdentifier Initializer
    ※Initializerはopt

Initializerは開発記その2で変数を実装したときにも出てきました。「初期化時の右辺」です。
つまり、JavaScriptではデフォルト値、というかデフォルト式として変数初期化時に書けるようなものなら何でも書けます。

「変数初期化時に書けるものが書ける」ということは右辺の内容を「呼ばれるたびに評価」する必要があるということです7。Rubyでもデフォルト値として任意の式が書け8、CRubyでは「デフォルト値を設定する実行コードが関数本体の前にあり、渡された引数の数に応じてどこから実行するかを決めている(条件ジャンプ)」ということをしていたのでそれと同じようにしようと思ったのですが、

よく思うとまだ実行コード内で条件分岐できないことに気づきました。
条件分岐(の命令)を先行して実装しようかと思いましたが「デフォルト値対応なし」で実装したcallFunctionに付け加える形で結局以下のように実装しました。

vm.js抜粋
    for (let i = 0; i < params.length; i++) {
        argsScope.defineObject(params[i].name, 'let');
        if (args[i] != undefined) {
            argsScope.setObject(params[i].name, args[i]);
        } else if (params[i].init) {
            // run initializer
            // TODO: this way is no good. rewrite in the future
            const initInsns = insnsList[params[i].init];
            runInsns(funcContext, initInsns);
            const val = stack.pop();
            argsScope.setObject(params[i].name, val);
        }
    }

つまり、デフォルト値を設定する「式」を実行(評価)するだけの命令列がありそれを実行してデフォルト値を得ています。コメントに書いてあるように「いけてない」ので条件分岐命令を実装したら直すかもしれません。命令列の配列(insnsList)を関数情報として入れているのもこのためです(他に関数内関数のためにも必要ですが)

言語仕様以外の改造

テストプログラムも増えてきたのでディレクトリを2階層にしました。

今後の予定

以上今回は関数を定義し呼び出すという「花形」の部分を実装しました。
初めは気づかなかったのですがこれで当初の「コールバック」も実現できたので「第1部 -完-」となります。

ただし、最終回ではなく「俺達の戦いはこれからだ!」となりますね。セルフホスティングのためにはまだまだ実装しないといけないものが山ほどあります。
JOKE開発の発端となったクラス(オブジェクト)に進んでもいいですが、次回はそろそろ条件分岐(を行うための命令)を実装しようかなと思います。


  1. 逆に「定義されているよりも多い引数で呼ぶ」こともできるわけですが、配列をまだ実装していないため「多い分全部」を格納するrestを操作する方法がなく今ステップでは実装を見送っています。 

  2. 普通はreturn ++counter;とすべきですが前回、「いや前置インクリメントあまり使わないから実装しない」としたツケがいきなり回ってきました(笑) 

  3. CRubyは参照形式、CPythonは埋め込み形式(C言語で書かれているので実際にはポインタ)だった気がする。 

  4. JOKEでは「演算子等を置くためのスタック」と「変数を置くためのスコープスタック」が別にしていたのでかなり手抜きにスコープのコピーができていますが、CRuby等ではスタックが分離されていないため(この方が普通?)、「現在の環境保存」がだいぶ複雑になっていた覚えがあります。 

  5. stackscopesなどいろいろなものを渡す必要があるため、「コンテキスト」という実行に関する情報をまとめたものを渡すようにしても変ではなかろうと思いオブジェクトを渡すようにしました。 

  6. ここら辺、C言語で書いてたらまだ要るもの、もう要らないものを判断するGCの実装が死ぬほど大変だったと思われます(書くのも大変だし動作確認も大変、バグがあっても再現条件がわからない等) 

  7. Pythonのデフォルト値は「関数定義時」に評価されるためリストを書くとm9(^Д^)プギャーとなるのは有名な話。 

  8. Rubyはselfや()を付けないでメソッドが呼び出せるためデフォルト値と言いながらその実体はメソッド呼んでるというテクニックがRailsで利用されています。