groovy-macro-methodsについて(前編)

はじめに

これはG*アドベントカレンダーの第14日目の記事です。

今回は、groovy-macro-methodsをご紹介します。

なお、本記事は半年くらい前のワークショップで発表する予定だったネタですが、時間の都合上発表できなかったので、今回その時のネタを使い回すことにしました。

groovy-macro-methodsとは

簡単に言いますと、groovy-macro-methodsは、AST(Abstract Syntax Tree : 抽象構文木)を引数に受け取り変換したASTを返すメソッドを、マクロ(コンパイル時にASTを変換する機能)としてサポートするためのライブラリです。

詳細は、次のspeakerdeckを参照してください。

groovy-macro-methodsの例

例えば、

def nullObject = null

assert null == nullObject.hashCode()

といったコードがある場合、NullPointerExceptionで落ちます。これを、

def nullObject = null

assert null == safe(nullObject.hashCode())

という風にsafeマクロで「コンパイル時にASTを変換」して、NullPointerExceptionで落ちないようにしたいと思います。つまり、「nullObject.hashCode()」といったメソッド呼び出しのASTを引数に受け取り、

def nullObject = null

assert null == (nullObject != null ? nullObject.hashCode() : null)

といった三項演算子のASTに変換してコンパイル時に置き換える、というマクロを定義します。この場合のsafeマクロの定義は次のようになります。

package foo.bar;

import org.codehaus.groovy.ast.expr.*;
import static org.codehaus.groovy.ast.tools.GeneralUtils.*;
import ru.trylogic.groovy.macro.runtime.*;

public class TestMacroMethods {
    @Macro
    public static Expression safe(MacroContext macroContext,
                                  MethodCallExpression callExpression) {
        return ternaryX(notNullX(callExpression.getObjectExpression()),
                                callExpression, constX(null));
    }
}

引数に「メソッド呼び出しの結果」ではなく「メソッド呼び出しのAST」を受け取り、三項演算子のASTに変換した結果(のAST)を戻り値として返します。 notNullX()が「引数がnullかどうかを判定するAST」を生成するメソッド(三項演算子の「nullObject != null」の部分)、constX()が「引数を定数として表すAST」を生成するメソッド、ternaryX()が「三項演算子のAST」を生成するメソッド、となります。

groovy-macro-methodsを使用したマクロの作り方

groovy-macro-methodsを使用してマクロを作る際、次の点に気をつけてください。

  1. public staticメソッドを用意する。
  2. メソッドに@Macroを付ける。
  3. メソッドの第一引数はMacroContextとする。
  4. メソッドの第二引数以降は変換したいASTのクラスとする。
  5. 戻り値はExpressionかExpressionの派生クラスとする。
  6. メソッド定義とは別に、拡張モジュールの定義を行う。

public staticメソッドを用意する。

マクロは、public staticのメソッドとして定義します。public staticでないとマクロとして認識してくれません。

メソッドに@Macroを付ける。

このメソッドがマクロであると宣言します。

メソッドの第一引数はMacroContextとする。

メソッドの第一引数はMacroContext型の値です。MacroContext自体は、次のクラスのオブジェクトを持ちます。

  • CompilationUnit
  • SourceUnit
  • マクロ自体のMethodCallExpression

先の例のように、必ずしも使用しないといけないわけではありません。

メソッドの第二引数以降は変換したいASTのクラスとする。

メソッドの第二引数以降は変換したいASTのクラスを指定します。 ただし、指定できるASTのクラスは、Expression(式)かその派生クラスのみとなります。なので、Statement(文)やその派生クラスをマクロに渡して変換することはできません。 これをなんとかしたい場合、ClosureExpression(クロージャ式)を引数とするマクロを定義し、ClosureExpression内のBlockStatement内のStatementを変換、といったことならできます。

戻り値はExpressionかExpressionの派生クラスとする。

戻り値はExpression(式)かExpressionの派生クラスとなります。Statement(文)やその派生クラスを戻り値として返すことはできません。 これをなんとかしたい場合、Closureを定義してcallメソッドを呼び出す、つまりClosureExpression(クロージャ式)のMethodCallExpression(メソッド呼び出し)を戻り値として返すマクロを定義し、そのClosureExpressionの中でStatementやその派生クラスを定義する、といったことならできます。

メソッド定義とは別に、拡張モジュールの定義を行う。

メソッドを定義するだけではマクロとして機能せず、groovyの拡張モジュールの定義を行う必要があります。

ファイル「META-INF/services/org.codehaus.groovy.runtime.ExtensionModule」を作成し、extensionClassesにマクロの定義を持つクラスのFQCN(完全修飾クラス名)を指定します。例えば先のsafeマクロの拡張モジュールの定義は次のとおりです。

moduleName=macro-module
moduleVersion=1.0
extensionClasses=foo.bar.TestMacroMethods 

最終的には

マクロを使用した場合、最終的には、

def nullObject = null

assert null == safe(nullObject.hashCode())

というコードは、

def nullObject = null

assert null == 
    MacroStub.INSTANCE.macroMethod
        (nullObject != null ?
            nullObject.hashCode() : null)

というコードにコンパイル時に変換され実行されます。macroMethodメソッド自体は、

public enum MacroStub {
    INSTANCE;
    public <T> T macroMethod(T obj) {
        return obj;
    }
}

のように引数を返すだけのメソッド呼び出しとなっています。

マクロの使い所

普通のメソッド呼び出しで事足りるのであれば、マクロを使う必要はないと思います。普通のメソッド呼び出しでは実現できない場合にマクロ(= AST変換)が使えないか検討してみてください。例えば、DSL(Domain Specification Language)、糖衣構文(シンタックスシュガー)、等です。

マクロ開発の問題点

マクロを開発する上で問題となることと言えば、マクロがどういうASTを返しているか、想定したASTを返しているか、といったことがわかりづらい、ということでしょう。

これを解決するために、GroovyConsoleを使用します。GroovyConsoleで「Inspect Ast」を実行すると、「Semantic Analysis」以降であれば、マクロを呼び出した際のAST変換後のコードを見ることができます。

後編に続く

さて、前編はここまで、groovy-macro-methodsとは何か、groovy-macro-methodsを使ったマクロの作り方をご紹介しました。

後編は、groovy-macro-methodsを使った「あまり実用的ではないのですが、サンプルとしてはまぁ何とか...」というマクロをいくつか作ってみましたので、それをご紹介することで、groovy-macro-methodsでどんなことができるかご紹介したいと思います。