第35条:注解优先于命名模式
Java 1.5 发行版本之前,一般使用命名模式(naming pattern)表明有些程序元素需要通过某种工具或者框架进行特殊处理。例如,JUnit 测试框架原本要求它的用户一定要用test
作为测试方法名称的开头[Beck04]。这种方法可行,但是有几个很严重的缺点。首先,文字拼写错误会导致失败,且没有任何提示。例如,假设不小心将一个测试方法命名为tsetSafetyOverride
而不是testSafetyOverride
。Junit 不会出错,但也不会执行测试,造成错误的安全感(即测试方法没有执行,它没有报错的可能,从而给人以测试正确的假象)。
命名模式的第二个缺点是,无法确保它们只用于相应的程序元素上。例如,假设将某个类称作testSafetyMechanisms
,是希望 Junit 会自动地测试它所有的方法,而不管它们叫什么名称。Junit 还是不会出错,但也同样不会执行测试。
命名模式的第三个缺点是,它们没有提供将参数值与程序元素关联起来的好方法。例如,假设想要支持一种测试类别,它只在抛出异常时才会成功。异常类型本质上是测试的一个参数。你可以利用某驻具体的命名模式,将异常类型名称编码到测试方法名称中,但是这样的代码会很不雅观,也很脆弱(见第50条)。编译器不知道要去检验准备命名异常的字符串是否真正命名成功。如果命名的类不存在,或者不是一个异常,你也要到试着运行测试时才会发现。
注解[JLS,9.7]很好地解决了所有这些问题。假设想要定义一个注解类型来指定简单的测试,它们自动运行,并在抛出异常时失败。一下就是这样的一个注解类型,命名为Test
:
// Marker annotation type declaration
import java.lang.annotation.*;
/**
* Indicates that the annotated method is a test method.
* Use only on parameterless static methods.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
}
Test
注解类型的声明就是它自身通过Retention
和Target
注解进行了注解。注解类型声明中的这种注解被称作元注解(meata-annotation)。@Retention(RetentionPolicy.RUNTIME)
元注解表明,Test
注解应该在运行时保留。如果没有保留,测试工具就无法知道Test
注解。@Target(ElementType.METHOD)
元注解表明,Test
注解只在方法声明中才是合法的:它不能运用到类声明、域声明或者其他程序元素上。
注意Test
注解声明上方的注释:“Use only on parameterless static methods(只用于无参的静态方法)”。如果编译器能够强制这一限制最好,但是它做不到。编译器可以替你完成多少错误检查,这是有限制的,即使是利用注解。如果将Test
注解放在实例方法的声明中,或者放在带有一个或者多个参数的方法中,测试程序还是可以编译,让测试工具在运行时来处理这个问题。
下面就是现实应用中的Test
注解,称作标记注解(marker annotation),因为它没有参数,只是“标注”被注解的元素。如果程序员拼错了Test
,或者将Test
注解应用到程序元素而非方法声明,程序就无法编译:
// Program containing marker annotations
public class Sample {
@Test public static void m1() {} // Test should pass
public static void m2() {}
@Test public static void m3() {
throw new RuntimeException("Boom");
}
public static void m4() {}
@Test public void m5() {} // INVALID USE: nonstatic method
public static void m6() {}
@Test public static void m7() { // Test should fail
throw newe RuntimeException("Crash");
}
public static void m8() {}
}
Sample
类有8个静态方法,其中4个被注解为测试。这4个中有2个抛出了异常:m3
和m7
,另外两个则没有:m1
和m5
。但是其中一个没有抛出异常的注解方法:m5
,是一个实例方法,因此不属于注解的有效使用。总之,Sample
包含4项测试:一项会通过,两项会失败,另一项无效。没有用Test
注解进行标注的4个方法会被测试工具忽略。
Test
注解对Sample
类的余熠没有直接的影响。它们只负责提供信息供相关的程序使用。更一般的讲,注解永远也不会改变被注解代码的语义,但是使它可以通过工具进行特殊的处理,例如像这种简单的测试运行类:
// Program to process marker annotations
import java.lang.reflect.*;
public class RunTests {
public static void main(String[] args) throws Exception {
int tests = 0;
int passed = 0;
Class testClass = Class.forName(args[0]);
for (Method m : testClass.getDeclaredMethods()) {
if (m.isAnnotationPresent(Test.class)) {
tests++;
try {
m.invoke(null);
passed++;
} catch (InvocationTargetException wrappedExc) {
Throwable exc = wrappedExc.getCause();
System.out.println(m + " failed: " + exc);
} catch (Exception exc) {
System.out.println("INVALID @Test: " + m);
}
}
}
System.out.printf("Passed: %d, Failed: %d%n",
passed, tests - passed);
}
}
测试运行工具在命令行上使用完全匹配的类名,并通过调用Method.invoke
反射式地运行类中所有标注了Test
的方法。isAnnotationPresent
方法告知该工具要运行哪些方法。如果测试方法抛出异常,反射机制就会将它封装在InvocationTargetException
中。该工具捕捉到了这个异常,并打印失败报告,包含测试方法抛出的原始异常,这些信息是通过getCause
方法从InvocationTargetException
中提取出来的。
如果尝试通过反射调用测试方法时抛出InvocationTargetException
之外的任何异常,表明编译时没有捕捉到Test
注解的无效用法。这种用法包括实例方法的注解,或者带有一个或者多个参数的方法的注解,或者不可访问的方法的注解。测试运行类中的第二个catch
块捕捉到了这些Test
用法错误,并打印出相应的错误消息。下面就是RunTests
在Sample
上运行时打印的输出:
public static void Sample.m3() failed: RuntimeException: Boom
INVALID @Test: public void Sample.m5()
public static void Sample.m7() failed: RuntimeException: Crash
Passed: 1, Failed 3
现在我们要针对只在抛出特殊异常时才成功的测试添加支持。为此我们需要一个新的注解类型:
// Annotation type with a parameter
import java.lang.annotation.*;
/**
* Indicates that the annotated method is a test method that
* must throw the designated exception to succeed.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
Class<? extends Exception> value();
}
这个注解的参数类型是Class<? extends Exception>
。这个通配符类型无疑很绕口。它在英语中的意思是:某个扩展Exception
的类的Class
对象,它允许注解的用户指定任何异常类型。这种用法是有限制的类型令牌(见第29条)的一个示例。下面就是实际应用中的这个注解,注意类名称被用作了注解的参数值:
// Program containing annotations with a parameter
public class Sample2 {
@ExceptionTest(ArithmeticException.class)
public static void m1() { // Test should pass
int i = 0;
i = i / i;
}
@ExceptionTest(ArithmeticException.class)
public static void m2() { // Should fail (wrong exception)
int[] a = new int[0];
int i = a[i];
}
@ExceptionTest(ArithmeticException.class)
public static void m3() { } // Should fail (no exception)
}
现在我们要修改一下测试运行工具来处理新的注解。这其中包括将以下代码添加到main
方法中:
if (m.isAnnotationPresent(ExceptionTest.class)) {
tests++;
try {
m.invoke(null);
System.out.printf("Test %s failed: no exception%n", m)
} catch (InvocationTargetException wrappedEx) {
Throwable exc = wrappedEx.getCause();
Class<? extends Exception> excType =
m.getAnnotation(ExceptionTest.class).value();
if (excType.isInstance(exc)) {
passed++;
} else {
System.out.printf(
"Test %s failed: expected %s, got %s%n",
m, excType.getName(), exc);
}
} catch (Exception exc) {
System.out.println("INVALID @Test: " + m);
}
}
这段代码类似于用来处理Test
注解的代码,但有一处不同:这段代码提取了注解参数的值,并用它检验该测试抛出的异常是否为正确的类型。没有显示的转换,因此没有出现ClassCastException
的危险。编译过的测试程序确保它的注解参数表示的是有效的异常类型,需要提醒一点:有可能注解参数在编译时是有效的,但是表示特定异常类型的类文件在运行时却不再存在。这种希望很少出现的情况下,测试运行类会抛出TypeNotFoundException
异常。
将上面的异常测试示例再深入一点,想像测试可以抛出任何一种指定异常时都得到通过。注解机制有一种工具,使得支持这种用法变得十分容易。假设我们将ExceptionTest
注解的类型参数改成Class
对象的一个数组:
// Annotation type with a parameter
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
Class<? extends Exception>[] value();
}
注解中数组参数的语法十分灵活。它是进行过优化的单元素数组。使用ExceptionTest
新版的数组参数之后,之前的所有ExceptionTest
注解仍然有效,并产生单元素的数组。为了指定多元素的数组,要用花括号({}
)将元素包围起来,并用逗号(,
)将它们隔开:
// Code containning an annotation with an array parameter
@ExceptionTest({ IndexOutOfBoundsException.class,
NullPointerException.class })
public static void doublyBad() {
List<String> list = new ArrayList<String>();
// The spec permits this method to throw either
// IndexOutOfBoundsException or NullPointerException
list.addAll(5, null);
}
修改测试运行工具来处理新的ExceptionTest
相当简单。下面的代码代替了原来的代码。
if (m.isAnnotationPresent(ExceptionTest.class)) {
tests++;
try {
m.invoke(null);
System.out.printf("Test %s failed: no exception%n", m)
} catch (InvocationTargetException wrappedEx) {
Throwable exc = wrappedEx.getCause();
Class<? extends Exception>[] excTypes =
m.getAnnotation(ExceptionTest.class).value();
int oldPassed = passed;
for (Class<? extends Exception> excType : excTypes) {
if (excType.isInstance(exc)) {
passed++;
break;
}
}
if (passed == oldPassed) {
System.out.printf("Test %s failed: %s %n", m, exc);
}
}
}
本条目中开发的测试框架只是一个试验,但它清楚地示范了注解之于命名模式的优越性。它这还只是揭开了注解功能的冰山一角。如果实在编写一个需要程序员给源文件添加信息的工具,就要定义一组适当的注解类型。既然有了注解,就完全没有理由再使用命名模式了。
也就是说,除了“工具铁匠(toolsmiths——特定的程序猿)”之外,大多数程序员都不必定义注解类性。但是所有的程序员都应该使用 Java 平台所提供的预定义的注解类型(见第36和24条)。还要考虑使用 IDE 或者静态分析工具所提供的任何注解。这种注解可以提升由这些工具所提供的诊断信息的质量。但是要注意这些注解还没有标准化,因此如果变换工具或者形成标准,就有很多工作要做了。