1. 程式人生 > >Java 程式設計師必須掌握的 5 個註解

Java 程式設計師必須掌握的 5 個註解

自 JDK5 推出以來,註解已成為Java生態系統不可缺少的一部分。雖然開發者為Java框架(例如Spring的@Autowired)開發了無數的自定義註解,但編譯器認可的一些註解非常重要。

在本文中,我們將看到5個Java編譯器支援的註解,並瞭解其期望用途。順便,我們將探索其建立背後的基本原理,圍繞其用途的一些特質,以及正確應用的一些例子。雖然其中有些註解比其他註解更為常見,但非初學Java開發人員都應該消化了解每個註解。

  • @Override

  • @FunctionalInterface

  • @SuppressWarnings

  • @SafeVarargs

  • @Deprecated

首先,我們將深入研究Java中最常用的註解之一:@Override。

覆蓋方法的實現或為抽象方法提供實現的能力是任何面向物件(OO)語言的核心。由於Java是OO語言,具有許多常見的面向物件的抽象機制,所以在非終極超類定義的非最終方法或介面中的任何方法(介面方法不能是最終的)都可以被子類覆蓋。點選這裡閱讀 Java 10 新特性實戰教程。

雖然開始時覆蓋方法看起來很簡單,但是如果執行不正確,則可能會引入許多微小的bug。例如,用覆蓋類型別的單個引數覆蓋Object#equals方法就是一種常見的錯誤:

public class Foo {    public boolean equals(Foo foo) {        // Check if the supplied object is equal to this object
    }
}

由於所有類都隱式地從Object類繼承,Foo類的目的是覆蓋Object#equals方法,因此Foo可被測試是否與Java中的任何其他物件相等。雖然我們的意圖是正確的,但我們的實現則並非如此。

實際上,我們的實現根本不覆蓋Object#equals方法。相反,我們提供了方法的過載:我們不是替換Object類提供的equals方法的實現,而是提供第二個方法來專門接受Foo物件,而不是Object物件。

我們的錯誤可以用簡單實現來舉例說明,該實現對所有的相等檢查都返回true,但當提供的物件被視為Object(Java將執行的操作,例如在Java Collections Framework即JCF中)時,就永遠不會呼叫它:

public class Foo {    public boolean equals(Foo foo) {        return true;
    }
}
Object foo = new Foo();
Object identicalFoo = new Foo();
System.out.println(foo.equals(identicalFoo));    // false

這是一個非常微妙但常見的錯誤,可以被編譯器捕獲。我們的意圖是覆蓋Object#equals方法,但因為我們指定了一個型別為Foo而不是Object型別的引數,所以我們實際上提供了過載的Object#equals方法,而不是覆蓋它。為了捕獲這種錯誤,我們引入@Override註解,它指示編譯器檢查覆蓋實際有沒有執行。如果沒有執行有效的覆蓋,則會丟擲錯誤。因此,我們可以更新Foo類,如下所示:

public class Foo {    @Override
    public boolean equals(Foo foo) {        return true;
}
}

如果我們嘗試編譯這個類,我們現在收到以下錯誤:

$ javac Foo.java
Foo.java:3: error: method does not override or implement a method from a supertype
        @Override
        ^1 error

實質上,我們已經將我們已經覆蓋方法的這一隱含的假設轉變為由編譯器進行的顯性驗證。如果我們的意圖被錯誤地實現,那麼Java編譯器會發出一個錯誤——不允許我們不正確實現的程式碼被成功編譯。通常,如果以下任一條件不滿足,則Java編譯器將針對使用@Override註解的方法發出錯誤(引用自Override註解文件):

  • 該方法確實會覆蓋或實現在超類中宣告的方法。

  • 該方法的簽名與在Object中宣告的任何公共方法(即equals或hashCode方法)的簽名覆蓋等價(override-equivalent)。

因此,我們也可以使用此註解來確保子類方法實際上也覆蓋超類中的非最終具體方法或抽象方法:

public abstract class Foo {    public int doSomething() {        return 1;
    }    public abstract int doSomethingElse();
}public class Bar extends Foo {    @Override
    public int doSomething() {        return 10;
    }    @Override
    public int doSomethingElse() {        return 20;
    }
}
Foo bar = new Bar();
System.out.println(bar.doSomething());         // 10System.out.println(bar.doSomethingElse());     // 20

@Override註解不僅不限於超類中的具體或抽象方法,而且還可用於確保介面的方法也被覆蓋(從JDK 6開始):

