使用空值註釋

NullPointerException 是 Java 程式失敗最常見的原因之一。 在最簡單的情況下,編譯器在發現如下的程式碼時可以直接警告您:

    Object o = null;
    String s = o.toString();

在分支/迴圈及擲出異常狀況下,為了查明在程式的某些或全部路徑上,是否已指派空值/非空值給解除參照的變數,相當複雜的流程分析就變得有必要。

由於先天的複雜性,最好在小片段中執行流程分析。 一次分析一個方法可以在良好的工具效能情況下完成 - 而系統全面分析已超出 Eclipse Java 編譯器的範圍。 優點是:分析快速,可漸進地完成,編譯器可隨著您輸入而直接警告您。 缺點:分析無法「看見」哪些值(空值或非空值)在方法之間流動(參數和回覆值)。

程序間的空值分析

這是空值註釋發揮作用的地方。 您可以將方法參數指定為 @NonNull,告知編譯器在這個位置中不空值。

    String capitalize(@NonNull String in) {
        return in.toUpperCase();                // 不需要空值檢查
    }
    void caller(String s) {
        if (s != null)
            System.out.println(capitalize(s));  // 需要先前的空值檢查
    }

Design-by-Contract 風格來說,這分成兩方面:

  1. 呼叫端負責永不傳遞空值,這由明確空值檢查來確定。
  2. 方法 capitalize實作者享有保證引數 in 不應該為空值,因此在這裡未經空值檢查而解除參照不會有問題。

對於方法回覆值,狀況是對稱的:

    @NonNull String getString(String maybeString) {
        if (maybeString != null)
            return maybeString;                         // 需要上述空值檢查
    else
            return "<n/a>";
    }
    void caller(String s) {
        System.out.println(getString(s).toUpperCase()); // 不需要空值檢查
    }
  1. 現在實作者必須確保永不傳回空值。
  2. 相反地,呼叫端現在享有保證未經檢查而解除參照方法結果沒有問題。

可用的註釋

Eclipse Java 編譯器可以配置來使用其加強型空值分析(依預設停用)的三個不同註釋類型:

這些位置中支援註釋 @NonNull@Nullable

以下支援 @NonNullByDefault

請注意,即使這些註釋的實際完整名稱可配置,但依預設會使用上述註釋(從套件 org.eclipse.jdt.annotation 中)。 當使用協力廠商空值註釋時,請確定那些註釋至少使用 @Target meta 註釋定義適當,否則,編譯器無法區別宣告註釋 (Java 5) 和類型註釋 (Java 8)。

建置路徑設定

Eclipse 的 eclipse/plugins/org.eclipse.jdt.annotation_*.jar 中隨附了一個內含預設空值註釋的 JAR。 在編譯時期,這個 JAR 必須位於建置路徑上,但在執行時期並不需要(所以您不需要將這個 JAR 傳送給已編譯程式碼的使用者)。

從 Eclipse Luna 起,這個 JAR 有兩個版本,一個含有在 Java 7 或更舊版本(1.1.x 版)中使用的宣告註釋,另一個含有在 Java 8(2.0.x 版)中使用的空值類型註釋

在一般 Java 專案方面,對於 @NonNull@Nullable@NonNullByDefault 等無法解析的參照,亦提供快速修正程式,用以將適當版本的 JAR 新增至建置路徑:

將內含預設空值註釋的程式庫複製到建置路徑

OSGi 軟體組 / 外掛程式:請新增下列其中一個項目至您的 MANIFEST.MF:

在 Java 7 或更舊版本的專案中使用空值註釋時:
Require-Bundle: ...,
 org.eclipse.jdt.annotation;bundle-version="[1.1.0,2.0.0)";resolution:=optional
對於 Java 8 專案中的空值類型註釋,請使用:
Require-Bundle: ...,
 org.eclipse.jdt.annotation;bundle-version="[2.0.0,3.0.0)";resolution:=optional

另請參閱相容性對應區段中的討論。

空值註釋的解譯

現在應該很清楚空值註釋可以為 Java 程式增加更多資訊(再由編譯器用來提供較佳的警告)。 但我們真正希望這些註釋有何用途? 從實務觀點來看,我們想用空值註釋來表達的資訊至少有三個層次:

  1. 對讀者的偶然提示(真人和編譯器)
  2. 依合約設計:某些或全部方法的 API 規格
  3. 使用延伸類型系統的完整規格

