深度解析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();
    }
}

執行這段代碼,會得到下面的結果:

 

 

 

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)枚舉可以實現接口;
儘管其中一些功能有其適當的用途,但應避免使用其中某些功能(即創建可執行注釋)。 在決定使用這些機密時,請確保真的有必要這樣做。

 

請關注」極客起源「公眾號,輸入109254獲取本文源代碼。

 

Tags: