Javaを創ろう
この記事はJava Advent Calendarの13日目の記事です.
昨日は@kisさんのJava SE 8でパターンマッチを実装するでした.
明日は@megascusさんです.
皆さんはおそらく普段からJavaを使ってプログラムを作っているかと思います.
そんな皆さんはJava言語について多くの思い・想いを持っているかと思います.
例えば,「こんな事ができたら良いのに」だったり,「ここが良くないんだよなぁ」といった具合です.
そういった言語特徴の追加や改善を自分の手でできたら素敵だと思いませんか?
幸いJavaはOpenJDKというオープンソースプロジェクトで開発が行われており,第三者の僕達でもソースコードを手に入れ自由に変更を加えることができます.*1
この記事では言語の拡張の仕方を述べ,OpenJDKを実際に用いて極々簡単な言語特徴を実装してみます.
コンパイラ概要
コンパイラの概要について軽く述べておきます.
より深い知識についてはコンパイラ関連の書籍を参考にしてください.
まずコンパイラはソースコードという「文字」の並びを「トークン」の並び(トークン列)に変換します.*2
この変換を行なうのがトーカナイザです.
例えばJavaの次のコードは以下のようなトークン列として解釈されます
public static void main(String[] args) {}
PUBLIC
STATIC
VOID
IDENT("main")
LPAREN
IDENT("String")
LBRACKET
RBRACKET
IDENT("args")
RPAREN
LBRACE
RBRACE
続いてこのトークン列をシンタックス(構文)に従いパースしていきます.
そしてその結果として抽象構文木(AST)を生成します.
この時にシンタックス上の誤りが検出されます.
以降はこの抽象構文木を元にセマンティックス(意味論)の検査などを行います.
この段階ではセマンティックス上の誤りが検出されます.
言語特徴を追加する際の流れ
言語特徴を追加する際はまず設計を行い実装を行なっていきます.
設計段階ではそもそもどのような特徴を実装するのかやどのような構文にするのか,またその特徴がどのような意味論を持つのかなどを検討します.
また,必要であればバイトコード命令の拡張も検討します.
続いて実装を行います.
トーカナイザ・パーサの実装
通常,新たな特徴を追加する場合は新たな構文を追加したり,既存の構文を変更したりするためパーサの実装を変える必要が出てきます.
OpenJDKではcom.sun.tools.javac.parser.JavacParserなどを弄ります.
また,新たな予約語を追加する場合などはトーカナイザも変更する必要があります.
例えばenumを追加する際や2進数数値の追加やラムダ式を追加する際には必要ですね.
OpenJDKではcom.sun.tools.javac.parser.Tokensやcom.sun.tools.javac.parser.JavaTokenizerなどを変更します.
ASTからコード生成までの実装
もしも追加する特徴が既存の抽象構文木に落としこめなければ新たな抽象構文木を定義する必要があります.
OpenJDKではcom.sun.tools.javac.tree.JCTreeやcom.sun.tools.javac.tree.TreeMakerやcom.sun.tools.javac.tree.JCTree.Visitorを継承するすべてのクラスを変更します.
ここでは新しい抽象構文木を既存の抽象構文木へ帰着させる処理やシンボル化やセマンティックスの検査なども実装します.
com.sun.tools.javac.compパッケージやcom.sun.tools.javac.codeパッケージあたりを変更する必要があります.
OpenJDKをビルドする
では,まずはじめにOpenJDKのソースコードを手に入れてビルドをしてみましょう.
これができなければ実装をして使ってみるといった事ができないですね.
僕の環境がUbuntuなのでここではUbuntuベースで話を進めます.
OpenJDKのソースコードはMercurialで管理されているのでまずはMercurialを手に入れましょう.
% sudo aptitude install mercurial
Mercurialが手に入ったらOpenJDKのソースコードを手に入れましょう.
今回は現在開発中のjdk8/tlというリポジトリを使います.
jdk7とはビルドの手順が微妙に違いますので注意してください.
% hg clone http://hg.openjdk.java.net/jdk8/tl MyJDK
おそらく1分もかからずにクローンが終わるかと思います.
実はこれだけではすべてのソースコードを手に入れることはできません.
OpenJDKのコードは分野別でリポジトリが分かれているためそれらを個別でクローンしなければなりません.
そのためのスクリプトが今クローンしたリポジトリにあるためそれを実行しましょう.
% cd MyJDK % sh ./get_source.sh
このスクリプトを実行することでビルドに必要なすべてのソースコードを手に入れることができます.
ネットワークの都合にもよりますが数分程度掛かるかと思います.
以上ですべてのソースコードを手に入れることができたかと思います.
役割でリポジトリがわかれていると述べましたが,JDK8のOpenJDKのリポジトリは以下のようになっています.
リポジトリ名 | 説明 |
---|---|
.(root) | 設定やmakeの仕組みを提供 |
hotspot | JVM(hotspot)のソースコード類 |
langtools | javacなどの言語処理のツールのソースコード類 |
jdk | コアライブラリのソースコード |
jaxp | JAXPのソースコード |
jaxws | JAX-WSのソースコード |
corba | Corbaのソースコード |
nashorn | nashorn(JSの実装)のソースコード |
言語特徴の追加ではlangtoolsとhotspotのリポジトリに対して変更を加えていきます.
ここからはOpenJDKをビルドして行きましょう.
ビルドにあたっては「MyJDK/README-builds.html」というファイルが参考になります.
英語になりますが,各プラットフォームごとのビルドの仕方が書いてあります.
まず,OpenJDKをビルドに依存するパッケージをインストールしましょう.
% sudo aptitude build-dep openjdk-7-jdk % sudo aptitude install openjdk-7-jdk libmotif-dev
続いてconfigureを実行します.
% bash ./configure.sh
configureが通ればビルドできますのでビルドしましょう.
% make all
allを指定することによってイメージの作成も行なってくれます.
使用するコンピュータの性能に左右されますが30分から1時間ぐらい掛かるかもしれません.
もしもconfigureやmakeが失敗したら以下の設定を適用してみてもう一度行なってみてください.
export LANG=C export PATH="/usr/lib/jvm/java-7-openjdk/bin:${PATH}"
ビルドが出来れば言語特徴に変更を加えて行きましょう.
Javaに新たな特徴を追加する
では,新たな特徴を追加して行きましょう.
今回は簡単な例として後置キャスト式を追加します.
まず,最初に後置キャスト式について説明しておきましょう.
JavaはC言語などと同様に前置キャスト式を採用しており,キャスト演算子をキャストしたい式の前に書くというものです.
以下は一例です,以下のような例であればジェネリクスなりを使うべきです.
String s = (String)l.get();
この前置キャスト式はメソッドチェインの場合などではキャストする対象を()で囲んで明示しなければならず()がかさみ読みにくくなる場合があります.
int len = ((String)l.get()).length();
そのためかScalaなどではキャスト演算子をキャストしたい式の後に書くようなスタイルを採用しています.*3
val len = l.get.asInstanceOf[String].length
先程の例よりかは長くなってしまいましたが,左から右へという式の流れが維持され流れるように読むことができるようになっているかと思います.
これと同様の機能をJavaの言語の特徴として実装してみましょう.
シンタックスの設計
最初に述べた通りシンタックスの設計から行いましょう.
基本的なシンタックスはScalaのものを踏襲しましょう.
Scalaのものはあくまでただのメソッド呼び出しですが,これを構文として捉えた場合は次のようになります.
expr DOT asInstanceOf LBRACKET type RBRACKET
ここではDOTは.をLBRACKETは[をRBRACKETは]を示しています.
また,exprは何らかの式を,typeは何らかの型を示しています.
これを元にJavaらしいシンタックスを考えてみます.
まず,JavaにはasInstanceOfに字面が似たinstanceofという予約語があります.これをasInstanceOfの代わりに使いましょう.
そして,Javaでは型を囲む様な記号は[]ではなく<>を使っていますのでそれを使うようにしましょう.
これらを元にすると以下のようなシンタックスになります.
expr DOT INSTANCEOF LT type GT
INSTANCEOFはinstanceofという予約語を,LTとGTは<と>を示しています.
先程の例をこの構文で書くと次のようになります.
int len = l.get().instanceof<String>.length():
セマンティックスの設計
続いてセマンティックスの設計をしましょう.
セマンティックスはJavaの今までのキャスト式と同じとしましょう.
本来であれば「この場合にはこのような意味を持つ」であったり,「このような場合はコンパイルエラーになる」といったようなことを新たに決めなければなりません.
今回は都合よく既にJavaに同様のセマンティックスが存在していたためそれを流用しました.*4
セマンティックスが同じであるためAST以降は今までのキャスト式と同じ物が使えます.
そのためセマンティックス周りの実装はほとんどありません.*5
以上で設計は終わりです.
続いて実装を行なって行きましょう.
パーサ,トーカナイザの実装
新たな構文を付け加えますが,新たな予約語を導入したりはしませんのでトーカナイザを弄る必要はありません.
パーサに変更を加えるのでlangtoolsのcom.sun.tools.javac.parser.JavacParserを開きましょう.
開くファイルはMyJDK/langtools/src/share/classes/com/sun/tools/javac/parser/JavacParser.javaにあります.
Javaのパーサで.で繋がれた式の解釈は次の二つで別れており,別々の箇所で行われています.
IDENT DOT 〜
その他 DOT 〜
IDENTは識別子のことです.
IDENT DOT 〜でinstanceOf<...>を受け付けるように書き換えましょう.
該当する部分は1203行目あたりです.
かなり途中を省略しています.
JavacParser#term3
case UNDERSCORE: case IDENTIFIER: case ASSERT: case ENUM: switch (token.kind) { case DOT: switch (token.kind) { case INSTANCEOF: if (typeArgs != null) return illegal(); // ident.<Type>instanceof・・・となっていたらエラー nextToken(); // INSTANCEOFを読み飛ばす accept(LT); // 次のトークンを読み取りLT(<)でなければエラー // Create TypeCast tree accept(GT); // 次のトークンを読み取りGT(>)でなければエラー break loop; } }
コメントを書いていますので雰囲気は伝わると思います.
このように書くことでident.instanceof<>といった構文を受け付けることができるようになりました.
続いてその他の場合にも対応させます.
該当する部分は1419行目あたりです.
先ほどと同様かなりの部分を省略しています.
JavacParser#term3Rest
} else if (token.kind == DOT) { nextToken(); } else if (token.kind == INSTANCEOF && (mode & EXPR) != 0) { if (typeArgs != null) return illegal(); nextToken(); accept(LT); // Create TypeCast tree accept(GT); } else {
行なっている処理は先程とあまり変わっていません.
続いて型の部分をパースし,キャスト式のASTを生成します.
これは既存のキャスト式のものを流用できます.
まず既存のものをメソッド化します.
前置キャスト式をパースしている部分は1094行目付近です
JavacParser#term3
case CAST: accept(LPAREN); mode = TYPE; int pos1 = pos; List<JCExpression> targets = List.of(t = term3()); while (token.kind == AMP) { checkIntersectionTypesInCast(); accept(AMP); targets = targets.prepend(term3()); } if (targets.length() > 1) { t = toP(F.at(pos1).TypeIntersection(targets.reverse())); } accept(RPAREN); mode = EXPR; JCExpression t1 = term3(); return F.at(pos).TypeCast(t, t1);
このコードを一部を次のようにメソッドに括り出します.
JavacParser#parseCastType
public JCExpression parseCastType(int pos) { int pos1 = pos; JCExpression t; List<JCExpression> targets = List.of(t = term3()); while (token.kind == AMP) { checkIntersectionTypesInCast(); accept(AMP); targets = targets.prepend(term3()); } if (targets.length() > 1) { t = toP(F.at(pos1).TypeIntersection(targets.reverse())); } return t; }
そして,キャストをパースする三つのコードでこのメソッドを呼び出すようにすれば完成です.
完成したソースコードは以下のリポジトリにあげています.
https://bitbucket.org/bitter_fox/openjdk-langtools-postfix-cast
また,Diffはこちらになります.
最後にもう一度ビルドを行なって問題がないか使ってみて完成になります.*6