以 (1) 來說,您可以立即開啟使用空值註釋,不必進一步閱讀,但您不應預期現在和之後會看到更多提示。 其他層次需要進一步說明。

依合約設計:API 規格

乍看之下,以「依合約設計」風格來說,API 規格使用空值註釋只表示所有 API 方法的簽章應該完全標註,亦即,不考慮 int 之類的初始類型,每一個參數和每一個方法傳回類型都應該標示為 @NonNull@Nullable。 因為這表示要插入大量空值註釋,最好瞭解在設計良好的程式碼中(尤其是 API 方法),@NonNull@Nullable 更常用。因此,可將 @NonNull 宣告為 default,在套件層次上使用 @NonNullByDefault 註釋,以減少註釋數目。

請注意,@Nullable 和省略空值註釋之間的明顯差異:這個註釋明確表示空值沒問題,且必須在預期內。 相反地,沒有註釋只是表示我們不知道用意。 這是長期以來的狀況,雙方(呼叫端和被呼叫端)重複檢查空值,有時是雙方誤以為對方會執行檢查。 這是 NullPointerException 的來源。 如果沒有註釋,編譯器不會提供特定的建議,但如果使用 @Nullable 註釋,則會標示每一個未檢查的解除參照。

根據這些基礎,我們可以將所有參數註釋對映至前置條件,並將傳回註釋解譯成方法的後置條件

子類型和置換

在物件導向程式設計中,「依合約設計」的概念還需要說明一個方面:子類型和置換(在後文中,「置換」就代表 Java 6 的 @Override 註釋:從超類型置換或實作另一個方法的方法)。呼叫方法的用戶端如下:

    @NonNull String checkedString(@Nullable String in)

應該允許假設這個方法的所有實作滿足合約。 因此,在介面 I1 中發現方法宣告時,我們必須排除任何實作 I1 的類型 Cn 提供不相容實作。 明確地說,如果有任何 Cn 嘗試以將參數宣告為 @NonNull 的實作來置換這個方法,都是不合法。 如果我們願意允許這樣,則針對 I1 所設計的用戶端模組可以合法地傳遞空值當作引數,但實作會假設非空值 - 允許方法實作內未檢查的解除參照,但在執行時期會爆發問題。 因此,@Nullable 參數規格強制所有置換,以允許空值為預期的合法值。

相反地,@NonNull 傳回規格強制所有置換,以確保永不傳回空值。

因此,編譯器必須檢查沒有置換會新增不存在於超類型中的 @NonNull 參數註釋 (或 @Nullable 傳回註釋)。

很有趣的是反向重新定義合法:新增 @Nullable 參數註釋或 @NonNull 傳回註釋(您可以將這些視為方法的「改進」,它接受更多值且產生更明確的回覆值)。

透過強制子類別在任何的置換方法重複空值註釋,不需搜尋繼承階層即可瞭解每一個方法的空值合約。不過,在繼承階層混合了不同來源的程式碼的情況下,可能無法一次對所有類別新增空值註釋。在這些情況下,您可以告知編譯器將缺少空值註釋的方法視為繼承了被置換方法中的註釋。這是利用編譯器選項繼承空值註釋來啟用。一個方法可以改寫具有不同空值合約的兩個方法。空值預設值也適用於與繼承空值註釋衝突的方法。這些情況都會標示為錯誤,置換方法必須使用明確空值註釋來解決此衝突。

是否將 @NonNull 參數放寬為未指定參數?

如果啟用繼承空值註釋,從類型理論觀點而言是安全的一個特殊情況,但仍可能有問題:假設有一個超級方法將參數宣告為 @NonNull,以及未限制對應參數(既不是由明確空值註釋限制,也不是由適當的 @NonNullByDefault 限制)的置換方法。

這是安全的,因為用戶端看到超宣告會強制避免 null,而置換實作因為缺少這個特定方法的規格,無法運用這項保證。

這也可能造成誤解,因為可能預期超類型中的宣告應該也適用於所有置換。

