Try   HackMD

TypeScriptに新しい演算子を追加してみた

※この記事はEEIC(東京大学工学部 電気電子工学科/電子情報工学科)3年の後期実験「大規模ソフトウェアを手探る」のレポートとして書かれています。


変数を比較するとき、[===]を使えばTrue/Falseを返してくれます。
しかしこれでオブジェクトを比較するときは、オブジェクトのアドレスを比較するだけなので、あまり役に立ちません。

a = {name: "花子", age = 18} b = {name: "花子", age = 18} console.log(a === b) //Falseになります

人間の直観的には、aとbは同じです。できればTrueを返してほしい。

console.log(a ==== b) //True になるような新しい演算子"===="が欲しい

そこで、今回はオブジェクトの比較をいい感じにしてくれる新しい「====」演算子をTypeScriptのソース基盤に追加します。


目次


初期準備

前提知識:JavaScriptとの関係

TypeScriptとはJavaScriptをベースに、静的型付けやオブジェクト指向などいろいろな便利な機能を追加して使えるようにするために、Microsoft社が開発した言語である。tsコンパイラによって.tsファイルを.jsファイルに翻訳し、出来た.jsファイルを通常のjs環境で実行することでコードを走らせる。

最終的には.jsファイルに翻訳するので、例えば.tsファイルにJavaScriptの文法で書いてもなんの問題もなくそのまま実行される。つまりすべての.jsプログラムは有効な.tsプログラムであり、互換性がある。

インストールとビルド

インストール
TypeScript V4.5.0 -devをインストールした。
Microsoft/TypeScriptのmainブランチ:https://github.com/microsoft/TypeScript

ビルド
TypeScript フォルダ内のREADMEにしたがってビルドした。
推奨されているコマンドを使用すると、TypeScript/built/local内にビルドされる。

# ビルド #TypeScriptフォルダの中に入る #node.js, npmがある前提。ここでは、node:v14.16.0 npm:v8.0.0 sudo npm install -g gulp #gulpのインストール sudo npm ci #依存パッケージのインストール gulp local #ビルドの実行

テスト実行

#実行 #TypeScriptフォルダの中に、test.tsを作成したとする。 sudo node ./built/local/tsc.js test.ts node test.js

バージョン確認

sudo node ./built/local/tsc.js --version

デバッガ―の準備

  1. 「.vscodeフォルダ」内のlaunch.template.jsonファイルをコピーし、launch.jsonとして.vscodeフォルダ内に配置

  2. 下記bashコードによりデバッガーlisten状態にする

#TypeScriptフォルダ内に入り、同じ階層にtest.tsを用意 sudo node --inspect-brk ./built/local/tsc.js test.ts
  1. vscodeの虫マークのデバッグツールタグから、"Attach to VS Code TS Server via Port"を選択して実行。これはlaunch.jsonに記述されているもの。

  2. コンパイラが動く


手順1:まず「====」がエラーを出すことなく.jsファイルに出力されるようにする

コンパイラは通常、「字句解析⇒構文解析⇒意味解析」という手順で動作する。
以下、その流れに沿って、必要な変更箇所を上げていく。

字句解析

1. Tokenの定義の追加

「===」という演算子が「EqualsEqualsEqualsToken」と定義されているのを発見した。そこでコード全体に対し、「EqualsEqualsEqualsToken」と検索すると、該当しそうな箇所がたくさん出てきた。これらの箇所において、「EqualsEqualsEqualsEqualsToken」を新たに追加する。

  • src>lib>typescript.d.ts
    .d.tsとはTypeScriptの型定義ファイルである。
    これはlibフォルダ内のものなので、本命のcompilerとはあまり関係ないが、
    ここにEqualsEqualsEqualsEqualsTokenを追加して、適当に400とした。
export enum SyntaxKind { Unknown = 0, ... CommaToken = 27, QuestionDotToken = 28, LessThanToken = 29, LessThanSlashToken = 30, GreaterThanToken = 31, LessThanEqualsToken = 32, GreaterThanEqualsToken = 33, EqualsEqualsToken = 34, ExclamationEqualsToken = 35, EqualsEqualsEqualsToken = 36, EqualsEqualsEqualsEqualsToken = 400,
  • src>compiler>types.ts :
    types.tsはTokenの型など様々なものを定義している重要なファイル。

