作って理解JavaScript:JOKE開発記その8 - 配列とアロー関数


今回のスコープ

また前回から二か月近く経ってますが8月は暑すぎてほとんど開発してませんでした。

さて前回のあとがき通りに今回のネタは配列メイン、ついでにアロー関数です。

  • ステップ11:アロー関数
  • ステップ12:配列
    • リテラルと添え字形式アクセス
    • for-of
    • 各種メソッド

ステップ11:アロー関数

ステップ11段階のコードは以下にあります。
https://github.com/junjis0203/joke/tree/step0011

前回も書きましたが、アロー関数での「thisを束縛しない」動作は実装していません。これはアロー関数を実装した(使いたかった)理由が「コールバック関数を短く書きたかったから」であるためです(thisを束縛しない動作を実装するモチベーションがない)

引数処理(カッコ問題)

アロー関数の仕様は以下に書かれています。
http://www.ecma-international.org/ecma-262/6.0/#sec-arrow-function-definitions

引数に対応するArrowParameters。一つ目がa => a * 2みたいなカッコなしで、二つ目が(a, b) => a + bみたいなカッコありに対応しています。

ArrowParameters :
    BindingIdentifier
    CoverParenthesizedExpressionAndArrowParameterList

CoverParenthesizedExpressionAndArrowParameterListどこかで見たなと思ったら開発記その3で言及していました。

Syntax

    PrimaryExpression :
        略
        CoverParenthesizedExpressionAndArrowParameterList

    CoverParenthesizedExpressionAndArrowParameterList :
        ( Expression )
        ( )
        ( ... BindingIdentifier )
        ( Expression , ... BindingIdentifier )

Supplemental Syntax

When processing the production

    PrimaryExpression : CoverParenthesizedExpressionAndArrowParameterList

the interpretation of CoverParenthesizedExpressionAndArrowParameterList is refined using the following grammar:

    ParenthesizedExpression :
        ( Expression )

このときはまだよくわかっていなかったので「つまり( Expression )として処理してしまっていいんやな」というように実装しました。

さて今回のArrowParametersについてもSupplemental Syntaxとして以下のように書かれています。

When the production

    ArrowParameters : CoverParenthesizedExpressionAndArrowParameterList

is recognized the following grammar is used to refine the interpretation of CoverParenthesizedExpressionAndArrowParameterList :

    ArrowFormalParameters :
        ( StrictFormalParameters )

というわけで( StrictFormalParameters )と処理するようにしてみましたが、うまくいきませんでした。
正確に言うとアロー関数自体は動くものの、(1 + 2)のようなカッコつき演算がこける1ようになりました。テスト大事ですね。

問題は、(を読んだ時点では以降にあるのがカッコつき演算なのかアロー関数なのかわからないという点です。

カッコ問題の解決方法

「カッコつき演算なのかアロー関数なのかわからない」問題には以下のように対処しました。

  1. refineしないCoverParenthesizedExpressionAndArrowParameterListとしてまず読み込む
  2. )の後に=>がある場合はアロー関数として処理する(後述)
  3. )の後に=>がなければカッコつき演算として処理する(実装的にはバックトラックした後、既存の演算文法解析処理に流す)

コンマの処理

CoverParenthesizedExpressionAndArrowParameterListのBNFを再掲(残余引数は省略)

CoverParenthesizedExpressionAndArrowParameterList :
    ( Expression )
    ( )

(。´・ω・)?
あれ?複数引数扱うBNFなくない?

それではここでExpressionBNFを見てみましょう。

Expression :
    AssignmentExpression
    Expression , AssignmentExpression

なんとExpressionはコンマ演算子を含んでいました。
つまり、「コンマ演算に対応することにより、アロー関数の引数を分けるコンマも対応される」という仕組みでした。これを考えた人は頭がいいというのかコリジョンを解決するために編み出したのかが気になるところです。

「コンマ演算」を「複数引数」として扱う

読み込んだCoverParenthesizedExpressionAndArrowParameterListを「アロー関数の引数」として扱うにはそれをStrictFormalParametersに直さないといけません。仕様ではここら辺にやり方が書かれていますが2、以下の変換を行うようにしました。

  • 「式」の「変数参照」は「変数宣言」に置き換える
  • 「式」の「代入演算」は「デフォルト値あり変数宣言」に置き換える

処理コードはこちら。コメントに「作者の心情」が吐露されてますね(笑)

