16. 注解

空~2022年8月13日
  • java
大约 15 分钟

16. 注解

注解(也被称为元数据)为我们在代码中添加信息提供了一种形式化的方式,使我们可以在稍后的某个时刻更容易的使用这些数据。

注解是 Java 5 所引入的众多语言变化之一。它们提供了 Java 无法表达的但是你需要完整表述程序所需的信息。

因此,注解使得我们可以以编译器验证的格式存储程序的额外信息。注解可以生成描述符文件,甚至是新的类定义,并且有助于减轻编写“样板”代码的负担。

通过使用注解,你可以将元数据保存在 Java 源代码中。并拥有如下优势:简单易读的代码,编译器类型检查,使用 annotation API 为自己的注解构造处理工具。

注解的语法十分简单,主要是在现有语法中添加 @ 符号。Java 5 引入了前三种定义在 java.lang 包中的注解:

  • @Override:表示当前的方法定义将覆盖基类的方法。如果你不小心拼写错误,或者方法签名被错误拼写的时候,编译器就会发出错误提示。
  • @Deprecated:如果使用该注解的元素被调用,编译器就会发出警告信息。
  • @SuppressWarnings:关闭不当的编译器警告信息。
  • @SafeVarargs:在 Java 7 中加入用于禁止对具有泛型 varargs 参数的方法或构造函数的调用方发出警告。
  • @FunctionalInterface:Java 8 中加入用于表示类型声明为函数式接口。

还有 5 种额外的注解类型用于创造新的注解。

基本注解

注解必须使用工具来处理,工具负责提取注解里包含的元数据,工具还会根据这些元数据增加额外的功能。在系统学习新的注解语法之前,先看一下 Java 提供的 4 个基本注解的用法——使用注解时要在其前面增加@符号,并把该注解当成一个修饰符使用,用于修饰它支持的程序元素。

4 个基本的注解如下:

  • @Override
  • @Deprecated
  • @SuppressWarnings
  • @SafeVarargs

@Override

@Override 就是用来指定方法覆载的,它可以强制一个子类必须覆盖父类的方法。

public class Fruit {
    public void info() {
        System.out.println("水果的info方法...");
    }
}

class Apple extends Fruit {
    // 使用@Override指定下面方法必须重写父类方法
    @Override
    public void info() {
        System.out.println("苹果重写水果的info方法...");
    }
}

@Override 的作用是告诉编译器检查这个方法,保证父类要包含一个被该方法重写的方法,否则就会编译出错。

把 Apple 类中的 info 方法误写成 infO,编译程序时将出现如下错误提示:

image-20220813104750336

提示

@Override 只能作用于方法,不能作用于其他程序元素。

@Deprecated

@Deprecated 用于表示某个程序元素(类、方法等)已过时,当其他程序使用已过时的类、方法时,编译器将会给出警告。

class Apple2 {
    // 定义info方法已过时
    @Deprecated
    public void info() {
        System.out.println("Apple的info方法");
    }
}

public class DeprecatedTest {
    public static void main(String[] args) {
        // 下面使用info方法时将会被编译器警告
        new Apple2().info();
    }
}

image-20220813105034000

提示