基於這個原因,編譯器提供了 在置換方法中不標註「 @NonNull 」參數選項:

舊式超類型

當標註的程式碼撰寫成「舊式」(即未標註)類型(可能來自協力廠商程式庫,因此無法變更)的子類型時,先前考量增加困難度。 如果您仔細閱讀前一節,您會發現我們不允許「舊式」方式被置換為具有 @NonNull 參數的方法 (因為使用超類型的用戶端不會「看到」@NonNull 契約)。

在此情況下,將強制您省略空值註釋 (已計劃支援事後將註釋新增至程式庫,但尚無法保證是否及何時提供這種特性)。

取消空值預設值

如果「舊式」類型的子類型位於已指定 @NonNullByDefault 的套件中,情況會變得難以處理。現在,未標註超類型的類型需要將置換方法中的所有參數標示為 @Nullable:甚至不允許省略參數註釋,因為這樣會解譯成 @NonNull 參數,而在該位置中禁止如此。 這就是 Eclipse Java 編譯器支援取消空值預設值的原因:這個元素會取消將具有 @NonNullByDefault(false) 的方法或類型標註成適用的預設值,而未標註的參數會再次解譯成未指定。 現在,子類型又重回到舊式,未新增不需要的 @Nullable 註釋:

class LegacyClass {
    String enhance (String in) { // 不強制用戶端傳遞非空值。
        return in.toUpperCase();
    }
}
 
@NonNullByDefault
class MyClass extends LegacyClass {
	
    // ... 具有 @NonNull 預設值的方法 ...
 
    @Override
    @NonNullByDefault(false)
    String enhance(String in) { // 如果 @NonNullByDefault 在這裡有作用,則此行無效
        return super.enhance(in);
    }
}

欄位的案例

空值註釋最適合套用於方法簽章(通常,區域變數甚至不需要這些空值註釋,但也可以使用空值註釋將已標註的程式碼與「舊式」程式碼連結)。在這種使用方式下,空值註釋會連接內部程序分析的片段,以達到廣域資料流程的相關陳述式。從 Eclipse Kepler 開始,也可以將空值註釋套用在欄位,但在這裡情況稍有不同。

考慮標記為 @NonNull 欄位:這顯然需要針對該欄位的任何指派提供一個不是空值的值。此外,編譯器必須能驗證非空值欄位一定無法在未起始設定狀態(在這種情況下,它的值仍然是 null)時被存取。驗證方式可以是每一個建構子都符合這項規則(同樣地,static 欄位必須有起始設定元),程式即可得到取消參照該欄位永不會導致 NullPointerException 的安全好處。

在考慮標記為 @Nullable 的欄位時,這種情況更微妙。這類的欄位應該一律視為危險,使用可為空值的欄位的建議方法是:使用區域變數之前一律指派值。使用區域變數,流程分析可以正確地辨別解除參照是否受到空值檢查的足夠保護。遵循這項一般規則,使用可為空值的欄位就不會產生任何問題。

當程式碼直接解除參照為空值欄位的值時,可能涉及到其他考量。問題是,下列其中一種情況很容易就能讓程式碼在解除參照之前,可以執行的任何空值檢查失效:

我們都知道,沒有一併分析執行緒同步化(這超過編譯器的功能),可空值欄位的空值檢查就無法對後續的解除參照提供 100% 的安全。因此,如果能並行存取可為空值的欄位,則絕不直接取消參照該欄位的值,而是一律改用區域變數。即使未涉及到並行,其餘問題也會對完整分析提出挑戰,這比編譯器通常可以處理的分析更困難。

流程分析和語法分析

假設編譯器無法完全分析別名化的影響、負面影響及並行,Eclipse 編譯器不會對欄位執行任何流程分析(而不是與它們的起始設定相關)。由於許多開發人員將為這個限制視為太過限制 - 需要使用區域變數,但開發人員感覺他們的程式實際上應該是安全的 - 因此,引入了新的選項,作為暫訂的折衷辦法:

編譯器可以配置為執行部分的語法分析。這將會偵測最明顯的型樣,如下所示:

    @Nullable Object f;
    void printChecked() {
        if (this.f != null)
            System.out.println(this.f.toString());
    }

