实现注释
从 JDK5 开始,Java 开始引入注释功能,从此,注释已成为许多 Java 应用程序和框架的重要组成部分。 在绝大多数情况下,注释将被用于描述语言结构,例如类,字段,方法等,但是在另一种情况下,可以将注释作为可实现的接口。
在常规的使用方法中,注释就是注释,接口就是接口。例如,下面的代码为接口 MyInterface 添加了一个注释。
@Deprecated interface MyInterface { }
而接口也只能起到接口的作用,如下面的代码,Person 实现了 IPerson 接口,并实现了 getName 方法。
interface IPerson { public String getName(); } class Person implements IPerson { @Override public String getName() { return "Foo"; } }
如果按注释方式使用,那么就是注释,如果按接口方式使用,那么就是接口。例如,下面的代码定义了一个 Test 注释。
@Retention(RetentionPolicy.RUNTIME) @interface Test { String name(); }
Test 注释通过 Retention 注释进行修饰。Retention 注释可以用来修饰其他注释,所以称为元注释,后面的 RetentionPolicy.RUNTIME 参数表示注释不仅被保存到 class 文件中,jvm 加载 class 文件之后,仍然存在。这样在程序运行后,仍然可以动态获取注释的信息。
Test 本身是一个注释,有一个名为 name 的方法,name 是一个抽象方法,需要在使用注释时指定具体的值,其实 name 相当于 Test 的属性。下面的 Sporter 类使用 Test 注释修改了 run 方法。
class Sporter { @Test(name = "Bill") public void run (){ } }
可以通过反射获取修饰 run 方法的注释信息,例如,name 属性的值,代码如下:
Sporter sporter = new Sporter(); var annotation = sporter.getClass().getMethod("run").getAnnotations()[0]; var method = annotation.annotationType().getMethod("name"); System.out.println(method.invoke(annotation)); // 输出 Bill
由于 Test 中有 name 方法,所以干脆就利用一下这个 name 方法,直接用类实现它,省得再定义一个类似的接口。代码如下:
class Teacher implements Test { @Override public String name() { return "Mike"; } @Override public Class extends Annotation> annotationType() { return Test.class; } }
要注意的是,如果要实现一个注释,那么必须实现 annotationType 方法,该方法返回了注释的类型,这里返回了 Test 的 Class 对象。尽管大多数情况下,都不需要实现一个注释,不过在一些情况,如注释驱动的框架内,可能会很有用。
五花八门的初始化方式:初始化块
在 Java 中,与大多数面向对象编程语言一样,可以使用构造方法实例化对象,当然,也有一些例外,例如,Java 对象的反序列化就不需要通过构造方法实例化对象(我们先不去考虑这些例外)。还有一些实例化对象的方式从表面上看没有使用构造方法,但本质上仍然使用了构造方法。
例如,通过静态工厂模式来实例化对象,其实是将类本身的构造方法声明为 private,这样就不能直接通过类的构造方法实例化对象了,而必须通过类本身的方法来调用这个被声明为 private 的构造方法来实例化对象,于是就有了下面的代码:
class Person { private final String name; private Person(String name) { this.name = name; } public String getName() { return name; } // 静态工厂方法 public static Person withName(String name) { return new Person(name); } } public class InitDemo { public static void main(String[] args){ // 通过静态工厂方法实例化对象 Person person = Person.withName("Bill"); System.out.println(person.getName()); } }
因此,当我们希望初始化一个对象时,我们将初始化逻辑放到对象的构造方法中。 例如,我们在 Person 类的构造方法中通过参数 name 初始化了 name 成员变量。 尽管似乎可以合理地假设所有初始化逻辑都在类的一个或多个构造方法中找到。但对于 Java,情况并非如此。在 Java 中,除了可以在构造方法中初始化对象外,还可以通过代码块来初始化对象。
class Car { // 普通的代码块 { System.out.println("这是在代码块中输出的"); } public Car() { System.out.println("这是在构造方法中输出的"); } } public class InitDemo { public static void main(String[] args){ Car car = new Car(); } }
通过在类的内部定义一堆花括号来完成初始化逻辑,这就是代码块的作用,也可以将代码块称为初始化器。实例化对象时,首先会调用类的初始化器,然后调用类的构造方法。 要注意的是,可以在类中指定多个初始化器,在这种情况下,每个初始化器将按着定义的顺序调用。
class Car { // 普通的代码块 { System.out.println("这是在第 1 个代码块中输出的"); } // 普通的代码块 { System.out.println("这是在第 2 个代码块中输出的"); } public Car() { System.out.println("这是在构造方法中输出的"); } } public class InitDemo { public static void main(String[] args){ Car car = new Car(); } }
除了普通的代码块(初始化器)外,我们还可以创建静态代码块(也称为静态初始化器),这些静态初始化器在将类加载到内存时执行。 要创建静态初始化器,我们只需在普通初始化器前面加 static 关键字即可。
class Car { { System.out.println("这是在普通代码块中输出的"); } static { System.out.println("这是在静态代码块中输出的"); } public Car() { System.out.println("这是在构造方法中输出的"); } } public class InitDemo { public static void main(String[] args){ Car car = new Car(); new Car(); } }
静态初始化器只执行一次,而且是最先执行的代码块。例如,上面的代码中,创建了两个 Car 对象,但静态块只会执行一次,而且是最先执行的,普通代码块和 Car 类的构造方法,在每次创建 Car 实例时都会依次执行。
如果只是代码块或构造方法,并不复杂,但如果构造方法、普通代码块和静态代码块同时出现在类中时就稍微复杂点,在这种情况下,会先执行静态代码块,然后执行普通代码块,最后才执行构造方法。当引入父类时,情况会变得更复杂。父类和子类的静态代码块、普通代码块和构造方法的执行规则如下:
按声明顺序执行父类中所有的静态代码块
按声明顺序执行子类中所有的静态代码块
按声明顺序执行父类中所有的普通代码块
执行父类的构造方法
按声明顺序执行子类中所有的普通代码块
执行子类的构造方法
下面的代码演示了这一执行过程:
class Car { { System.out.println("这是在 Car 普通代码块中输出的"); } static { System.out.println("这是在 Car 静态代码块中输出的"); } public Car() { System.out.println("这是在 Car 构造方法中输出的"); } } class MyCar extends Car { { System.out.println("这是在 MyCar 普通代码块中输出的"); } static { System.out.println("这是在 MyCar 静态代码块中输出的"); } public MyCar() { System.out.println("这是在 MyCar 构造方法中输出的"); } } public class InitDemo { public static void main(String[] args){ new MyCar(); } }
初始化有妙招:双花括号初始化
许多编程语言都包含某种语法机制,可以使用非常少的代码快速创建列表(数组)和映射(字典)对象。 例如,C ++可以使用大括号初始化,这使开发人员可以快速创建枚举值列表,甚至在对象的构造方法支持此功能的情况下初始化整个对象。 不幸的是,在 JDK 9 之前,因此,在 JDK9 之前,我们仍然需要痛苦而无奈地使用下面的代码创建和初始化列表:
ListmyInts = new ArrayList<>(); myInts.add(1); myInts.add(2); myInts.add(3);
尽管上面的代码可以很好完成我们的目标:创建包含 3 个整数值的 ArrayList 对象。但代码过于冗长,这要求开发人员每次都要使用变量(myInts)的名字。为了简化这段 diamante,可以使用双括号来完成同样的工作。
ListmyInts = new ArrayList<>() {{ add(1); add(2); add(3); }};
双花括号初始化实际上是多个语法元素的组合。首先,我们创建一个扩展 ArrayList 类的匿名内部类。 由于 ArrayList 没有抽象方法,因此我们可以为匿名类实现创建一个空的实体。
ListmyInts = new ArrayList<>() {};
使用这行代码,实际上创建了原始 ArrayList 完全相同的 ArrayList 匿名子类。他们的主要区别之一是我们的内部类对包含的类有隐式引用,我们正在创建一个非静态内部类。 这使我们能够编写一些有趣的逻辑(如果不是很复杂的话),例如将捕获的此变量添加到匿名的,双花括号初始化的内部类代码如下:
package black.magic; import java.util.ArrayList; import java.util.List; class InitDemo { public ListgetListWithMeIncluded() { return new ArrayList () {{ add(InitDemo.this); }}; } } public class DoubleBraceInitialization { public static void main(String[] args) { List myInts2 = new ArrayList<>() {}; InitDemo demo = new InitDemo(); List initList = demo.getListWithMeIncluded(); System.out.println(demo.equals(initList.get(0))); } }
如果上面代码中的内部类是静态定义的,则我们将无法访问 InitDemo.this。 例如,以下代码静态创建了名为 MyArrayList 的内部类,但无法访问 InitDemo.this 引用,因此不可编译:
class InitDemo { public ListgetListWithMeIncluded() { return new FooArrayList(); } private static class FooArrayList extends ArrayList {{ add(InitDemo.this); // 这里会编译出错 }} }
重新创建双花括号初始化的 ArrayList 的构造之后,一旦我们创建了非静态内部类,就可以使用实例初始化(如上所述)来在实例化匿名内部类时执行三个初始元素的加法。 由于匿名内部类会立即实例化,并且匿名内部类中只有一个对象存在,因此我们实质上创建了一个非静态内部单例对象,该对象在创建时会添加三个初始元素。 如果我们分开两个大括号,这将变得更加明显,其中一个大括号清楚地构成了匿名内部类的定义,另一个大括号表示了实例初始化逻辑的开始:
ListmyInts = new ArrayList<>() { { add(1); add(2); add(3); } };
尽管该技巧很有用,但 JDK 9(JEP 269)已用一组 List(以及许多其他收集类型)的静态工厂方法代替了此技巧的实用程序。 例如,我们可以使用这些静态工厂方法创建上面的列表,代码如下:
ListmyInts = List.of(1, 2, 3);
之所以需要这种静态工厂技术,主要有两个原因:
(1)不需要创建匿名内部类;
(2)减少了创建列表所需的样板代码(噪音)。
不过以这种方式创建列表的代价是:列表是只读的。也就是说一旦创建后就不能修改。 为了创建可读写的列表,就只能使用前面介绍的双花括号初始化方式或者传统的初始化方式了。
请注意,传统初始化,双花括号初始化和 JDK 9 静态工厂方法不仅可用于 List。 它们也可用于 Set 和 Map 对象,如以下代码段所示:
MapmyMap1= new HashMap<>(); myMap1.put("key1", 10); myMap1.put("key2", 15); Map myMap2 = new HashMap<>() {{ put("Key1", 10); put("Key2", 15); }}; Map myMap3 = Map.of("key1", 10, "key2", 15);
在使用双花括号方式初始化之前,要考虑它的性质,虽然确实提高了代码的可读性,但它带有一些隐式的副作用。例如,会创建隐式对象。
注释并不是打酱油的:可执行注释
注释几乎是每个程序必不可少的组成部分,注释的主要好处是它们不被执行,而且容易让程序变得更可读。 当我们在程序中注释掉一行代码时,这一点变得更加明显。我们希望将代码保留在我们的应用程序中,但我们不希望它被执行。 例如,以下程序导致将 5 打印到标准输出:
public static void main(String args[]) { int value = 5; // value = 8; System.out.println(value); }
尽管不执行注释是一个基本的假设,但这并不是完全正确的。 例如,以下代码片段会将什么打印到标准输出呢?
public static void main(String args[]) { int value = 5; // \u000dvalue = 8; System.out.println(value); }
大家一定猜测是 5,但是如果运行上面的代码,我们看到在 Console 中输出了 8。 这个看似错误的背后原因是 Unicode 字符\ u000d。 此字符实际上是 Unicode 回车,并且 Java 源代码由编译器作为 Unicode 格式的文本文件使用。 添加此回车符会将“value= 8;”换到注释的下一行(在这一行没有注释,相当于在 value 前面按一下回车键),以确保执行该赋值。 这意味着以上代码段实际上等于以下代码段:
public static void main(String args[]) { int value = 5; // value = 8; System.out.println(value); }
尽管这似乎是 Java 中的错误,但实际上是该语言中的内置的功能。 Java 的最初目标是创建独立于平台的语言(因此创建 Java 虚拟机或 JVM),并且源代码的互操作性是此目标的关键。 允许 Java 源代码包含 Unicode 字符,这就意味着可以通过这种方式包含非拉丁字符。 这样可以确保在世界一个区域中编写的代码(其中可能包含非拉丁字符,例如在注释中)可以在其他任何地方执行。 有关更多信息,请参见 Java 语言规范或 JLS 的 3.3 节。 下面是一个更复杂的案例,大家看看输出什么东东。
package black.magic; public class ExecutableComments { public static void main(String args[]) { int value = 5; // \u000d\u0069\u006e\u0074\u0020\u0041value = 20; // A(65)、i(105)、n(110)、t(116) 空格:32 System.out.println(Avalue); } }
枚举与接口结合:枚举实现接口
与 Java 中的类相比,枚举的局限性之一是枚举不能从另一个类或枚举继承。 例如,无法执行以下操作:
public class Speaker { public void speak() { System.out.println("Hi"); } } public enum Person extends Speaker { JOE("Joseph"), JIM("James"); private final String name; private Person(String name) { this.name = name; } } Person.JOE.speak();
但是,我可以让枚举实现一个接口,并为其抽象方法提供一个实现,如下所示:
public interface Speaker { public void speak(); } public enum Person implements Speaker { JOE("Joseph"), JIM("James"); private final String name; private Person(String name) { this.name = name; } @Override public void speak() { System.out.println("Hi"); } } Person.JOE.speak();
现在,我们还可以在需要 Speaker 对象的任何地方使用 Person 的实例。 此外,我们还可以在每个常量的基础上提供接口抽象方法的实现(称为特定于常量的方法):
public interface Speaker { public void speak(); } public enum Person implements Speaker { JOE("Joseph") { public void speak() { System.out.println("Hi, my name is Joseph"); } }, JIM("James"){ public void speak() { System.out.println("Hey, what's up?"); } }; private final String name; private Person(String name) { this.name = name; } @Override public void speak() { System.out.println("Hi"); } } Person.JOE.speak();
与本文中的其他一些不同,应在适当的地方鼓励使用此技术。 例如,如果可以使用枚举常量(例如 JOE 或 JIM)代替接口类型(例如 Speaker),则定义该常量的枚举应实现接口类型。
总结
在本文中,我们研究了 Java 中的五个隐藏秘密:
(1)可扩展的注释;
(2)实例初始化可用于在实例化时配置对象;
(3)用于初始化的双花括号;
(4)可执行的注释;
(5)枚举可以实现接口; 尽管其中一些功能有其适当的用途,但应避免使用其中某些功能(即创建可执行注释)。 在决定使用这些机密时,请确保真的有必要这样做。