parser.js抜粋
function ArrowParameter(scanner) {
    function convertNode(node) {
        let clone;
        switch(node.type) {
        case Node.IDENTIFIER_REFERENCE:
            clone = {...node, type: Node.IDENTIFIER};
            break;
        case Node.ASSIGNMENT:
            clone = {
                type: Node.INITIALIZE,
                identifier: node.left.identifier,
                initializer: node.right
            };
            break;
        }
        return clone;
    }

    if (checkCharToken(scanner.token, '(')) {
        /*
        Specification says CoverParenthesizedExpressionAndArrowParameterList is recognized as StrictFormalParameters.
        But can't refine because of ill implementation ...
        */

        const list = CoverParenthesizedExpressionAndArrowParameterList(scanner);
        // expand
        const params = [];
        let curr = list.expr;
        if (curr) {
            while (curr.type == Node.COMMA) {
                params.push(convertNode(curr.left));
                curr = curr.right;
            }
            params.push(convertNode(curr));
        }
        const node = {
            params
        }
        return node;
    } else {
        // 省略
    }
}

ステップ12:配列

ステップ12段階のコードは以下にあります。
https://github.com/junjis0203/joke/tree/step0012

配列はステップ12を作ってる最中もいろいろと実装が変わりましたが最終的に以下のようになりました。

  • 配列はただのオブジェクトである(JokeObjectクラスのインスタンスである)
  • constructorとしてJokeArrayを持つ
  • JokeArrayJokeFunctionクラスのインスタンスである

JavaScriptのArrayはビルトインオブジェクトであり、ビルトインクラスではありません。
ということは知っていたつもりだったのですが、初め「JokeFunctionクラスを継承したJokeArrayクラスを作り、配列はJokeArrayクラスのインスタンス」としていたのでステップ10までで作ったオブジェクトとクラスの仕組みの上で動かすためにおかしな実装をしていました。

リテラル

さてまずはリテラルで書いた配列を扱えるようにしました。なお、new Array()で配列を作るのもやればできると思いますが自分が普段そういう書き方をしていないので対応していません。

前回、lengthはgetter/setter使えばできるなとgetter/setterを実装したわけですが結局使っていません。arr.length = 0みたいにされたときは現在の要素をクリアするのが正しい動作だと思いますが使わないので・・・

for-of

for-ofはいろいろと難関でした。

構文解析的な課題

まず構文解析的な課題です。前述したアロー関数カッコ問題と同じような感じですが、forがあった時点ではC言語風のfor文が書かれているのかfor-of文が書かれているのかはわかりません。なお、ofはECMAScriptでは予約語ではないのですがめんどくさいのでJOKEでは予約語としています。
http://www.ecma-international.org/ecma-262/6.0/#sec-iteration-statements

IterationStatement :
    for ( Expression ; Expression ; Expression ) Statement
    for ( LexicalDeclaration Expression ; Expression ) Statement
    for ( LeftHandSideExpression of AssignmentExpression ) Statement
    for ( ForDeclaration of AssignmentExpression ) Statement

いろいろと手抜きして以下のようにしました。

  • for-ofではconstで新しいスコープが作られるもののみをサポート。つまり、上の3番目のBNFはサポートしない。
  • とりあえずLexicalDeclarationとして読み込んでみて駄目ならfor-ofとして読んでみる。なお2番目のBNFで「セミコロン足りなくね?」と思われた方は開発記その5をご参照ください。

