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)を生成します.
この時にシンタックス上の誤りが検出されます.

以降はこの抽象構文木を元にセマンティックス(意味論)の検査などを行います.
この段階ではセマンティックス上の誤りが検出されます.

セマンティックスの検査などが終わればコンパイラはコード生成を行います.
Javaの場合ではクラスファイルですね.

言語特徴を追加する際の流れ

言語特徴を追加する際はまず設計を行い実装を行なっていきます.

設計段階ではそもそもどのような特徴を実装するのかやどのような構文にするのか,またその特徴がどのような意味論を持つのかなどを検討します.
また,必要であればバイトコード命令の拡張も検討します.

続いて実装を行います.

トーカナイザ・パーサの実装

通常,新たな特徴を追加する場合は新たな構文を追加したり,既存の構文を変更したりするためパーサの実装を変える必要が出てきます.
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パッケージあたりを変更する必要があります.

コード生成の実装

もしも新しい特徴が新たなコードを生成しなければいけない場合はコード生成系の処理に変更を加えます.

com.sun.tools.javac.jvm,comp,codeなどを変更します.

JVMの実装

生成されるクラスファイルに変更があった場合はJVM(hotspot等)の実装を変更しなければいけません.
C++です.頑張って実装しましょう.

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に新たな特徴を追加する

では,新たな特徴を追加して行きましょう.

今回は簡単な例として後置キャスト式を追加します.
まず,最初に後置キャスト式について説明しておきましょう.

JavaC言語などと同様に前置キャスト式を採用しており,キャスト演算子をキャストしたい式の前に書くというものです.
以下は一例です,以下のような例であればジェネリクスなりを使うべきです.

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

まとめ

かなり駆け足でしたがJava言語を拡張する流れを説明しました.
このようにして見てみると僕達,一般開発者でもJavaを創れる気がしてきませんか?

Java(OpenJDK)は他のOSSと同様に僕達のような一般開発者をJavaの開発者(Javaを創る人)として受け入れる様な土壌があります.
もしこういう機能があったらいいなぁと思う人は自分で実装をしてみてProjectCoinやAdopt a JSRといった場で提案してみてはいかがでしょうか?
*7

また,javacなどのバグを見つけた時にバグパレードへ報告するのは勿論,一度自分で直せないか取り組んでみてはいかがでしょうか?

*1:当然OpenJDKのライセンスの範囲内にはなりますが.

*2:OpenJDKではパースしながら必要に応じてトークン化していきます

*3:Scalaでは言語の特徴と言うよりはライブラリの特徴(Anyクラスのメソッド)として用意されている

*4:前置キャスト式ですね

*5:セマンティックス周りの実装を省略できたらかなり楽になるので後置キャストという特徴を選びました:)

*6:実際はテストを書いて本当の完成です

*7:実装が無くても提案はできるのでアイディアがあればぜひ積極的に提案しましょう