深度解析Java中的5个“黑魔法”

时间:2023-03-08 15:42:53
深度解析Java中的5个“黑魔法”

现在的编程语言越来越复杂,尽管有大量的文档和书籍,这些学习资料仍然只能描述编程语言的冰山一角。而这些编程语言中的很多功能,可能被永远隐藏在黑暗角落。本文将为你解释其中5个Java中隐藏的秘密,可以称其为Java的“黑魔法”。对于这些魔法,会描述它们的实现原理,并结合一些应用场景给出实现代码。

1. 一石二鸟:实现注释(Annotation)

从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对象。尽管大多数情况下,都不需要实现一个注释,不过在一些情况,如注释驱动的框架内,可能会很有用。

2. 五花八门的初始化方式:初始化块

在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实例时都会依次执行。

如果只是代码块或构造方法,并不复杂,但如果构造方法、普通代码块和静态代码块同时出现在类中时就稍微复杂点,在这种情况下,会先执行静态代码块,然后执行普通代码块,最后才执行构造方法。当引入父类时,情况会变得更复杂。父类和子类的静态代码块、普通代码块和构造方法的执行规则如下:
1. 按声明顺序执行父类中所有的静态代码块
2. 按声明顺序执行子类中所有的静态代码块
3. 按声明顺序执行父类中所有的普通代码块
4. 执行父类的构造方法
5. 按声明顺序执行子类中所有的普通代码块
6. 执行子类的构造方法

下面的代码演示了这一执行过程:

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();
}
}

执行这段代码,会得到下面的结果:

深度解析Java中的5个“黑魔法”

3. 初始化有妙招:双花括号初始化

许多编程语言都包含某种语法机制,可以使用非常少的代码快速创建列表(数组)和映射(字典)对象。 例如,C ++可以使用大括号初始化,这使开发人员可以快速创建枚举值列表,甚至在对象的构造方法支持此功能的情况下初始化整个对象。 不幸的是,在JDK 9之前,因此,在JDK9之前,我们仍然需要痛苦而无奈地使用下面的代码创建和初始化列表:

List<Integer> myInts = new ArrayList<>();
myInts.add(1);
myInts.add(2);
myInts.add(3);

尽管上面的代码可以很好完成我们的目标:创建包含3个整数值的ArrayList对象。但代码过于冗长,这要求开发人员每次都要使用变量(myInts)的名字。为了简化这段diamante,可以使用双括号来完成同样的工作。

List<Integer> myInts = new ArrayList<>() {{
add(1);
add(2);
add(3);
}};

双花括号初始化实际上是多个语法元素的组合。首先,我们创建一个扩展ArrayList类的匿名内部类。 由于ArrayList没有抽象方法,因此我们可以为匿名类实现创建一个空的实体。

List<Integer> myInts = new ArrayList<>() {};

使用这行代码,实际上创建了原始ArrayList完全相同的ArrayList匿名子类。他们的主要区别之一是我们的内部类对包含的类有隐式引用,我们正在创建一个非静态内部类。 这使我们能够编写一些有趣的逻辑(如果不是很复杂的话),例如将捕获的此变量添加到匿名的,双花括号初始化的内部类代码如下:

ackage black.magic;

import java.util.ArrayList;
import java.util.List;
class InitDemo {
public List<InitDemo> getListWithMeIncluded() {
return new ArrayList<InitDemo>() {{
add(InitDemo.this);
}};
}
}
public class DoubleBraceInitialization {
public static void main(String[] args) { List<Integer> myInts2 = new ArrayList<>() {}; InitDemo demo = new InitDemo();
List<InitDemo> initList = demo.getListWithMeIncluded();
System.out.println(demo.equals(initList.get(0)));
}
}

如果上面代码中的内部类是静态定义的,则我们将无法访问InitDemo.this。 例如,以下代码静态创建了名为MyArrayList的内部类,但无法访问InitDemo.this引用,因此不可编译:

class InitDemo {

    public List<InitDemo> getListWithMeIncluded() {
return new FooArrayList();
}
private static class FooArrayList extends ArrayList<InitDemo> {{
add(InitDemo.this); // 这里会编译出错
}}
}

重新创建双花括号初始化的ArrayList的构造之后,一旦我们创建了非静态内部类,就可以使用实例初始化(如上所述)来在实例化匿名内部类时执行三个初始元素的加法。 由于匿名内部类会立即实例化,并且匿名内部类中只有一个对象存在,因此我们实质上创建了一个非静态内部单例对象,该对象在创建时会添加三个初始元素。 如果我们分开两个大括号,这将变得更加明显,其中一个大括号清楚地构成了匿名内部类的定义,另一个大括号表示了实例初始化逻辑的开始:

List<Integer> myInts = new ArrayList<>() {
  {
    add(1);
    add(2);
    add(3);
  }
};

尽管该技巧很有用,但JDK 9(JEP 269)已用一组List(以及许多其他收集类型)的静态工厂方法代替了此技巧的实用程序。 例如,我们可以使用这些静态工厂方法创建上面的列表,代码如下:

List<Integer> myInts = List.of(1, 2, 3);

之所以需要这种静态工厂技术,主要有两个原因:
(1)不需要创建匿名内部类;
(2)减少了创建列表所需的样板代码(噪音)。
不过以这种方式创建列表的代价是:列表是只读的。也就是说一旦创建后就不能修改。 为了创建可读写的列表,就只能使用前面介绍的双花括号初始化方式或者传统的初始化方式了。

请注意,传统初始化,双花括号初始化和JDK 9静态工厂方法不仅可用于List。 它们也可用于Set和Map对象,如以下代码段所示:

Map<String, Integer> myMap1= new HashMap<>();
myMap1.put("key1", 10);
myMap1.put("key2", 15); Map<String, Integer> myMap2 = new HashMap<>() {{
put("Key1", 10);
put("Key2", 15);
}}; Map<String, Integer> myMap3 = Map.of("key1", 10, "key2", 15);

在使用双花括号方式初始化之前,要考虑它的性质,虽然确实提高了代码的可读性,但它带有一些隐式的副作用。例如,会创建隐式对象。

4. 注释并不是打酱油的:可执行注释

注释几乎是每个程序必不可少的组成部分,注释的主要好处是它们不被执行,而且容易让程序变得更可读。 当我们在程序中注释掉一行代码时,这一点变得更加明显。我们希望将代码保留在我们的应用程序中,但我们不希望它被执行。 例如,以下程序导致将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节。

5. 枚举与接口结合:枚举实现接口

与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)枚举可以实现接口;
尽管其中一些功能有其适当的用途,但应避免使用其中某些功能(即创建可执行注释)。 在决定使用这些机密时,请确保真的有必要这样做。

请关注”极客起源“公众号,输入获取本文源代码。

深度解析Java中的5个“黑魔法”