如果啟用給定的選項,編譯器將不會標示上面的程式碼。瞭解此語法分析一點都不「智慧」是很重要的。如果在檢查和解除參照之間出現任何程式碼,編譯器會膽怯地「忘記」前一次空值檢查的資訊,甚至不會嘗試根據某個準則來看看中間程式碼是否可能無害。因此,請注意:每當編譯器將一個可為空值的欄位的解除參照標示為不安全時,雖然肉眼觀察並不會產生空值,請重新撰寫程式碼以嚴格遵循上面所顯示的可識別型樣,甚至使用更好的方法:使用區域變數,運用流程分析的所有複雜性,這是語法分析所無法達到的。

依合約設計的好處

在上述「依合約設計」樣式中使用空值註釋,在幾方面有助於改進 Java 程式碼的品質:在方法之間的介面上,更明確看出哪些參數/傳回允許空值及哪些不允許。 這可透過也可讓編譯器檢查的方式來捕捉設計決策,與開發人員相當有關。

此外,根據這個介面規格,程序內的流程分析可取得可用的資訊,提供更準確的錯誤/警告。 如果沒有註釋,則進出方法的任何值會有不明的空值,因此空值分析仍然不會指出其使用情形。 如果有 API 層次空值註釋,則實際可知大部分值的空值,而編譯器未注意的 NPE 極少。 不過,您應該注意仍有漏洞,由於未指定的值會流進分析,而無法執行完成陳述式,不論執行時期是否可能發生 NPE。

使用延伸類型系統的完整規格

空值註釋支援是設計成會與未來的延伸相容。這項延伸成為類型註釋 (JSR 308),已變成 Java 語言的一部分,並納入於 Java 8 中。JDT 支援運用空值類型註釋的新概念。

解釋編譯器訊息

這裡解釋編譯器會檢查的規則,以及當違反規則時會發出的訊息,來提供基於註釋的空值分析的語意詳細資料。

在對應的喜好設定頁面,編譯器檢查的個別規則依下列標題分組:

空值規格的違規

在規格違規方面,我們會處理空值註釋的要求違反實際實作的任何狀況。 一般狀況是指定的值(區域、引數、方法傳回)為 @NonNull,而實作實際上提供可為空值的值。 在這裡,如果確實知道表示式會評估為 null 值,或如果以 @Nullable 註釋來宣告,則表示式視為可為空值。

其次,這一組也包含如上討論的方法置換的規則。 在這裡,超方法會建立要求(例如,空值為合法引數),而置換會嘗試躲避這個要求(假設空值不是合法引數)。 如前所述,就算將引數從未標註特殊化為 @NonNull 也違反規格,因為這樣會引進合約來約束用戶端(不傳遞空值),但使用超類型的用戶端根本不知有這個合約,所以根本不知如何處理。

這裡提供規格違規的完整狀況清單。 必須瞭解不應該忽略這一組的錯誤,否則會根據錯誤的假設來執行整個空值分析。 明確地說,每當編譯器看到具有 @NonNull 註釋的值時,就可確定不會在執行時期出現空值。 這是關於規格違規的規則,可確保這個推論很合理。 因此,強烈建議將這種問題維持配置為錯誤

空值註釋與空值推斷之間發生衝突

這一組規則也會檢查是否遵循空值規格。 不過,我們在這裡處理的不是宣告@Nullable 的值 (也不是 null 值本身),而是程序內流程分析推斷在某些執行路徑上可能出現空值的值。

這個狀況起因於在未標註的區域變數上,編譯器會使用流程分析來推斷是否可能有空值。 假設這項分析正確,如果發現問題,則這個問題的嚴重性與直接違反空值規格相同。 因此,再次強烈建議將這個問題維持配置為錯誤,不要忽略這些訊息。

將這些問題分組有兩個目的:利用流程分析來記錄已發生給定的問題,以及:考量這個流程分析可能錯誤的情況(因為實作有誤)。 如果確認是實作錯誤,則在異常狀況下可以抑制這種錯誤訊息。