    • 64行目 SyntaxKindというオブジェクトの中身がいろいろ定義されている。
      このSyntaxKind変数はcompilerの中の様々な箇所で呼び出される重要な変数である。全種類のTokenを中身にもつので、各種Tokenを呼び出したいときに「SyntaxKind.EqualsToken」のように使われる。
    ​​​​export const enum SyntaxKind = { ​​​​Unkown, ​​​​... , ​​​​EqualsEqualsEqualsToken, ​​​​... ​​​​}
    • 503行目 PunctuationSyntaxKindの定義があり、EqualsEqualsEqualsTokenを含む多くの演算子の集合として定義している
    ​​​​export type PunctionationSyntaxKind = ​​​​| SyntaxKind.OpenBraceToken ​​​​| ... ​​​​| SyntaxKind.EqualsEqualsEuqalsToken ​​​​
    • 1933行目 同じく、EqualityOperatorがいくつかの等号系Tokenの集合として定義されている

2.「====」の読み取り

  • src>compiler>scanner.ts :

    • 183行目 "==="という文字列に対し、EqualsEqualsEqualsTokenという名前のSyntaxKindが割り当てられている(Map)
    ​​​​const textToToken = new Map(getEntries({ ​​​​...textToKeywordObj, ​​​​"{": SyntaxKind.OpenBraceToken, ​​​​. ​​​​. ​​​​. ​​​​"===": SyntaxKind.EqualsEqualsEqualsToken, ​​​​})) ​​​​
    • 1945行目 
      CharacterCodes.equals、つまり[=]を4つ連続して読み取れるようにする

構文解析:構文木が生成されるようにする

以上の過程で、良い感じに構文木が生成されるようになっているので、コンパイラのこの機能に対し手を加える必要はない。

意味解析:構文としてのエラーチェック

===を真似して、「EqualsEqualsEqualsEqualsToken」用の文を追加すればいい。

  • src>compiler>parser.ts :

    • 5549行目 構文解析において、===の次に何が来ていいのかチェックする関数
  • src>services>classifier.ts

    • 375行目 isBinaryExpressionOperatorToken(): 引数二つの演算子である場合、trueを返す
  • src>services>codefixs>interFromUsage.ts

    • 798行目
  • src>compiler>factory>utilities.ts

    • 963行目 isEqualityOperator()
  • src>compiler>utilities.ts

    • 3760行目
  • src>services>utilities.ts

    • 2664行目

ここまでやれば、以下のようなtest用のtsファイルを翻訳するのに成功するはず!

// tsファイル console.log(a====b) ↓ ↓ ↓ // jsファイル console.log(a====b)

手順2:「====」という演算子を含む個所を、適切なJavaScript構文に置き換えられるようにする

ここまででjsファイルにエラーなく「====」を吐き出すことができるようになったがこれでは当然動くはずはなく、次はjsが認識できる形に書き直さなければならない。

書き直している箇所をどう手探ればいいか考えていたところ、Null合体演算子を追っていけばいいのではと気付いた。

Null合体演算子とはa ?? bの形で表されるもので、aがundefinedやnullであればbを返し、そうでなければaを返すものである。これはコンパイルするとa !== null && a !== void 0 ? a : bの形に変換されるので、それが実際にどこで変換されるのかを見つければうまく活用できるはずである。

追っていくと、次の関数に行き着いた。

function transformNullishCoalescingExpression(node: BinaryExpression) {
    let left = visitNode(node.left, visitor, isExpression);
    let right = left;
    if (!isSimpleCopiableExpression(left)) {
        right = factory.createTempVariable(hoistVariableDeclaration);
        left = factory.createAssignment(right, left);
    }
    return setTextRange(factory.createConditionalExpression(
        createNotNullCondition(left, right),
        /*questionToken*/ undefined,
        right,
        /*colonToken*/ undefined,
        visitNode(node.right, visitor, isExpression),
    ), node);
}

名前からして明らかにNull合体演算子に関わっていそうである。引数のnodeoperatorTokenQuestionQuestionToken??のトークンか)であればこの関数にとび、新たなnodeを返すようである。ここで返されるnodeが、jsに変換されるときに生成されるコードに対応している。つまり、createConditionalExpressionは三項演算子に対応するnodeを作る関数で、nullでないという判定部分を作るcreateNotNullCondition??の左右のnodeを渡している。(questionTokencolonTokenにundefinedを渡しているがそうするとcreateConditionalExpression内でそれに対応するTokenが作られるようである。)

それなら====でも、a ==== bに対応するnodeを受け取って新たなnodeを返す関数を生成し、EqualsEqualsEqualsEqualsTokenのときにこの関数にとぶようにすればよさそうである。

