天天看點

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

請關注”極客起源“公衆号,輸入109254擷取本文完整源代碼。

現在的程式設計語言越來越複雜,盡管有大量的文檔和書籍,這些學習資料仍然隻能描述程式設計語言的冰山一角。而這些程式設計語言中的很多功能,可能被永遠隐藏在黑暗角落。本文将為你解釋其中5個Java中隐藏的秘密,可以稱其為Java的“黑魔法”。對于這些魔法,會描述它們的實作原理,并結合一些應用場景給出實作代碼。

一石二鳥:實作注釋(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對象。盡管大多數情況下,都不需要實作一個注釋,不過在一些情況,如注釋驅動的架構内,可能會很有用。

五花八門的初始化方式:初始化塊

在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個“黑魔法”

初始化有妙招:雙花括号初始化

許多程式設計語言都包含某種文法機制,可以使用非常少的代碼快速建立清單(數組)和映射(字典)對象。 例如,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匿名子類。他們的主要差別之一是我們的内部類對包含的類有隐式引用,我們正在建立一個非靜态内部類。 這使我們能夠編寫一些有趣的邏輯(如果不是很複雜的話),例如将捕獲的此變量添加到匿名的,雙花括号初始化的内部類代碼如下:

package 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);           

在使用雙花括号方式初始化之前,要考慮它的性質,雖然确實提高了代碼的可讀性,但它帶有一些隐式的副作用。例如,會建立隐式對象。

注釋并不是打醬油的:可執行注釋

注釋幾乎是每個程式必不可少的組成部分,注釋的主要好處是它們不被執行,而且容易讓程式變得更可讀。 當我們在程式中注釋掉一行代碼時,這一點變得更加明顯。我們希望将代碼保留在我們的應用程式中,但我們不希望它被執行。 例如,以下程式導緻将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前面按一下Enter鍵),以確定執行該指派。 這意味着以上代碼段實際上等于以下代碼段:

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節。

枚舉與接口結合:枚舉實作接口

與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),則定義該常量的枚舉應實作接口類型。