1. 程式人生 > >深度解析Java中的5個“黑魔法”

深度解析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獲取本文原始碼。

&n