The King's Museum

ソフトウェアエンジニアのブログ。

【Effective Java】項目35:命名パターンよりアノテーションを選ぶ

アノテーション

Javaアノテーションは、ソースコードメタデータを付与するための仕組みです。 付与されたアノテーションは、コンパイル時にコンパイラが利用したり、実行時に仮想マシンが利用したりします。 通常、アノテーションメタデータなのでコードのセマンティクスに影響を与えません。

Java では標準でいくつかアノテーションが用意されていますが、自分で新たなアノテーションを作成することもできます。

public @interface Test {
}

上記では @Test という自作のアノテーションを作成しています。 このアノテーションは最もシンプルなアノテーションです。

アノテーションに対してさらにアノテーションをつけることも可能です。 メタアノテーションと呼ばれるものです。

例えば、アノテーションメソッドやクラスに対して付与できますが、メタアノテーションによって付与先を制限することもできます。

// @Test の付与先はメソッドのみ
@Target(ElementType.METHOD) 
public @interface Test {
}

この場合、@Test アノテーションメソッドにしか付与できなくなります。 クラスにこのアノテーションが付けられているとコンパイルエラーとなります。

また、アノテーションをプログラムのどの時点まで保持するかを指定することも出来ます。 実行時に必要のないメタデータコンパイル時に削除されていても問題ないからです。

例えば @Override アノテーションコンパイル時にのみ保持するように設定されています。

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Override {
}

http://grepcode.com/file/repository.grepcode.com/java/root/jdk/openjdk/7u40-b43/java/lang/Override.java?av=f

このようにして好きなようにアノテーションを作成することができます。

命名パターンとアノテーション

コンパイラフレームワークに特別な処理を依頼したい場合、命名パターンが利用されることがあります。 例えば JUnit における「テストケースメソッドは先頭に test をつけること」といった規則のことです。

この命名パターンには明らかに問題があります。

  • コンパイラが間違いを判断できない
  • パラメータを関連付けにくい
  • 利用箇所(クラス?メソッド?)を限定できない

これらの問題点はアノテーションによってすべて解決することができます。

例えば、先ほどの「テストケースメソッドの先頭に test をつける」という命名規則アノテーションで置き換えてみます。

名前を @Test とし、static class メソッドのみにつけられるアノテーションを定義します。 テストケースメソッドを識別するためには実行時までアノテーションを保持する必要があります。

以上を踏まえると @Test の定義は以下のようになります。

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
}

この @Test アノテーションは以下のようにして使います。

public class SampleTest {
    @Test public static void m1() {}
    public static void m2() {}
    @Test public static void m3() {}
}

このコードでは、@Test アノテーションのつけられた m1() と m2() のみがテストとして実行されます。 もし、@Test アノテーションの綴りを間違えていたらコンパイラが検知してくれます。 また、間違ってクラスに @Test アノテーションを付与していたらやはりコンパイルエラーとなります。

このアノテーションを使ったテストランナーはどのようになるでしょうか。

まず、リフレクションを使って、対象のテストクラスからメソッドを取り出します。 それぞれのメソッドアノテーションが付与されているかをチェックし、アノテーションが付与されていれば実行します。

実際のコードは以下の様になります。

public static void run(String className) throws Exception {
    int tests = 0;
    int passed = 0;
    Class testClass = Class.forName(className);
    for (Method method : testClass.getDeclaredMethods()) {
        // Test のアノテーションが付与されているかをチェック
        if (method.isAnnotationPresent(Test.class)) {
            tests++;
            try {
                // メソッドの実行。
                // 引数無し・static メソッドを想定
                method.invoke(null);
                passed++;
            } catch (InvocationTargetException wrapped) {
                Throwable e = wrapped.getCause();
                System.out.println(method + " failed: " + e);
            } catch (Exception e) {
                System.out.println("INVALID @Test: " + method);
            }
        }
    }
    System.out.printf("Passed: %d, Failed: %d%n", passed, tests - passed);
}

今回のテストでは引数無しの static メソッドを想定しています。 それ以外では例外がスローされ、実行は失敗してしまいます。 このエラーをコンパイラが検知できればよいのですが、現在はそうなっていません。

アノテーション

次に特定の例外がスローされることを期待するテストのためのアノテーションを作成してみます。

アノテーションは付与時に値を与えることもできます。 値を付与するアノテーションは以下のようにして宣言します。

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementTYpe.METHOD)
public @interface ExceptionTest{
    // 値は Exception を継承している境界型トークン
    Class<? extends Exception> value();
}

これを以下のようにして、例外を期待するテストとして利用します。

public class Sample2 {
    @ExceptionTest(ArithmeticException.class)
    public static void m1() {
        int i = 0;
        i  = i / i;
    }
}

このテストを実行するため、テストランナーに以下のコードを追加します。

if (method.isAnnotationPresent(ExceptionTest.class)) {
    tests++;
    try {
        method.invoke(null);
        System.out.println("Test %s failed: no exception%n" + method);
        passed++;
    } catch (InvocationTargetException wrapped) {
        Throwable e = wrapped.getCause();
        Class<? extends Exception> eType = method.getAnnotation(ExceptionTest.class).value();
        if (eType.isInstance(e)) {
            passed++;
        } else {
           System.out.printf("Test %s failed: expected %s, got %s%n", method, eType.getName(), e);
        }
    } catch (Exception e) {
        System.out.println("INVALID @Test: " + method);
    }
}

基本的には @Test の時のテストランナーと同様です。 ただし、送出された例外がアノテーションで付与された例外クラスのインスタンスかどうかを確認しています。

アノテーションには複数の値を与えることもできます。

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementTYpe.METHOD)
public @interface NewExceptionTest{
    // 値として配列を受け付ける
    Class<? extends Exception>[] value();
}

これを利用するには以下のようにします。 ただし、今までの単一の値の書き方でも、配列のプロパティに代入することができます。

public class Sample2 {
    // 複数の要素を配列として指定するアノテーション
    @NewExceptionTest({ArithmeticException.class,
                       NullpointerException.class})
    public static void m1() {
        int i = 0;
        i  = i / i;
    }

    // 正当なアノテーション指定。要素1の配列として処理される。
    @NewExceptionTest(ArithmeticException.class)
    public static void m2() {
        int i = 0;
        i  = i / i;
    }
}

このように、ソースファイルに情報を追加するためにアノテーションは最適です。 命名パターンは利用するべきではありません。

今回、アノテーションの学習のために自分でアノテーションを作成しました。 ただし、実際はほとんどの開発者は自分でアノテーションを定義する必要はありません。

Java プラットフォームが提供している事前に定義されたアノテーションを最大限利用するべきです。

(c) The King's Museum