public interface Foo {    public int doSomething();
}public class Bar implements Foo {    @Override
    public int doSomething() {        return 10;
    }
}
Foo bar = new Bar();
System.out.println(bar.doSomething());    // 10

通常,覆蓋非final類方法、抽象超類方法或介面方法的任何方法都可以使用@Override進行註解。有關有效覆蓋的更多資訊,請參閱《Overriding and Hiding》文件 以及《Java Language Specification (JLS)》的第9.6.4.4章節。

@FunctionalInterface

隨著JDK 8中lambda表示式的引入,函式式介面在Java中變得越來越流行。這些特殊型別的介面可以用lambda表示式、方法引用或建構函式引用代替。根據@FunctionalInterface文件,函式式介面的定義如下:

一個函式式介面只有一個抽象方法。由於預設方法有一個實現,所以它們不是抽象的。

例如,以下介面被視為函式式介面:

public interface Foo {    public int doSomething();
}public interface Bar {    public int doSomething();    public default int doSomethingElse() {        return 1;
    }
}

因此,下面的每一個都可以用lambda表示式代替,如下所示:

public class FunctionalConsumer {    public void consumeFoo(Foo foo) {
        System.out.println(foo.doSomething());
    }    public void consumeBar(Bar bar) {
        System.out.println(bar.doSomething());
    }
}
FunctionalConsumer consumer = new FunctionalConsumer();
consumer.consumeFoo(() -> 10);    // 10consumer.consumeBar(() -> 20);    // 20

重點要注意的是,抽象類,即使它們只包含一個抽象方法,也不是函式式介面。更多資訊,請參閱首席Java語言架構師Brian Goetz編寫的《Allow lambdas to implement abstract classes》。與@Override註解類似,Java編譯器提供了@FunctionalInterface註解以確保介面確實是函式式介面。例如,我們可以將此註解新增到上面建立的介面中:

@FunctionalInterfacepublic interface Foo {    public int doSomething();
}@FunctionalInterfacepublic interface Bar {    public int doSomething();    public default int doSomethingElse() {        return 1;
    }
}

如果我們錯誤地將介面定義為非函式介面並用@FunctionalInterface註解了錯誤的介面,則Java編譯器會發出錯誤。例如,我們可以定義以下帶註解的非函式式介面:

@FunctionalInterfacepublic interface Foo {    public int doSomething();    public int doSomethingElse();
}

如果我們試圖編譯這個介面,則會收到以下錯誤:

$ javac Foo.java
Foo.java:1: error: Unexpected @FunctionalInterface annotation
@FunctionalInterface
^
  Foo is not a functional interface
    multiple non-overriding abstract methods found in interface Foo1 error

使用這個註解,我們可以確保我們不會錯誤地建立原本打算用作函式式介面的非函式式介面。需要注意的是,即使在@FunctionalInterface註解不存在的情況下,介面也可以用作函式式介面(可以替代為lambdas,方法引用和建構函式引用),正如我們前面的示例中所見的那樣。這類似於@Override註解,即一個方法是可以被覆蓋的,即使它不包含@Override註解。在這兩種情況下,註解都是允許編譯器執行期望意圖的可選技術。

有關@FunctionalInterface註解的更多資訊,請參閱@FunctionalInterface文件和《JLS》的第4.6.4.9章節。點選這裡閱讀 Java 10 新特性實戰教程。

警告是所有編譯器的重要組成部分,為開發人員提供的反饋——可能危險的行為或在未來的編譯器版本中可能會出現的錯誤。例如,在Java中使用泛型型別而沒有其關聯的正式泛型引數(稱為原始型別)會導致警告,就像使用不推薦使用的程式碼一樣(請參閱下面的@Deprecated部分)。雖然這些警告很重要,但它們可能並不總是適用甚至並不總是正確的。例如,可能會有對不安全的型別轉換髮生警告的情況,但是基於使用它的上下文,我們可以保證它是安全的。