以任何靜態分析的本質而言,流程分析可能看不出不可能有執行路徑和值的某些組合。以變數相關性來舉例說明:

   String flatten(String[] inputs1, String[] inputs2) { 
        StringBuffer sb1 = null, sb2 = null;
        int len = Math.min(inputs1.length, inputs2.length);
        for (int i=0; i<len; i++) {
            if (sb1 == null) {
                sb1 = new StringBuffer();
                sb2 = new StringBuffer();
            }
            sb1.append(inputs1[i]);
            sb2.append(inputs2[i]); // 在這裡警告
        }
        if (sb1 != null) return sb1.append(sb2).toString();
        return "";
    }

編譯器會報告 sb2.append(..) 的呼叫上可能有空值指標存取. 讀者可看到沒有實際的危險,因為 sb1sb2 實際上是以兩個變數皆為空值或兩者皆不為空值的方式產生關聯。 我在問題的那一行,我們知道 sb1 不是空值,因此 sb2 也不是空值。 我們不深入探討相關性分析為何超出 Eclipse Java 編譯器功能的詳細原因,只要記住,這項分析沒有完整定理證明的能力,因此只是悲觀地報告某些問題,而更強大的分析可能將這些問題視為假警報。

如果您要利用流程分析,建議您稍微協助編譯器來「看見」您的結論。 這項協助可以只是將 if (sb1 == null) 分割成兩個獨立的 if,每一個區域變數各一個,只要付出極低的代價,現在編譯器就可確實瞭解狀況,並據以檢查程式碼。 本主題的更多討論如下

未檢查從非標註類型轉換成 @NonNull 類型

這一組問題是基於下列類推方法來論證:在使用 Java 5 通用類型的程式中,呼叫 Java-5 之前的程式庫可能會公開原始類型,也就是通用類型的應用程式(無法指定具體類型引數)。 為了將這些值傳入使用通用類型的程式中,編譯器可以新增隱含轉換,假設類型引數是以程式碼的用戶端部分所預期的方式來指定。 編譯器會對使用這種轉換發出警告,並假設程式庫「做對事」來繼續其類型檢查。 同樣地,程式庫方法未標註的傳回類型可以視為「原始」或「舊式」類型。 再者,隱含轉換可以樂觀地假設預期的規格。 此外會發出警告,而分析會繼續假設程式庫「做對事」。

理論上,需要這種隱含轉換也表示規格違規。 不過,在此情況下,可能是協力廠商程式碼違反我們的程式碼所預期的規格。 或者,可能是(我們本身已確認)某些協力廠商程式碼履行合約,但卻沒有如此宣告(因為沒有使用空值註釋)。 在此情況下,我們可能無法準確修復組織原因的問題。

    @SuppressWarnings("null")
    @NonNull Foo foo = Library.getFoo(); // 隱含轉換
    foo.bar(); 

上述程式碼 Snippet 假設 Library.getFoo() 傳回 Foo 而未指定空值註釋。 我們可以指派給 @NonNull 區域變數,以觸發未檢查轉換的警告,將回覆值整合到標註的程式內。 將對應的 SuppressWarnings("null") 新增至這個宣告,表示我們知道先天的危險,並負責驗證程式庫的行為確實符合預期。

讓程式碼更易於分析的提示

如果流程分析看不到某個實際上不是空值的值,最簡單的策略就是新增以 @NonNull 標註的範圍區域變數。 然後,如果您確信指派給這個區域變數的值在執行時期絕不會是空值,則您可以使用如下的 helper 方法:

    static @NonNull <T> T assertNonNull(@Nullable T value, @Nullable String msg) {
        if (value == null) throw new AssertionError(msg);
        return value;
    }
    @NonNull MyType foo() {
        if (isInitialized()) {
            MyType couldBeNull = getObjectOrNull();
            @NonNull MyType theValue = assertNonNull(couldBeNull, 
                    "value should not be null because application " +
                    "is fully initialized at this point.");
            return theValue;
        }
        return new MyTypeImpl();
    }

請注意,使用上述 assertNonNull() 方法表示您要負責在執行時期一律遵守這項主張。 就算這不是您要的,標註的區域變數仍有助於分析將空值可能進入某位置的範圍和原因縮小。

採用空值註釋的提示

當 JDT 3.8.0 版發行時,仍持續收集採用空值註釋的建議。因此,這項資訊目前在 Eclipse Wiki 中維護。