C# 中的 null 包容運算子 “!” —— 概念、由來、用法和注意事項

技術譯民發表於2021-01-12

在 2020 年的最後一天,部落格園發起了一個開源專案:基於 .NET 的部落格引擎 fluss,我抽空把原始碼下載下來看了下,發現在屬性的定義中,有很多地方都用到了 null!如下圖所示

cnblog null

這是什麼用法呢?之前沒有在專案中用過,所以得空就研究了一下。

以前,! 運算子用來表示 “否”,比如不等於 !=。在 C# 8.0 以後,! 運算子有了一個新意義—— null 包容運算子,用來控制型別的可空性。要了解 null 包容運算子,首先就要了解可為 null 的引用型別

可為 null 的引用型別

C# 8.0 引入了可為 null 的引用型別,與可空型別補充值型別的方式一樣,它們以相同的方式補充引用型別。也就是說,通過將 ? 追加到某引用型別,可以將變數宣告為可以為 null 的引用型別。 例如,string? 表示可以為 nullstring。使用這些新型別可以更清楚地表達程式碼設計的意圖 —— 比如將某些變數宣告為 必須始終具有值,而其他一些變數宣告為 可以缺少值

藉助這個定義,我們在定義引用型別的變數或屬性時,便有了兩種選擇:

  1. 假定引用不可以為 null 當變數定義為不可以為 null 時,編譯器會強制執行規則——確保在不檢查它們是否為 null 的前提下,取消引用這些變數是安全的:
    • 變數必須初始化為非 null 值。
    • 變數永遠不能賦值為 null
  2. 假定引用可以為 null 當變數定義為可以為 null 時,編譯器會強制執行不同的規則——確保您自己已正確檢查 null 引用:
    • 只有當編譯器可以保證該值不為 null 時,才可以取消引用該變數。
    • 這些變數可以用預設的 null 值進行初始化,也可以在其他程式碼中賦值為 null

與 C# 8.0 之前對引用變數的處理相比,這個新功能提供了顯著的優勢。在早期版本中,不能通過變數的宣告來確定設計意圖,編譯器沒有為引用型別提供針對 null 引用異常的安全性。

通過新增可為 null 的引用型別,您可以更清楚地宣告您的意圖。null 值是表示一個變數不引用值的正確方法,請不要使用此功能從程式碼中刪除所有的 null 值。而是,應向編譯器和閱讀程式碼的其他開發人員宣告您的意圖。通過宣告意圖,編譯器會在您編寫與該意圖不一致的程式碼時警告您。

是不是讀起來有點繞?還是直接看示例比較容易理解些,請繼續往下看。首先,我們來

啟用可為 null 的引用型別

有三種方法可以啟用可為 null 的引用型別

在專案檔案中啟用

<Nullable>enable</Nullable>

將上面這一行新增到專案檔案中,為當前專案啟用 可為 null 的引用型別,如下圖所示:

nullable enable 1

在自定義專案屬性中啟用

Directory.Build.props 檔案中可以為目錄下的所有專案啟用 可為 null 的引用型別, 下面截圖是 fluss 專案中的設定

cnblog nullable enable 3

使用前處理器指令啟用

可以使用 #nullable enable#nullable disable 前處理器指令在程式碼中的任意位置啟用和禁用 可為 null 的引用型別

nullable enable 2

舉例說明

典型用法

假設有這個定義:

class Person
{
    public string? MiddleName;
}

如下這樣呼叫:

void LogPerson(Person person)
{
    Console.WriteLine(person.MiddleName.Length);  // 警告  CS8602  解引用可能出現空引用。
    Console.WriteLine(person.MiddleName!.Length); // 沒有警告
}

nullable enable warning

這個 ! 運算子其實就是關閉了編譯器的空檢查,它就是在告訴編譯器或者以後維護你程式碼的同事:“我”肯定不是 null ,你不用做 null 安全檢查了。

內部執行機制

使用此運算子告訴編譯器可以安全地訪問可能為 null 的內容。您可以用它來表達在這種情況下“不關心” null 安全性。

當我們討論到 null 安全性時,一個變數可以有兩種狀態:

  1. Nullable : 可以為 null
  2. Non-Nullable :不可以為 null

從 C# 8.0 開始,所有的引用型別預設都是 Non-nullable