為了忽略某些上下文中的特定警告,JDK 5中引入了@SuppressWarnings註解。此註解接受一個或多個字串引數——描述要忽略的警告名稱。雖然這些警告的名稱通常在編譯器實現之間有所不同,但有3種警告在Java語言中是標準化的(因此在所有Java編譯器實現中都很常見):

  • unchecked:表示型別轉換未經檢查的警告(編譯器無法保證型別轉換是安全的),導致發生的可能原因有訪問原始型別的成員(參見《JLS》4.8章節)、窄參考轉換或不安全的向下轉換(參見《JLS》5.1.6章節)、未經檢查的型別轉換(參見《JLS》5.1.9章節)、使用帶有可變引數的泛型引數(參見《JLS》8.4.1章節和下面的@SafeVarargs部分)、使用無效的協變返回型別(參見《JLS》8.4.8.3章節)、不確定的引數評估(參見《JLS》15.12.4.2章節),未經檢查的方法引用型別的轉換(參見《JLS》15.13.2章節)、或未經檢查的lambda型別的對話(參見《JLS》15.27.3章節)。

  • deprecation:表示使用了已棄用的方法、類、型別等的警告(參見《JLS》9.6.4.6章節和下面的@Deprecated部分)。

  • removal:表示使用了最終廢棄的方法、類、型別等的警告(參見《JLS》9.6.4.6章節和下面的@Deprecated部分)。

為了忽略特定的警告,可以將@SuppressedWarning註解與抑制警告(以字串陣列的形式提供)的一個或多個名字新增到發生警告的上下文中:

public class Foo {    public void doSomething(@SuppressWarnings("rawtypes") List myList) {        // Do something with myList
    }
}

@SuppressWarnings註解可用於以下任何一種情況:

  • 型別

  • 方法

  • 引數

  • 建構函式

  • 區域性變數

  • 模組

一般來說,@SuppressWarnings註解應該應用於最直接的警告範圍。例如,如果方法中的區域性變數應忽略警告,則應將@SuppressWarnings註解應用於區域性變數,而不是包含區域性變數的方法或類:

public class Foo {    public void doSomething() {        @SuppressWarnings("rawtypes")
        List myList = new ArrayList();        // Do something with myList
    }
}

@SafeVarargs

可變引數在Java中是一種很有用的技術手段,但在與泛型引數一起使用時,它們也可能會導致一些嚴重的問題。由於泛型在Java中是非特定的,所以具有泛型型別的變數的實際(實現)型別不能在執行時被斷定。由於無法做出此判斷,因此變數可能會儲存非其實際型別的引用到型別,如以下程式碼片段所示(摘自《Java Generics FAQs》):

List ln = new ArrayList<Number>();
ln.add(1);
List<String> ls = ln;                 // unchecked warning String s = ls.get(0);                 // ClassCastException

在將ln分配給ls後,堆中存在變數ls,該變數具有List<String>的型別,但儲存引用到實際為List<Number>型別的值。這個無效的引用被稱為堆汙染。由於直到執行時才能確定此錯誤,因此它會在編譯時顯示為警告,並在執行時出現ClassCastException。當泛型引數與可變引數組合時,可能會加劇此問題:

public class Foo {    public <T> void doSomething(T... args) {        // ...
    }
}

在這種情況下,Java編譯器會在呼叫站點內部建立一個數組來儲存可變數量的引數,但是T的型別並未實現,因此在執行時會丟失。實質上,到doSomething的引數實際上是Object[]型別。如果依賴T的執行時型別,那麼這會導致嚴重的問題,如下面的程式碼片段所示:

public class Foo {
    public <T> void doSomething(T... args) {        Object[] objects = args;        String string = (String) objects[0];
    }
}
Foo foo = new Foo();
foo.<Number>doSomething(1, 2);

如果執行此程式碼片段,那麼將導致ClassCastException,因為在呼叫站點傳遞的第一個Number引數不能轉換為String(類似於獨立堆汙染示例中丟擲的ClassCastException)。通常,可能會出現以下情況:編譯器沒有足夠的資訊來正確確定通用可變引數的確切型別,這會導致堆汙染,這種汙染可以通過允許內部可變引數陣列從方法中轉義來傳播,如下面摘自《Effective Java》第3版 pp.147的例子:

public static <T> T[] toArray(T... args) {    return args;
}

在某些情況下,我們知道方法實際上是型別安全的,不會造成堆汙染。如果可以在保證的情況下做出這個決定,那麼我們可以使用@SafeVarargs註解來註解該方法,從而抑制與可能的堆汙染相關的警告。但是,這引出了一個問題:什麼時候通用可變引數方法會被認為是型別安全的?Josh Bloch在《Effective Java》第3版第147頁的基礎上提供了一個完善的解決方案——基於方法與內部建立的用於儲存其可變引數的陣列的互動:

如果方法沒有儲存任何東西到陣列(這會覆蓋引數)且不允許對陣列的引用進行轉義(這會使得不受信任的程式碼可以訪問陣列),那麼它是安全的。換句話說,如果可變引數陣列僅用於從呼叫者向方法傳遞可變數量的引數——畢竟,這是可變引數的目的——那麼該方法是安全的。

