この記事は エムスリー Advent Calendar 2015 の4日目の記事です。
要約
自分で javac をコンパイルして、好きな文法を追加した Java言語のソースを書いちゃうという話です。
今回は Groovy などで見かける ?.
という Null Safe Reference を導入してみました。似たものに Elvis Operator とよばれる ?:
もあって、そのうちこれにも挑戦してみたいです。
Null Safe Operator, Elvis Operator は Javaへの機能追加として提案もされていたようです(もちろん、周知のとおり、取り込まれてはいません)
導入したい文法
オブジェクトのフィールド参照やメソッド呼び出しで、左辺値が null なら フィールド参照やメソッド呼び出しなどを行わず、結果を null にする
object?.field
object?.method(..)
同等のJavaコードへの変換イメージとしてはこのようなものです。
// 新文法 "Null Safe Reference"
String x = foo?.bar?.toString();
// 対応する正しい文法のJavaコード
String x = (foo == null) ? null
: (foo.bar == null) ? null
: foo.bar.toString();
上手くいったら配列にも適用できるようにしてみたいと思いますが、ひとまずは .
だけ対応します。
準備
以下を準備しておきます。
-
Oracle Java SE 8 の JDK
-
Apache Ant
今回は 1.9.3 を使いました。 -
Mercurial
OpenJDK は Mercurial でバージョン管理されているので、cygwin で Mercurial を追加しました。 -
OpenJDK の langtool
OpenJDK を初めて真剣に覗いてみました。また、各言語使用やツールごとのリポジトリと、それらの各JDKバージョンごとのリポジトリがあってどこを見ればよいのか少し迷います。ソースコード管理は Mercurial でした。
javac については Compiler 配下にある langtools というリポジトリを見ればよいようです。今回は JDK8 を使い、JDK9で挑戦するのは見送りました1。
> hg clone http://hg.openjdk.java.net/jdk8u/jdk8u/langtools
しばらくすると取得完了です。まずは普通にコンパイルして動作チェックしましょう。
コンパイラだけを作るには build-bootstrap-classes という ant ターゲットをビルドすればよいです。
> cd make
> ant -Dboot.java.home=c:\apps\jdk8 build-bootstrap-javac
それほど時間かからずにコンパイルできました!
build\bootstrap\lib\javac.jar というファイルが出来上がります。
これを使って試しに以下のサンプルをコンパイルしてみます。
public class Exp {
public static void main(final String[] args) {
String foo = null;
System.out.println("length of foo:" + foo.length());
}
}
> java -classpath c:\me\hg\jdk8u\langtools\build\bootstrap\lib\javac.jar com.sun.tools.javac.Main Exp.java
> java Exp
Exception in thread "main" java.lang.NullPointerException
at Exp.main(Exp.java:4)
いい感じに? NPE が発生しています。
では、Null Safe Reference を使ったコードをコンパイルします。
> java -classpath c:\me\hg\jdk8u\langtools\build\bootstrap\lib\javac.jar com.sun.tools.javac.Main Exp2.java
Exp2.java:4: エラー: 式の開始が不正です
System.out.println("length of foo:" + foo?.length());
^
エラー1個
もちろんコンパイルエラーになりました。
ところで、javac ともなればとっくに ANTLR などのパーサジェネレータを使って構文解析のコードを生成していると思っていたのですが、まだ現状では手書きのコードだそうです。現在は ANTLR ベースにするプロジェクト OpenJDK compiler grammer が活動しているようです。これも楽しみです。
手書きパーサーということなので、構文定義のファイルをいじるのではなく、今のパーサーのコードを直接いじって今回の目的を果たすことになりそうです。
新文法の導入
冒頭のサンプルにあったように、null 参照した時は全体の値が null になるような文法を導入します。
もう少し具体的には <ident>?.<field>
というコードがでてきたら `( == null) ? null : .`` に変換したいということです。
このためにどういう構文木を作ったらよいのか考えます。
まずは今回導入する文法が、javac の内部表現としてどういう AST(抽象構文木)になるのか、つまり javac 本体のコードとしてどう書けばよいのか、を順に調べていきたいと思います。
三項演算子に相当する構文木をさがす
最初に三項演算子 ? :
から始めます。
? :
のパーサ部分のコードは以下のようになっていました。コメントにBNFが書いてあるので探しやすいです。
/** Expression1Rest = ["?" Expression ":" Expression1]
*/
JCExpression term1Rest(JCExpression t) {
if (token.kind == QUES) {
int pos = token.pos;
nextToken();
JCExpression t1 = term();
accept(COLON);
JCExpression t2 = term1();
return F.at(pos).Conditional(t, t1, t2);
} else {
return t;
}
}
F.at(pos). ナントカ で構文木を構成しているようです。
F は TreeMaker クラスです。このクラスはその名のとおり構文木をつくる役割で、at(pos) を指定することでソースの現在行を覚えさせ、次いでそこを参照する構文木を Conditional() や Literal() など木の種類ごとの生成メソッドで作る、という使い方のようです。
それぞれの構文木の要素自体は JCTree クラスを継承したクラスでできています。
field == null に相当する構文木を探す
つづいて null に相当する構文木を探します。リテラルとして定義されていました。
case NULL:
t = F.at(pos).Literal(
TypeTag.BOT,
null);
最後に expr1 == expr2
です。二項演算子は共通のコードで処理されていました。
/** Construct a binary or type test node.
*/
private JCExpression makeOp(int pos,
TokenKind topOp,
JCExpression od1,
JCExpression od2)
{
if (topOp == INSTANCEOF) {
return F.at(pos).TypeTest(od1, od2);
} else {
return F.at(pos).Binary(optag(topOp), od1, od2);
}
}
optag の中で EQEQ
は JCTree.Tag.EQ
を返すようになっていました。
ここまでくると、作りたい構文木をどういう javac のコードで書けばよいか、全体が見えてきました。
F.at(pos).Conditional( // A ? B : C
F.at(pos).Binary( // Aの部分: X op Y
JCTree.Tag.EQ, // op として == を使う
F.at(pos).Literal(TypeTag.BOT, null), // Xの部分: null
<identifier token>), // Yの部分: nullチェック対象となるオブジェクト等
F.at(pos).Literal(TypeTag.BOT, null), // Bの部分: null
<place holder for consequent tokens>) // Cの部分: 元の式全体
という形になりそうです。
新文法のコードを差し込む場所を探す
続いて、?.
を解釈して構文木をつくる部分を探します。.
(トークン名 DOT)を扱っているところをいろいろ探せばよさそうですが、Java では パッケージ名の区切りにも使われているのでそちらと混同しないようにします。
他にも、よく考えれば .class とか .super() とか DOT を使う表現がいろいろあるよな……と混乱してきたので、コメント化されている BNF を元にちょっと構文全体を見直してみます。
式全体を表す Expression から。
* Expression = Expression1 [ExpressionRest]
* ExpressionRest = [AssignmentOperator Expression1]
* AssignmentOperator = "=" | "+=" | "-=" | "*=" | "/=" |
* "&=" | "|=" | "^=" |
* "%=" | "<<=" | ">>=" | ">>>="
Expression1 は 代入されえない式(右辺値)ととらえればよさそう。
* Expression1 = Expression2 [Expression1Rest]
* Expression1Rest = ["?" Expression ":" Expression1]
Expression2 は 三項演算子を含まない残り。
* Expression2 = Expression3 [Expression2Rest]
* Expression2Rest = {infixop Expression3}
* | Expression3 instanceof Type
* infixop = "||"
* | "&&"
* | "|"
* | "^"
* | "&"
* | "==" | "!="
* | "<" | ">" | "<=" | ">="
* | "<<" | ">>" | ">>>"
* | "+" | "-"
* | "*" | "/" | "%"
Expression3 は 二項演算子を含まない残り。
* Expression3 = PrefixOp Expression3
* | "(" Expr | TypeNoParams ")" Expression3
* | Primary {Selector} {PostfixOp}
*
* {@literal
* Primary = "(" Expression ")"
* | Literal
* | [TypeArguments] THIS [Arguments]
* | [TypeArguments] SUPER SuperSuffix
* | NEW [TypeArguments] Creator
* | "(" Arguments ")" "->" ( Expression | Block )
* | Ident "->" ( Expression | Block )
* | [Annotations] Ident { "." [Annotations] Ident }
* | Expression3 MemberReferenceSuffix
* [ [Annotations] "[" ( "]" BracketsOpt "." CLASS | Expression "]" )
* | Arguments
* | "." ( CLASS | THIS | [TypeArguments] SUPER Arguments | NEW [TypeArguments] InnerCreator )
* ]
* | BasicType BracketsOpt "." CLASS
* }
*
* PrefixOp = "++" | "--" | "!" | "~" | "+" | "-"
* PostfixOp = "++" | "--"
* Type3 = Ident { "." Ident } [TypeArguments] {TypeSelector} BracketsOpt
* | BasicType
* TypeNoParams3 = Ident { "." Ident } BracketsOpt
* Selector = "." [TypeArguments] Ident [Arguments]
* | "." THIS
* | "." [TypeArguments] SUPER SuperSuffix
* | "." NEW [TypeArguments] InnerCreator
* | "[" Expression "]"
* TypeSelector = "." Ident [TypeArguments]
* SuperSuffix = Arguments | "." Ident [Arguments]
* MemberReferenceSuffix = "::" [TypeArguments] Ident
* | "::" [TypeArguments] "new"
大物が来ました……
定義が細かすぎてつかみにくい部分に名前をつけてみます。特にメソッド参照の後にもいろいろつけられる2ので、ややこしく見えていたようです。
* Expression3 = PrefixOp Expression3
* | "(" Expr | TypeNoParams ")" Expression3
* | Primary {Selector} {PostfixOp}
*
* Primary = "(" Expression ")"
* | Literal
* | ThisClassExpression
* | SuperClassExpression
* | ConstructorExpression
* | LambdaExpression
* | FqdnExpression
* | Expression3 MemberReferenceSuffix [ MemberReferenceFollowExpression ]
* | BasicType BracketsOpt "." CLASS
*
* Selector = "." FieldOrCallExpression
* | "." THIS
* | "." SuperClassExpression
* | "." InnerConstructorExpression
* | ArrayExpression
こうやってみると Selector のところにある "." だけについて、"?" が前にあるパターンを処理すればよさそうです。this
とか super
とかは対応しないという手もありますがまとめてやっちゃいます。
構文を修正すると
* Expression3 = ...
* | Primary { NullSafeSelector | Selector } {PostfixOp}
* NullSafeSelector = "?." FieldOrCallExpression
* | "?." THIS
* | "?." SuperClassExpression
* | "?." InnerConstructorExpression
* | ArrayExpression
という感じでしょうか。
該当するコードは、以下の部分 でよさそうです。
} else if (token.kind == DOT) {
nextToken();
typeArgs = typeArgumentsOpt(EXPR);
if (token.kind == SUPER && (mode & EXPR) != 0) {
mode = EXPR;
t = to(F.at(pos1).Select(t, names._super));
nextToken();
t = arguments(typeArgs, t);
typeArgs = null;
} else if (token.kind == NEW && (mode & EXPR) != 0) {
if (typeArgs != null) return illegal();
mode = EXPR;
int pos2 = token.pos;
nextToken();
if (token.kind == LT) typeArgs = typeArguments(false);
t = innerCreator(pos2, typeArgs, t);
typeArgs = null;
} else {
List<JCAnnotation> tyannos = null;
if ((mode & TYPE) != 0 && token.kind == MONKEYS_AT) {
// is the mode check needed?
tyannos = typeAnnotationsOpt();
}
t = toP(F.at(pos1).Select(t, ident()));
if (tyannos != null && tyannos.nonEmpty()) {
t = toP(F.at(tyannos.head.pos).AnnotatedType(tyannos, t));
}
t = argumentsOpt(typeArgs, typeArgumentsOpt(t));
typeArgs = null;
}
ここで、 トークンが QUESDOT だったらさっき検討した構文木を作って、再帰的に残りを処理する、というようなコードにすればよさそうです。
具体的に修正していく
まずは、?.
という一続きの演算子的なトークンを作る必要があるので、 QUESDOT
というトークンを定義します。
@@ -227,6 +227,7 @@
GTGTEQ(">>="),
GTGTGTEQ(">>>="),
MONKEYS_AT("@"),
+ QUESDOT("?."),
これだけで行けるかと思ったのですが、各種の演算子トークンを切り出す汎用処理で、DOT があるとそこで処理を打ち切ってしまうので、DOT も演算子トークンとして解析するよう修正。
ここはデグレを考えると、前の文字が QUES の時だけ DOT もみるようにすべきですが……
@@ -441,7 +441,7 @@
}
tk = tk1;
reader.scanChar();
- if (!isSpecial(reader.ch)) break;
+ if (!isSpecial(reader.ch) && (reader.ch != '.')) break;
}
ここまでの流れでうまくいっているのかをチェックするため、まずは ?.
が来た時にエラーにせず単に無視するだけのコードにしてみます。
+ } else if (token.kind == QUESDOT) {
+ nextToken();
+ typeArgs = typeArgumentsOpt(EXPR);
+ if (token.kind == SUPER && (mode & EXPR) != 0) {
....
これで前述の新文法のコード Exp2.java をコンパイルすると、コンパイルエラーは出なくなります。ただし実行するともちろん NPE です。
c:>java -classpath c:\me\hg\jdk8u\langtools\build\bootstrap\lib\javac.jar com.sun.tools.javac.Main Exp2.java
c:>java Exp2
Exception in thread "main" java.lang.NullPointerException
at Exp2.main(Exp2.java:4)
そろそろ本丸のパーサー部分に例の構文木を突っ込みます。ちょっと冗長ですが丸ごと DOT の処理をコピペしてサンプルを作ってみます。
@@ -1414,6 +1415,45 @@
t = to(F.at(pos1).Indexed(t, t1));
}
accept(RBRACKET);
+ } else if (token.kind == QUESDOT) {
+ JCExpression t2 = t;
+ nextToken();
+ typeArgs = typeArgumentsOpt(EXPR);
+ // ... ここは全部 この下にある DOT の処理部分のコピー
+ }
+ t = F.at(pos1).Conditional(
+ F.at(pos1).Binary(
+ JCTree.Tag.EQ,
+ F.at(pos1).Literal(TypeTag.BOT, null),
+ t2),
+ F.at(pos1).Literal(TypeTag.BOT, null),
+ t)
+ ;
} else if (token.kind == DOT) {
pos1 とか pos2 がどう設定されているのかまだよく見えていませんが、強引にコンパイルしてみます。
c:>java -classpath c:\me\hg\jdk8u\langtools\build\bootstrap\lib\javac.jar com.sun.tools.javac.Main Exp2.java
c:>java Exp2
length of foo:null
できた!
一応バイトコードも確認してみます。
public static void main(java.lang.String[]);
Code:
0: aconst_null
1: astore_1
2: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
5: new #3 // class java/lang/StringBuilder
8: dup
9: invokespecial #4 // Method java/lang/StringBuilder."<init>":()V
12: ldc #5 // String length of foo:
14: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
17: aconst_null
18: aload_1
19: if_acmpne 26
22: aconst_null
23: goto 33
26: aload_1
27: invokevirtual #7 // Method java/lang/String.length:()I
30: invokestatic #8 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
33: invokevirtual #9 // Method java/lang/StringBuilder.append:(Ljava/lang/Object;)Ljava/lang/StringBuilder;
36: invokevirtual #10 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
39: invokevirtual #11 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
42: return
LineNumberTable:
line 3: 0
line 4: 2
line 5: 42
19,22行目あたりで null チェックして残りを処理するかどうか分岐しているのがわかります。
JVM にはnull かどうかを一気に判定する命令があったような気もするのですが……また別の機会に調べてみます。
まとめ
- java の文法に不満がある場合、バイトコード生成しまくりアプローチ と、 他のJVM言語にいっちゃう との間の選択肢として 独自改造 javac というアプローチもあり得ることがわかった(嘘)
- でも全体として整合性撮れている文法かどうか確認しづらいので、 Compiler Grammer プロジェクト楽しみ
- ちょっと乱暴なハックなのでもう少し中身の理解を深める必要あり
以上!
2015.12.08追記
慣れない Mercurial と格闘しソースを bitbucket にあげました。コミット履歴が汚くて恥ずかしい……
あわせて配列インデックスの前の ?[
と、Elvis Operator ?:
も導入してみました。以下のようなソースがコンパイル通って実行できます。
public class Exp3 {
public static void main(final String[] args) {
String[] foo = null;
System.out.println("length of foo:" + (foo?[0].length() ?: -1));
}
}
しかし、今回のやり方では null チェックしている部分を2回評価していることに気が付きました。これは修正しないといけない……
-
最初、jdk9 の langtools リポジトリ に挑戦したのですが、Java9で導入されるモジュール分割の影響か、Oracle の Java SE 8 のコンパイラの機能では「jrtというファイルシステムがない」というコンパイルエラーになってしまいました。すでに存在する Java8 に対応した javac で Java8 のコンパイラを作るというのは少し反則気味ですがこれを使うことにしました。 ↩
-
本当だろうか?? 適切な文脈なら
Hoge::foobar[1]
とか書けちゃうの?? ちょっと信じられないです。 ↩