@Deprecated 的作用与文档注释中的@deprecated 标记的作用基本相同,但它们的用法不同,前者是 JDK 5 才支持的注解,无须放在文档注释语法(/*/部分)中,而是直接用于修饰程序中的程序单元,如方法、类、接口等。

@SuppressWarnings

@SuppressWarnings 指示被该注解修饰的程序元素(以及该程序元素中的所有子元素)取消显示指定的编译器警告。

在通常情况下,如果程序中使用没有泛型限制的集合将会引起编译器警告,为了避免这种编译器警告,可以使用@SuppressWarnings 修饰。

// 关闭整个类里的编译器警告
@SuppressWarnings(value = "all")
public class SuppressWarningsTest {
    public static void main(String[] args) {
        List<String> myList = new ArrayList();     //①
    }
}

@SafeVarargs

如下代码可能导致运行时异常。

List list = new ArrayList<Integer>();
list.add(20);     // 添加元素时引发unchecked异常
// 下面代码引起“未经检查的转换”的警告,编译、运行时完全正常
List<String> ls = list;    //①
// 但只要访问ls里的元素,如下面代码就会引起运行时异常
System.out.println(ls.get(0));

Java 把引发这种错误的原因称为“堆污染”(Heap pollution),当把一个不带泛型的对象赋给一个带泛型的变量时,往往就会发生这种“堆污染”。

对于形参个数可变的方法,该形参的类型又是泛型,这将更容易导致“堆污染”。

public class ErrorUtils {
    public static void faultyMethod(List<String>... listStrArray) {
        // Java语言不允许创建泛型数组,因此listArray只能被当成List[]处理
        // 此时相当于把List<String>赋给了List,已经发生了“堆污染”
        List[] listArray = listStrArray;
        List<Integer> myList = new ArrayList<Integer>();
        myList.add(new Random().nextInt(100));
        // 把listArray的第一个元素赋为myArray
        listArray[0] = myList;
        String s = listStrArray[0].get(0);
    }
}

该方法有个形参是List<String>...类型,个数可变的形参相当于数组,但 Java 又不支持泛型数组,因此程序只能把List<String>...当成 List[]处理,这里就发生了“堆污染”。

Java 7 会在定义该方法时就发出“堆污染”警告,这样保证开发者“更早”地注意到程序中可能存在的“漏洞”。

但在有些时候,开发者不希望看到这个警告,则可以使用如下 3 种方式来“抑制”这个警告。

  1. 使用@SafeVarargs 修饰引发该警告的方法或构造器。
  2. 使用@SuppressWarnings("unchecked")修饰。
  3. 编译时使用-Xlint:varargs 选项。

第 3 种方式一般比较少用,通常可以选择第 1 种或第 2 种方式,@SafeVarargs 修饰引发该警告的方法或构造器,它是 Java 7 专门为抑制“堆污染”警告提供的。

元注解

JDK 除了在 java.lang 下提供了 4 个基本的注解之外,还在 java.lang.annotation 包下提供了 4 个 Meta Annotation(元注解),这 4 个元注解都用于修饰其他的注解定义。

@Retention

@Retention 只能用于修饰一个注解定义,用于指定被修饰的注解可以保留多长时间,@Retention 包含一个 RetentionPolicy 类型的 value 成员变量,所以使用@Retention 时必须为该 value 成员变量指定值。

value 成员变量的值只能是如下 3 个。

  1. RetentionPolicy.CLASS:编译器将把注解记录在 class 文件中。当运行 Java 程序时,JVM 不再保留注解。这是默认值。
  2. RetentionPolicy.RUNTIME:编译器将把注解记录在 class 文件中。当运行 Java 程序时, JVM 也会保留注解,程序可以通过反射获取该注解信息。
  3. RetentionPolicy.SOURCE:注解只保留在源代码中,编译器直接丢弃这种注解。

如果需要通过反射获取注解信息,就需要使用 value 属性值为 RetentionPolicy.RUNTIME@Retention

使用@Retention 元数据注解可采用如下代码为 value 指定值。

//  该注解保留到运行时
@Retention(value = RetentionPolicy.RUNTIME)
public @interface Testable {
}

也可采用如下代码来为 value 指定值。

//  该注解将被编译器丢弃
@Retention(RetentionPolicy.SOURCE)
public @interface Testable {
}

当注解的成员变量名为 value 时,程序中可以直接在注解后的括号里指定该成员变量的值,无须使用 name=value 的形式。

image-20220813113422945

@Target

@Target 也只能修饰一个注解定义,它用于指定被修饰的注解能用于修饰哪些程序单元。@Target 元注解也包含一个名为 value 的成员变量,该成员变量的值只能是如下几个。

  1. ElementType.ANNOTATION_TYPE:指定该策略的注解只能修饰注解。
  2. ElementType.CONSTRUCTOR:指定该策略的注解只能修饰构造器。
  3. ElementType.FIELD:指定该策略的注解只能修饰成员变量。
  4. ElementType.LOCAL_VARIABLE:指定该策略的注解只能修饰局部变量。
  5. ElementType.METHOD:指定该策略的注解只能修饰方法定义。
  6. ElementType.PACKAGE:指定该策略的注解只能修饰包定义。
  7. ElementType.PARAMETER:指定该策略的注解可以修饰参数。
  8. ElementType.TYPE:指定该策略的注解可以修饰类、接口(包括注释类型)或枚举定义。

使用@Target 也可以直接在括号里指定 value 值,而无须使用 name=value 的形式。

// 只能修饰成员变量。
@Target(ElementType.FIELD)
public @interface ActionListenerFor{}

@Documented

@Documented 用于指定被该元注解修饰的注解类将被 javadoc 工具提取成文档,如果定义注解类时使用了@Documented 修饰,则所有使用该注解修饰的程序元素的 API 文档中将会包含该注解说明。

@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Testable {
}

代码定义一个 MyTest 类,该类中的 info()方法使用 @Testable 修饰。

public class MyTest {
    // 使用@Testable修饰info方法
    @Testable
    public void info() {
        System.out.println("info方法...");
    }
}

使用 javadoc 工具为 Testable.java、MyTest.java 文件生成 API 文档后的效果如图。

img

把上面 Testable.java 程序中的粗体字代码删除或注释掉,再次使用 javadoc 工具生成的 API 文档如图。

img

@Inherited

@Inherited 元注解指定被它修饰的注解将具有继承性——如果某个类使用了@A 注解(定义该注解时使用了@Inherited 修饰)修饰,则其子类将自动被@A 修饰。

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface Inheritable {
}

程序中定义了一个 Base 基类,该基类使用了@Inheritable 修饰,则 Base 类的子类将会默认使用@Inheritable 修饰。

// 使用@Inheritable修饰的Base类
@Inheritable
class Base {
}

// InheritableTest类只是继承了Base类
// 并未直接使用@Inheritable 注解
public class InheritableTest extends Base {
    public static void main(String[] args) {
        // 打印InheritableTest类是否有@Inheritable修饰
        System.out.println(InheritableTest.class.isAnnotationPresent(Inheritable.class));
    }
}

运行上面程序,会看到输出:true。

自定义注解

定义注解

定义新的注解类型使用@interface 关键字(在原有的 interface 关键字前增加@符号)定义一个新的注解类型与定义一个接口非常像,如下代码可定义一个简单的注解类型。

// 定义一个简单的注解类型
public @interface Test {}

定义了该注解之后,就可以在程序的任何地方使用该注解,使用注解时的语法非常类似于 public、final 这样的修饰符,通常可用于修饰程序中的类、方法、变量、接口等定义。通常我们会把注解放在所有修饰符之前,而且由于使用注解时可能还需要为成员变量指定值,因而注解的长度可能较长,所以通常把注解另放一行。

// 使用@Test修饰类定义
@Test
public class MyClass {
    ...
}

注解的成员变量在注解定义中以无形参的方法形式来声明,其方法名和返回值定义了该成员变量的名字和类型。

public @interface MyTag {
    // 定义了两个成员变量的Annotation
    // Annotation中的成员变量以方法的形式来定义
    String name();
    int age();
}

在注解里定义了成员变量之后,使用该注解时就应该为该注解的成员变量指定值。

public class Test {
    // 使用带成员变量的Annotation时,需要为成员变量赋值
    @MyTag(name="xx", age=6)
    public void info(){
          ...
    }
    ...
}

可以在定义注解的成员变量时为其指定初始值(默认值),指定成员变量的初始值可使用 default 关键字。

public @interface MyTag {
    // 定义了两个成员变量的Annotation
    // 使用default为两个成员变量指定初始值
    String name() default "yeeku";

    int age() default 32;
}

为注解的成员变量指定了默认值, 使用该注解时则可以不为这些成员变量指定值,而是直接使用默认值。

public class Test {
    // 使用带成员变量的Annotation
    // 因为它的成员变量有默认值,所以可以不为它的成员变量指定值
    @MyTag
    public void info() {
          ...
    }
    ...
}

当然也可以在使用 MyTag 注解时为成员变量指定值,如果为 MyTag 的成员变量指定了值,则默认值不会起作用。

根据注解是否可以包含成员变量,可以把注解分为如下两类。

  1. 标记注解:一个没有定义成员变量的注解类型被称为标记。这种注解仅利用自身的存在与否来为我们提供信息,如前面介绍的@Override@TestAnnotation
  2. 元数据注解:包含成员变量的注解,因为它们可以接受更多的元数据,所以也被称为元数据注解。

提取注解信息

当开发者使用注解修饰了类、方法、Field 等成员之后,这些注解不会自己生效,必须由开发者提供相应的工具来提取并处理注解信息。

Java 使用注解接口来代表程序元素前面的注解,该接口是所有注解类型的父接口。Java 5 在 java.lang.reflect 包下新增了 AnnotatedElement 接口,该接口代表程序中可以接受注解的程序元素。该接口主要有如下几个实现类。

  1. Class:类定义。
  2. Constructor:构造器定义。
  3. Field:类的成员变量定义。
  4. Method:类的方法定义。
  5. Package:类的包定义。

java.lang.reflect 包下主要包含一些实现反射功能的工具类,从 Java 5 开始,java.lang.reflect 包所提供的反射 API 扩充了读取运行时注解的能力。当一个注解类型被定义为运行时注解后,该注解才会在运行时可见,JVM 才会在装载*.class 文件时读取保存在 class 文件中的注解。

AnnotatedElement 接口是所有程序元素(如 Class、Method、Constructor 等)的父接口,所以程序通过反射获取了某个类的 AnnotatedElement 对象(如 Class、Method、Constructor 等)之后,程序就可以调用该对象的如下 3 个方法来访问注解信息。

  1. getAnnotation(Class<T> annotationClass):返回该程序元素上存在的指定类型的注解,如果该类型的注解不存在,则返回 null。
  2. Annotation[] getAnnotations():返回该程序元素上存在的所有注解。
  3. boolean isAnnotationPresent(Class<? extends Annotation>annotationClass):判断该程序元素上是否存在指定类型的注解,如果存在则返回 true,否则返回 false。
// 获取Test类的info方法的所有注解
Annotation[] aArray = Class.forName("Test").getMethod("info").getAnnotations();
// 遍历所有注解
for (Annotation an : aArray ) {
    System.out.println(an);
}

如果需要获取某个注解里的元数据,则可以将注解强制类型转换成所需的注解类型,然后通过注解对象的抽象方法来访问这些元数据。

// 获取tt对象的info方法所包含的所有注解
Annotation[] annotation = tt.getClass().getMethod("info").getAnnotations();
// 遍历每个注解对象
for (Annotation tag : annotation) {
    // 如果tag注解是MyTag1类型
    if (tag instanceof MyTag1) {
        System.out.println("Tag is:" + tag);
        // 将tag强制类型转换为MyTag1
        // 并输出tag对象的method1和method2两个成员变量的值
        System.out.println("tag.name():" + ((MyTag1)tag).method1());
        System.out.println("tag.age():" + ((MyTag1)(tag)).method2());
    }
    // 如果tag注解是MyTag2类型
    if (tag instanceof MyTag2) {
        System.out.println("Tag is:" + tag);
        // 将tag强制类型转换为MyTag2
        // 并输出tag对象的method1和method2两个成员变量的值
        System.out.println("tag.name():" + ((MyTag2)tag).method1());
        System.out.println("tag.age():" + ((MyTag2)(tag)).method2());
    }

使用注解

下面分别介绍两个使用注解的例子,第一个注解 Testable 没有任何成员变量,仅是一个标记注解,它的作用是标记哪些方法是可测试的。

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

如下 MyTest 测试用例中定义了 8 个方法,这 8 个方法没有太大的区别,其中 4 个方法使用@Testable 注解来标记这些方法是可测试的。

package annotations;

public class MyTest {
    // 使用@Testable标记注解指定该方法是可测试的
    @Testable
    public static void m1() {
    }

    public static void m2() {
    }

    // 使用@Testable标记注解指定该方法是可测试的
    @Testable
    public static void m3() {
        throw new RuntimeException("Boom");
    }

    public static void m4() {
    }

    // 使用@Testable标记注解指定该方法是可测试的
    @Testable
    public static void m5() {
    }

    public static void m6() {
    }

    // 使用@Testable标记注解指定该方法是可测试的
    @Testable
    public static void m7() {
        throw new RuntimeException("Crash");
    }

    public static void m8() {
    }
}

为了让程序中的这些注释起作用,接下来必须为这些注释提供一个注释处理工具。

public class ProcessorTest {
    public static void process(String clazz) throws ClassNotFoundException {
        int passed = 0;
        int failed = 0;
        // 遍历clazz对应的类里的所有方法
        for (Method m : Class.forName(clazz).getMethods()) {
            // 如果该方法使用了@Testable修饰
            if (m.isAnnotationPresent(Testable.class)) {
                try {
                    // 调用m方法
                    m.invoke(null);
                    // passed加1
                    passed++;
                } catch (Exception ex) {
                    System.out.println("方法" + m + "运行失败,异常:" + ex.getCause());
                    failed++;
                }
            }
        }
        // 统计测试结果
        System.out.println(
            "共运行了:" + (passed + failed) + "个方法,其中:\n" + "失败了:" + failed + "个,\n" + "成功了:" + passed + "个!");
    }
}

该程序的主类非常简单,提供主方法,使用 ProcessorTest 来分析目标类即可。

package annotations;

public class RunTests {
    public static void main(String[] args) throws Exception {
        // 处理MyTest类
        ProcessorTest.process("annotations.MyTest");
    }
}

image-20220813141844754

定义一个简单的注解,我们可以用它来追踪项目中的用例。

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface UseCase {
    int id();
    String description() default "no description";
}

在下面的类中,有三个方法被注解为用例:

public class PasswordUtils {
    @UseCase(id = 47, description =
            "Passwords must contain at least one numeric")
    public boolean validatePassword(String passwd) {
        return (passwd.matches("\\w*\\d\\w*"));
    }
    @UseCase(id = 48)
    public String encryptPassword(String passwd) {
        return new StringBuilder(passwd)
                .reverse().toString();
    }
    @UseCase(id = 49, description =
            "New passwords can't equal previously used ones")
    public boolean checkForNewPassword(
            List<String> prevPasswords, String passwd) {
        return !prevPasswords.contains(passwd);
    }
}

注解处理器,我们用它来读取被注解的 PasswordUtils 类,并且使用反射机制来寻找 @UseCase 标记。给定一组 id 值,然后列出在 PasswordUtils 中找到的用例,以及缺失的用例。

public class UseCaseTracker {
    public static void
    trackUseCases(List<Integer> useCases, Class<?> cl) {
        for(Method m : cl.getDeclaredMethods()) {
            UseCase uc = m.getAnnotation(UseCase.class);
            if(uc != null) {
                System.out.println("Found Use Case " +
                        uc.id() + "\n " + uc.description());
                useCases.remove(Integer.valueOf(uc.id()));
            }
        }
        useCases.forEach(i ->
                System.out.println("Missing use case " + i));
    }
    public static void main(String[] args) {
        List<Integer> useCases = IntStream.range(47, 51)
                .boxed().collect(Collectors.toList());
        trackUseCases(useCases, PasswordUtils.class);
    }
}

输出:

Found Use Case 48
no description
Found Use Case 47
Passwords must contain at least one numeric
Found Use Case 49
New passwords can't equal previously used ones
Missing use case 50