因此,如果我們建立了以下方法(來自pp.149同上),那麼我們可以用@SafeVarags註解來合理地註解我們的方法:

@SafeVarargsstatic <T> List<T> flatten(List<? extends T>... lists) {    List<T> result = new ArrayList<>();    for (List<? extends T> list : lists) {
        result.addAll(list);
    }    return result;
}

有關@SafeVarargs註解的更多資訊,請參閱@SafeVarargs文件,《JLS》9.6.4.7章節以及《Effective Java》第3版中的Item32。點選這裡閱讀 Java 10 新特性實戰教程。

@Deprecated

在開發程式碼時,有時候程式碼會變得過時和不應該再被使用。在這些情況下,通常會有個替補的更適合手頭的任務,且雖然現存的對過時程式碼的呼叫可能會保留,但是所有新的呼叫都應該使用替換方法。這個過時的程式碼被稱為不推薦使用的程式碼。在某些緊急情況下,不建議使用的程式碼可能會被刪除,應該在未來的框架或庫版本從其程式碼庫中刪除棄用的程式碼之前立即轉換為替換程式碼。

為了支援不推薦使用的程式碼的文件,Java包含@Deprecated註解,它會將一些建構函式、域、區域性變數、方法、軟體包、模組、引數或型別標記為已棄用。如果棄用的元素(建構函式,域,區域性變數等)被使用了,則編譯器發出警告。例如,我們可以建立一個棄用的類並按如下所示使用它:

@Deprecatedpublic class Foo {}
Foo foo = new Foo();

如果我們編譯此程式碼(在命名為Main.java的檔案中),我們會收到以下警告:

$ javac Main.javaNote: Main.java uses or overrides a deprecated API.Note: Recompile with -Xlint:deprecation for details.

通常,每當使用@Deprecated註解的元素時,都會引發警告,除了用於以下五種情況:

  • 宣告本身就被宣告為是棄用的(即遞迴呼叫)。

  • 宣告被註解禁止棄用警告(即@SuppressWarnings(“deprecation”)註解,如上所述,應用於使用棄用元素的上下文。

  • 使用和宣告都在同一個最外面的類中(即,如果類呼叫其本身的棄用方法)。

  • 用在import宣告中,該宣告匯入通常不贊成使用的型別或構件(即,在將已棄用的類匯入另一個類時)。

  • exports或opens指令內。

正如前面所說的,在某些情況下,當不推薦使用的元素將被刪除,則呼叫程式碼應立即刪除不推薦使用的元素(稱為terminally deprecated code)。在這種情況下,可以使用forRemoval引數提供的@Deprecated註解,如下所示:

@Deprecated(forRemoval = true)public class Foo {}

使用此最終棄用程式碼會導致一系列更嚴格的警告:

$ javac Main.java
Main.java:7: warning: [removal] Foo in com.foo has been deprecated and marked for removal
                Foo foo = new Foo();
                ^
Main.java:7: warning: [removal] Foo in com.foo has been deprecated and marked for removal
                Foo foo = new Foo();
                              ^2 warnings

除了標準@Deprcated註解所描述的相同異常之外,總是會發出最終棄用的警告。我們還可以通過為註解提供since變數來新增文件到@Deprecated註解中:

@Deprecated(since = "1.0.5", forRemoval = true)public class Foo {}

可以使用@deprecated JavaDoc元素(注意小寫字母d)進一步文件化已棄用的元素,如以下程式碼片段所示:

/**
 * Some test class.
 * 
 * @deprecated Replaced by {@link com.foo.NewerFoo}.
 * 
 * @author Justin Albano
 */@Deprecated(since = "1.0.5", forRemoval = true)public class Foo {}

JavaDoc工具將生成以下文件:

有關@Deprecated註解的更多資訊,請參閱@Deprecated文件和《JLS》9.6.4.6章節。

結尾

自JDK 5引入註解以來,註解一直是Java不可缺少的一部分。雖然有些註解比其他註解更受歡迎,但本文中介紹的這5種註解是新手級別以上的開發人員都應該理解和掌握的:

  • @Override

  • @FunctionalInterface

  • @SuppressWarnings

  • @SafeVarargs

  • @Deprecated

雖然每種方法都有其獨特的用途,但所有這些註解使得Java應用程式更具可讀性,並允許編譯器對我們的程式碼執行一些其他隱含的假設。隨著Java語言的不斷髮展,這些經過實踐驗證的註解可能服務多年,幫助確保更多的應用程式按開發人員的意圖行事。