では次にどのようなnodeを返すようにすればいいのか。そもそもどのようにして作ればいいのかというところに興味が移るが、createConditionalExpressionがどこでexportされているかを探してみると、nodeFactory.tsといういかにもなファイルにnodeを生成する関数がたくさんあることを発見した。

以下長いが、実際に作成した関数を示す。もし新しいクラスを作ったりするとなるとかなりの作業量になるのではと危惧していたが実際は既存の関数を組み合わせればよかったのでそこは安心した。

function transformObjectEqualsExpression(node: BinaryExpression){
    let left = visitNode(node.left, visitor, isExpression);
    let right = visitNode(node.right, visitor, isExpression);
    if (!isSimpleCopiableExpression(left)) {
        right = factory.createTempVariable(hoistVariableDeclaration);
        left = factory.createAssignment(right, left);
    }
    const n = factory.createIdentifier("n");
    const m = factory.createIdentifier("m");
    const objectEquals = factory.createIdentifier("objectEquals")
    const a = factory.createIdentifier("a");
    const b = factory.createIdentifier("b");
    const i = factory.createIdentifier("i");
    const temp = factory.createTempVariable(/*recordTempVariable*/ undefined);
    const lengthOfLeft = factory.createTempVariable(/*recordTempVariable*/ undefined);
    const lengthOfRight = factory.createTempVariable(/*recordTempVariable*/ undefined);
    const functionExpression = factory.createFunctionExpression(
        /*modifiers*/ undefined,
        /*asteriskToken*/ undefined,
        /*name*/ objectEquals,
        /*typeParameters*/ undefined,
        /*parameters*/ [
            factory.createParameterDeclaration(undefined,undefined,undefined,a),
            factory.createParameterDeclaration(undefined,undefined,undefined,b),
            factory.createParameterDeclaration(undefined,undefined,undefined,i)
        ],
        /*type*/ undefined,
        factory.createBlock([
            factory.createVariableStatement(
                /* modifier */ undefined,
                /* declarationList */ factory.createVariableDeclarationList([
                    factory.createVariableDeclaration(lengthOfLeft,undefined,undefined,factory.createNumericLiteral(0)),
                    factory.createVariableDeclaration(lengthOfRight,undefined,undefined,factory.createNumericLiteral(0))
                ])
            ),
            factory.createIfStatement(
                /* expression */ factory.createBinaryExpression(
                    i,
                    factory.createToken(SyntaxKind.EqualsEqualsToken),
                    factory.createNumericLiteral(100)
                ),
                /* then */ factory.createReturnStatement(
                    factory.createFalse()
                )
            ),
            factory.createForInStatement(
                /* initializer */ factory.createVariableDeclarationList([
                    factory.createVariableDeclaration(n)
                ]),
                a,
                factory.createBlock([
                    factory.createExpressionStatement(
                        factory.createPrefixIncrement(lengthOfLeft)
                    )
                ])
            ),
            factory.createForInStatement(
                /* initializer */ factory.createVariableDeclarationList([
                    factory.createVariableDeclaration(m)
                ]),
                b,
                factory.createBlock([
                    factory.createExpressionStatement(
                        factory.createPrefixIncrement(lengthOfRight)
                    )
                ])
            ),
            factory.createIfStatement(
                /* expression */ factory.createBinaryExpression(
                    lengthOfLeft,
                    factory.createToken(SyntaxKind.ExclamationEqualsToken),
                    lengthOfRight
                ),
                /* then */ factory.createReturnStatement(
                    factory.createFalse()
                )
            ),
            factory.createForInStatement(
                /* initializer */ factory.createVariableDeclarationList([
                    factory.createVariableDeclaration(n)
                ]),
                a,
                /* statement */ factory.createBlock([
                    factory.createVariableStatement(
                        /* modifier */ undefined,
                        /* declarationList */ factory.createVariableDeclarationList([
                            factory.createVariableDeclaration(temp,undefined,undefined,factory.createFalse())
                        ])
                    ),
                    factory.createForInStatement(
                        factory.createVariableDeclarationList([
                            factory.createVariableDeclaration(m)
                        ]),
                        b,
                        /* statement */ factory.createBlock([
                            factory.createIfStatement(
                                factory.createLogicalAnd(
                                    factory.createBinaryExpression(
                                        n,
                                        factory.createToken(SyntaxKind.EqualsEqualsToken),
                                        m
                                    ),
                                    factory.createLogicalAnd(
                                        factory.createBinaryExpression(
                                            factory.createTypeOfExpression(
                                                factory.createElementAccessExpression(
                                                    a,
                                                    n
                                                ),
                                            ),
                                            factory.createToken(SyntaxKind.EqualsEqualsEqualsToken),
                                            factory.createStringLiteral("object")
                                        ),
                                        factory.createBinaryExpression(
                                        factory.createTypeOfExpression(
                                                factory.createElementAccessExpression(
                                                    b,
                                                    m
                                                ),
                                            ),
                                            factory.createToken(SyntaxKind.EqualsEqualsEqualsToken),
                                            factory.createStringLiteral("object")
                                        )
                                    )
                                ),
                                factory.createExpressionStatement(
                                    factory.createBinaryExpression(
                                        temp,
                                        factory.createToken(SyntaxKind.EqualsToken),
                                        factory.createFunctionCallCall(
                                            objectEquals,
                                            factory.createThis(),
                                            [    
                                                factory.createElementAccessExpression(
                                                    a,
                                                    n
                                                ),                    
                                                factory.createElementAccessExpression(
                                                    b,
                                                    m
                                                ),
                                                factory.createBinaryExpression(
                                                    i,
                                                    factory.createToken(SyntaxKind.PlusToken),
                                                    factory.createNumericLiteral(1)
                                                )
                                            ]
                                        )
                                    )
                                ),
                                factory.createExpressionStatement(factory.createBinaryExpression(
                                    temp,
                                    factory.createToken(SyntaxKind.EqualsToken),
                                    factory.createLogicalOr(
                                        temp,
                                        factory.createLogicalAnd(
                                            factory.createBinaryExpression(
                                                n,
                                                factory.createToken(SyntaxKind.EqualsEqualsToken),
                                                m
                                            ),
                                            factory.createBinaryExpression(
                                                factory.createElementAccessExpression(
                                                    a,
                                                    n
                                                ),
                                                factory.createToken(SyntaxKind.EqualsEqualsToken),
                                                factory.createElementAccessExpression(
                                                    b,
                                                    m
                                                )
                                            )
                                        )
                                    )
                                ))
                            )
                        ])
                    ),
                    factory.createIfStatement(
                        /* expression */ factory.createLogicalNot(temp),
                        /* then */ factory.createReturnStatement(factory.createFalse())
                    )
                ])
            ),
            factory.createReturnStatement(factory.createTrue())
        ])
    );
    return setTextRange(factory.createFunctionCallCall(
        functionExpression,
        factory.createThis(),
        [left, right, factory.createNumericLiteral(0)]
    ),node);
}