“可空性”可以通過以下兩個新的型別運算子進行修改:

  1. ! :從 Nullable 改為 Non-Nullable
  2. ? :從 Non-Nullable 改為 Nullable

這兩個運算子是相互對應的。您使用這兩個運算子限定變數,然後編譯器根據您的限定來確保 null 安全性。

? 運算子的用法

  1. Nullable:string? x;
    • x 是引用型別,因此預設是不可以為 null
    • 我們使用 ? 運算子將其改為可以為 null
    • x = null; 賦值正常,沒有警告。
  2. Non-Nullable:string y;
    • y 是引用型別,因此預設是不可以為 null
    • y = null; 賦值會產生一個警告,因為您給一個宣告為不支援 null 的變數分配了一個 null 值。

如下圖:

nullable enable warning y

! 運算子的用法

string x;
string? y = null;
  1. x = y;
    • 非法!警告:將 null 文字或可能的 null 值轉換為不可為 null 型別(y 可能為 null)。
    • 賦值運算子 = 左邊是不可以為 null,但右邊是可以為 null
  2. x = y!;
    • 合法!
    • 賦值運算子 = 左右兩邊都是不可以為 null
    • 因為 y! 使用了 ! 運算子到 y,使得右邊也變成了不可以為 null,所以賦值沒有問題。

如下圖:

nullable enable warning y 2

⚠️ 警告: null 包容運算子 ! 僅在型別系統級別關閉編譯器檢查;在執行時,該值仍然可能是 null

這是反模式的

C# 程式設計時應該儘量避免使用 null 包容運算子 !

有一些有效的使用場景(在下面會介紹),比如單元測試,使用這個運算子是適合的。不過,在 99% 的情況下,使用替代解決方案會更好。請不要只是為了取消警告,而在程式碼中打幾十個 !。要想清楚您的場景是否真的值得使用它。

? 可以使用,但要小心使用。如果沒有實際的目的或使用場景,請不要使用它。

null 包容運算子 ! 抵消了您獲得的編譯器保證的 null 安全性的作用!

使用 ! 運算子將導致很難發現 bug。如果您定義了一個標記為不可以為 null屬性,您也就假定了可以安全地使用它。但是在執行時,您卻突然遇到 NullReferenceException 異常而撓頭,因為一個值在用 ! 繞過了編譯器檢查後,實際上卻變成了 null,這不是給自己添麻煩嗎?

既然這樣,那麼,

為什麼 ! 運算子會存在?

  • 在某些邊緣情況下,編譯器無法檢測到可以為 null 的值實際上是不為 null 的。
  • 使遺留程式碼庫遷移更容易。
  • 在某些情況下,您根本不關心某些內容是否為 null
  • 在進行單元測試時,您可能想要檢查傳遞 null 時的程式碼行為。

接下來,我們繼續看下:

null! 是什麼意思呢?

null! 是在告訴編譯器 null 不是 null 值,這聽起來很怪異,是不是?

實際上,它和上面例子中的 y! 一樣。它只是看起來挺怪異,因為它將該運算子用在了 null 字面量上,但概念是一樣的。

我們再來看一下文章開頭提到的 fluss 原始碼中的一行程式碼:

/// <summary>
/// 所屬的部落格。
/// </summary>
public BlogSite BlogSite { get; set; } = null!;

這行程式碼定義了一個名稱為 BlogSite、型別為 BlogSite不可以為 null類屬性。因為它是不可以為 null,因此單從技術上講,很明顯它是不可以被賦值為 null的。

但是,您可以通過使用 ! 運算子,將 BlogSite 屬性賦值為 null。因為,就編譯器所關心的 null 安全性而言,null! 不是 null

總結

看到這裡,想必您肯定已經明白了 null! 是什麼意思,也學會了 null 包容運算子 ! 的概念、由來和用法。但是正如我在文中提到的那樣,程式設計時應該儘量避免使用 !,因為它抵消了您本可以獲得的編譯器保證的 null 安全性;而且,這種寫法閱讀起來有點讓人費解。


有朋友說文章內容不太容易看懂,我補充兩張圖幫助理解一下:

C# 8.0之前:

null forgiving operator 1

C# 8.0之後:

null forgiving operator 2


參考:


作者 : 技術譯民
出品 : 技術譯站

相關文章