実装コードはこちら。コメントに作者の心情が(ry
そろそろ汎用の「BNF1を試す」→「駄目ならBNF2を試す」という仕組みを作った方がいい気がしますね・・・

parser.js抜粋
            const state = scanner.saveState();
            try {
                decr = LexicalDeclaration(scanner);
            } catch (e) {
                if (e instanceof SyntaxError) {
                    // try for-of
                    // TODO: too ad-hoc
                    scanner.restoreState(state);
                    type = Node.FOR_OF;
                    decr = ForDeclaration(scanner);
                    if (decr && checkKeywordToken(scanner.token, 'of')) {
                        scanner.next();
                    } else {
                        throw e;
                    }
                } else {
                    throw e;
                }
            }

実行方法的な課題(仕様編)

次に実行的な課題です。for-ofをどのように実行すればいいかは次の個所に書かれています。
http://www.ecma-international.org/ecma-262/6.0/#sec-for-in-and-for-of-statements-runtime-semantics-labelledevaluation

要約すると

  • ofの右側にある式からiteratorを取得する
  • iteratorから次を取り出す
  • 次がないならループ終了する
  • 次があるならその値をセットして本文を実行する

初めは独自解釈(よくわからなかったとも言う)で実装したのですが、「for-ofって配列以外にも使えるのかな」ということは疑問に思っており、たまたまSetを使う機会があってSetもfor-ofで回せることを知りました。
というわけでもう少し調べてみるとiteratorのインターフェースがちゃんと定義されていることがわかりました(ぉぃ
http://www.ecma-international.org/ecma-262/6.0/#sec-iteration

  • for-ofはオブジェクトの@@iteratorメソッドを呼び出してiteratorを取得する
  • iteratorはnextメソッドが呼び出されるとIteratorResultオブジェクトを返す
  • IteratorResultdoneプロパティはiteratorに次があるかを示し、valueプロパティが次の値である

というわけでそれを実装しました。やや長いのでコードを貼るのは止めておきます。

実行方法的な課題(実装編)

ここまでは仕様的な実行方法の話、ここからは@@iteratorを「どう実装するか」の話です。
配列の各種メソッドを実装するために、Rubyとかのように「ネイティブ(言語処理系を書くために使われている言語)」で処理を記述できるようにしようと思ってはいたのですが一足早くその機会が訪れました。

まず以下のJokeNativeMethodクラスを定義しました。

object.js抜粋
export class JokeNativeMethod extends JokeObject {
    constructor(method) {
        super();
        this.method = method;
    }

    call(context, thisValue, ...args) {
        return this.method(context, thisValue, ...args);
    }
}

このJokeNativeMethodを使って「処理」をラップします。本当はただの関数にしたかったのですが「this」を保持する場所が必要だったのでこのようになりました。

builtin/array.js抜粋
const Array_iterator = new JokeNativeMethod(
    (context, thisValue) => {
        return new JokeArrayIterator(thisValue);
    }
);

定義した「ネイティブメソッド」はprototypeに設定しておきます。

builtin/array.js抜粋
export const JokeArray = new JokeFunction();
{
    const prototype = JokeArray.getProperty('prototype');
    prototype.setProperty('length', 0);

    prototype.setProperty(Symbol.iterator, Array_iterator);

「ネイティブメソッド」の呼び出しはベタに条件分岐で書いています。

vm.js抜粋
export function callFunction(context, func, args) {
    const thisValue = context.thisValue ? context.thisValue : func.thisValue;

    if (func instanceof JokeNativeMethod) {
        return func.call(context, thisValue, ...args);
    }

    // これ以降、「アセンブル」されたプログラムの呼び出し処理

context(変数スコープなど)を引き回しているのはどうにもダサいのですがいい方法が思いつかないのでこのようになっています。また、thisthisValueをよく間違えて例外が起こるのが問題ですね(笑)

各種メソッド

というわけで予定よりも早く「ネイティブメソッド」のAPIができたので、後は淡々と配列のメソッドを定義していきました。ただし全部ではなく自分が使いそうなもののみです。

  • push, pop, unshift, shift
  • reverse, sort, splice
  • includes, indexOf, join, slice, toString
  • filter, find, map, reduce

あっ、もう一ネタありましたね。今度は「ネイティブメソッドからコールバックで渡された関数を呼ぶ方法」です。

builtin/array.js抜粋
const Array_filter = new JokeNativeMethod(
    (context, thisValue, callback, thisArg) => {
        const length = thisValue.getProperty('length');
        const callbackContext = {
            ...context,
            thisValue: thisArg ? thisArg : thisValue
        };
        const filtered = JokeArray.newInstance();
        for (let i = 0; i < length; i++) {
            const elem = thisValue.getProperty(i);
             // ↓これ
            if (callback.call(callbackContext, elem, i, thisValue)) {
                filtered.invoke('push', context, elem);
            }
        }
        return filtered;
    }
);

callされたら単純に自分を引数にしてcallFunctionを呼び出します。
callFunctionが定義されているのはvm.jsなのでこの依存関係はやや微妙です。

object.js抜粋
export class JokeFunction extends JokeObject {
    call(context, ...args) {
        return callFunction(context, this, args);
    }

最後に、「sortって比較関数渡されない場合はa < bみたいにやればいいのかな」と考えていましたが全然違いました。
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/sort

compareFunction (比較関数) が与えられなかった場合、 undefined 以外のすべての配列要素は文字列に変換され、文字列が UTF-16 コード単位順でソートされます。例えば、 "banana" は "cherry" の前に来ます。数値のソートでは、 9 が 80 の前に来ますが、数値は文字列に変換されるため、 Unicode 順で "80" が "9" の前に来ます。 undefined の要素はすべて、配列の末尾に並べられます。

マジか。これって常識なんですかね。
なお、JOKEでは比較関数渡されない場合の動作は未実装(ていうか落ちます)です。

あとがき

以上、今回はアロー関数と配列を実装しました。思ってた通りのこともあれば思ってた(想像していた)仕様と違う個所も多くありました。
ともかくこれで配列、前回でオブジェクトを実装したので次は残余引数だったり分割代入だったりを実装していく予定です。
それではまた一か月後(?)に。


  1. SyntaxErrorが起きてた記憶があります。 

  2. 仕様のこの部分は今開発記を書きながら仕様を確認していて気づきました。実装時に気づいてたらもうちょっとまともな実装になってたかも・・・