@reki2000 のJavaの文法を自分好みに変えてみる(nullは友達の巻)が面白い!と思ったので、JavaScript で同じことをやってみます。
自分で javac をコンパイルして、好きな文法を追加した Java言語のソースを書いちゃうという話です。
今回は Groovy などで見かける ?. という Null Safe Reference を導入してみました。似たものに Elvis Operator とよばれる ?: もあって、そのうちこれにも挑戦してみたいです。
この記事の成果は shuhei/babel-plugin-null-safe-accessor という Babel プラグインとして利用できます。
方針
JavaScript には null
だけでなく undefined
もあるので、以下のような変換にしたいと思います。関数呼び出しの場合も便利に使えるように末尾の ()
も考慮します。
// 新文法
var b = a?.b;
a?.b(c, d);
// 既存文法で表現した場合
var b = a == null ? a : a.b;
a == null ? a : a.b(c, d);
V8 などをいじってコンパイルするのはハードルが高いので、実行前の変換を考えます。JavaScript 文法の拡張には Sweet.js というものがありますが、今回はすでに多くの方が使っている Babel でやってみようと思います。
Babel の JavaScript 変換の仕組みは大まかに言うと以下のようになります。
元のコード -> Parser -(AST)-> Transformers -(AST)-> Generator -> 出力されるコード
今回はまず Parser が新しい文法をパースできるようにする必要があります。Parser は Tokenizer を継承しており、Tokenizer がパースした token から AST を組み立てていきます。?.
用に questionDot という token と、それに対応する AST type を用意します。
?.
の元になる .
(プロパティへのアクセス)の AST 表現は以下の様なものです。
{
"type": "MemberExpression",
"computed": false,
"object": { /* オブジェクト */ },
"property": { /* プロパティ */ }
}
?.
用に MemberExpression と同様の構造の SafeMemberExpression という新しい AST type を用意し、Parser が解釈・出力できるようにします。その後 Transformer で SafeMemberExpression を上述の三項演算子の AST に変換します。
Parser
現状の Babel 6 ではプラグインで新文法を追加することはできません。babel-plugin-syntax-* というような公式のプラグインがありますが、フラグを on にしているだけなのです。しょうがないので node_modules 以下のソースを編集していきます(後でモンキーパッチ化してモジュールに切り出します)。
まずは触る場所のあたりをつけるために、どこでエラーになるか見てみます。
npm init
npm install -D babel-core
var babel = require('babel-core');
var code = 'var c = a?.b?.c;';
var result = babel.transform(code);
$ node index.js
/Users/shuhei/work/js/null-friendly-js/node_modules/babel-core/lib/transformation/file/index.js:547
throw err;
^
SyntaxError: unknown: Unexpected token (1:10)
> 1 | var c = a?.b?.c;
| ^
at Parser.pp.raise (/Users/shuhei/work/js/null-friendly-js/node_modules/babylon/lib/parser/location.js:22:13)
at Parser.pp.unexpected (/Users/shuhei/work/js/null-friendly-js/node_modules/babylon/lib/parser/util.js:91:8)
at Parser.pp.parseExprAtom (/Users/shuhei/work/js/null-friendly-js/node_modules/babylon/lib/parser/expression.js:510:12)
at Parser.pp.parseExprSubscripts (/Users/shuhei/work/js/null-friendly-js/node_modules/babylon/lib/parser/expression.js:265:19)
at Parser.pp.parseMaybeUnary (/Users/shuhei/work/js/null-friendly-js/node_modules/babylon/lib/parser/expression.js:245:19)
at Parser.pp.parseExprOps (/Users/shuhei/work/js/null-friendly-js/node_modules/babylon/lib/parser/expression.js:176:19)
at Parser.pp.parseMaybeConditional (/Users/shuhei/work/js/null-friendly-js/node_modules/babylon/lib/parser/expression.js:158:19)
at Parser.pp.parseMaybeAssign (/Users/shuhei/work/js/null-friendly-js/node_modules/babylon/lib/parser/expression.js:121:19)
at Parser.pp.parseMaybeConditional (/Users/shuhei/work/js/null-friendly-js/node_modules/babylon/lib/parser/expression.js:163:28)
at Parser.pp.parseMaybeAssign (/Users/shuhei/work/js/null-friendly-js/node_modules/babylon/lib/parser/expression.js:121:19)
node_modules/babylon/lib/parser/expression.js
でエラーが出ていることがわかります。エラーの内容は、そんな token はありませんというもの。Token を増やす必要がありそうです。node_modules/bablylon/lib/tokenizer/types.js
を見ると dot, doubleColon など token の一覧があります。ここに ?.
に対応する questionDot という token を追加します。
types.questionDot = new TokenType("?.", { beforeExpr: true });
そして ?.
というコードに対して questionDot token を返すようにします。現在の babel ではパフォーマンスのため char code を使って文字を判断しているようですね。
Tokenizer.prototype.getTokenFromCode = function getTokenFromCode(code) {
switch (code) {
/* snip */
case 63:
var next = this.input.charCodeAt(this.state.pos + 1);
if (next === 46) {
// ?.
this.state.pos += 2;
return this.finishToken(_types.types.questionDot);
} else {
++this.state.pos;return this.finishToken(_types.types.question);
}
/* snip */
}
};
ここまでで Tokenizer ができたので、今度は token から AST を作る方も修正します。dot
から MemberExpression を作るところの横で、同じように questionDot
から SafeMemberExpression を作ります。
pp.parseSubscripts = function (base, startPos, startLoc, noCalls) {
for (;;) {
if (/* snip */) {
/* snip */
} else if (this.eat(_tokenizerTypes.types.questionDot)) {
var node = this.startNodeAt(startPos, startLoc);
node.object = base;
node.property = this.parseIdentifier(true);
node.computed = false;
base = this.finishNode(node, "SafeMemberExpression");
} /* snip */
}
}
ここで試しに実行してみます。
$ node index.js
/Users/shuhei/work/js/null-friendly-js/node_modules/babel-core/lib/transformation/file/index.js:547
throw err;
^
ReferenceError: unknown: unknown node of type "SafeMemberExpression" with constructor "Node"
at CodeGenerator.print (/Users/shuhei/work/js/null-friendly-js/node_modules/babel-generator/lib/printer.js:69:13)
at CodeGenerator.VariableDeclarator (/Users/shuhei/work/js/null-friendly-js/node_modules/babel-generator/lib/generators/statements.js:292:10)
at CodeGenerator._print (/Users/shuhei/work/js/null-friendly-js/node_modules/babel-generator/lib/printer.js:143:19)
at CodeGenerator.print (/Users/shuhei/work/js/null-friendly-js/node_modules/babel-generator/lib/printer.js:88:10)
at CodeGenerator.printJoin (/Users/shuhei/work/js/null-friendly-js/node_modules/babel-generator/lib/printer.js:178:12)
at CodeGenerator.printList (/Users/shuhei/work/js/null-friendly-js/node_modules/babel-generator/lib/printer.js:243:17)
at CodeGenerator.VariableDeclaration (/Users/shuhei/work/js/null-friendly-js/node_modules/babel-generator/lib/generators/statements.js:275:8)
at CodeGenerator._print (/Users/shuhei/work/js/null-friendly-js/node_modules/babel-generator/lib/printer.js:143:19)
at CodeGenerator.print (/Users/shuhei/work/js/null-friendly-js/node_modules/babel-generator/lib/printer.js:88:10)
at CodeGenerator.printJoin (/Users/shuhei/work/js/null-friendly-js/node_modules/babel-generator/lib/printer.js:178:12)
SafeMemberExpression の AST が Generator まで来ているので、パースがうまくいっていることがわかりますね!
Transformer
次は Transformer を書きます。今回の変換の対象は以下です。
- メソッド呼び出し(callee が SafeMemberExpression)な CallExpression
- メソッド呼び出しでない SafeMemberExpression
書き方は Babel plugin handbook を参考に。babel-template
を使えば AST を作るのも簡単ですね。
var babel = require('babel-core');
var template = require('babel-template');
var buildReference = template('OBJECT == null ? OBJECT : OBJECT.PROPERTY');
var buildMethodCall = template('OBJECT == null ? OBJECT : OBJECT.METHOD(ARGUMENTS)');
function transformSafeMemberExpression(t) {
var types = t.types;
return {
visitor: {
SafeMemberExpression: function (path) {
var safeReference = buildReference({
OBJECT: path.node.object,
PROPERTY: path.node.property
});
path.replaceWith(safeReference);
},
CallExpression: function (path) {
var callee = path.node.callee;
if (callee.type === 'SafeMemberExpression') {
var safeMethodCall = buildMethodCall({
OBJECT: callee.object,
METHOD: callee.property,
ARGUMENTS: path.node.arguments
});
path.replaceWith(safeMethodCall);
}
}
}
};
};
var code = [
'var c = a?.b?.c;',
'a?.b?.c(d, e);',
].join('\n');
var result = babel.transform(code, {
plugins: [transformSafeMemberExpression]
});
console.log(result.code);
SafeMemberExpression なんてないよ、と言われてしまうので、ちょっと雑ですが以下のように AST type 定義に追加しておきます。
var babelTypes = require('babel-types');
// For visitor validation.
babelTypes.TYPES.push('SafeMemberExpression');
// For node validation.
babelTypes.FLIPPED_ALIAS_KEYS['Expression'].push('SafeMemberExpression');
そして実行すると・・・
$ node index.js
var c = (a == null ? a : a.b) == null ? a == null ? a : a.b : (a == null ? a : a.b).c;
(a == null ? a : a.b) == null ? a == null ? a : a.b : (a == null ? a : a.b).c(d, e);
できました!同じチェックを何回もしているのが微妙ですが、ちゃんと変換できているようです。
プラグイン化
上記のコード変更をうまいことモンキーパッチに変えて、プラグインモジュールを作りました。
shuhei/babel-plugin-null-safe-accessor
普通の Babel プラグイン同様、以下のような感じで利用できます。
npm install -D babel-plugin-null-safe-accessor
{
"plugins": [
"null-safe-accessor"
]
}
こちらでは、ついでに a.b?()
のように関数/メソッドを null 安全に呼び出す SafeCallExpression も追加してあります。そのうち ?[]
にも対応してみたいですね。
まとめ
- Java でできて JavaScript でできないことはない!(嘘)
- Babel に文法追加用の API はないけど、モンキーパッチすれば好きな文法を追加できる。