結果、以下のようにコンパイルできた。

console.log(function objectEquals(a, b, i) { var _a = 0, _b = 0; if (i == 100) return false; for (var n in a) { ++_a; } for (var m in b) { ++_b; } if (_a != _b) return false; for (var n in a) { var _c = false; for (var m in b) { if (n == m && (typeof a[n] === "object" && typeof b[m] === "object")) _c = objectEquals.call(this, a[n], b[m], i + 1); else _c = _c || n == m && a[n] == b[m]; } if (!_c) return false; } return true; }.call(this, a, b, 0));

これを実行することで、オブジェクトの中身を直接比較することができる。
以下のようにプロパティの順番が違っていても、プロパティの値がオブジェクトになっていても演算子ひとつで比較できる。

var a = { name: "hogehoge", birthday: "1111", myFavoriteLanguages: { first: "TypeScript", second: "C++", third: "Ruby" } }; var b = { birthday: "1111", myFavoriteLanguages: { first: "TypeScript", third: "Ruby", second: "C++" }, name: "hogehoge" }; console.log(a ==== b); /* true */

課題

課題としては以下のように自分を値に持つプロパティがある場合の対応についてである。

const a = { name: "hoge", birthday: "1111", object: {} } const b = { name: "hoge", birthday: "1111", object: {} } a.object = a b.object = b console.log(a ==== b)

無限ループにはまるのを防ぐために上の実装では関数にカウンタを渡し、再帰の深さをカウントしていき、それが100に達した時点でfalseを返すようにしたが、果たしてfalseでいいのか、他にもっとよい実装はないかという点に関しては考える余地があると思われる。


終わりに

TypeScriptのコンパイラを実際にいじってみた系のサイトもなく本当に手探り状態で挑んた実験であったが実際に目標となるものが実装できて勉強になったし自信に繋がった。