深度解析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對象。儘管大多數情況下,都不需要實現一個註釋,不過在一些情況,如註釋驅動的框架內,可能會很有用。

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

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

}

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

  1. 初始化有妙招:雙花括號初始化

許多編程語言都包含某種語法機制,可以使用非常少的代碼快速創建列表(數組)和映射(字典)對象。 例如,C ++可以使用大括號初始化,這使開發人員可以快速創建枚舉值列表,甚至在對象的構造方法支持此功能的情況下初始化整個對象。 不幸的是,在JDK 9之前,因此,在JDK9之前,我們仍然需要痛苦而無奈地使用下面的代碼創建和初始化列表:

List myInts = new ArrayList<>();
myInts.add(1);
myInts.add(2);
myInts.add(3);
儘管上面的代碼可以很好完成我們的目標:創建包含3個整數值的ArrayList對象。但代碼過於冗長,這要求開發人員每次都要使用變量(myInts)的名字。爲了簡化這段diamante,可以使用雙括號來完成同樣的工作。

List myInts = new ArrayList<>() {{

add(1);
add(2);
add(3);

}};

雙花括號初始化實際上是多個語法元素的組合。首先,我們創建一個擴展ArrayList類的匿名內部類。 由於ArrayList沒有抽象方法,因此我們可以爲匿名類實現創建一個空的實體。

List 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 myInts = new ArrayList<>() {
  {
    add(1);
    add(2);
    add(3);
  }
};

儘管該技巧很有用,但JDK 9(JEP 269)已用一組List(以及許多其他收集類型)的靜態工廠方法代替了此技巧的實用程序。 例如,我們可以使用這些靜態工廠方法創建上面的列表,代碼如下:

List myInts = List.of(1, 2, 3);
之所以需要這種靜態工廠技術,主要有兩個原因:
(1)不需要創建匿名內部類;
(2)減少了創建列表所需的樣板代碼(噪音)。
不過以這種方式創建列表的代價是:列表是隻讀的。也就是說一旦創建後就不能修改。 爲了創建可讀寫的列表,就只能使用前面介紹的雙花括號初始化方式或者傳統的初始化方式了。

請注意,傳統初始化,雙花括號初始化和JDK 9靜態工廠方法不僅可用於List。 它們也可用於Set和Map對象,如以下代碼段所示:

Map myMap1= 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);

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

  1. 註釋並不是打醬油的:可執行註釋

註釋幾乎是每個程序必不可少的組成部分,註釋的主要好處是它們不被執行,而且容易讓程序變得更可讀。 當我們在程序中註釋掉一行代碼時,這一點變得更加明顯。我們希望將代碼保留在我們的應用程序中,但我們不希望它被執行。 例如,以下程序導致將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節。

  1. 枚舉與接口結合:枚舉實現接口

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

原文地址https://www.cnblogs.com/nokiaguy/p/12692042.html

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章