重訪 C# 空值安全(上):更方便優雅的語法

之前寫過同樣主題的筆記,這次涵蓋 C# 14,更完整,也添加更多細節。分為上下兩集,這是上集。


C# 從最初的版本開始,就不斷演進其空值處理機制。從 C# 2.0 的 Nullable Value Types (T?),到各種 null 運算子(???.),再到 C# 8 的 Nullable Reference Types,每一步都是為了讓「空值」這件事變得更明確、可追蹤、可由編譯器檢查


本文要介紹現代 C# 的空值安全機制(null safety),分為上下兩集,涵蓋 C# 14。

這是上集,從基礎觀念與語法糖談起。

為什麼需要空值安全?

在傳統 C# 中,參考型別的變數預設可以是 null

string name = null;  // 完全合法
int length = name.Length;  //  運行時爆炸:NullReferenceException

這種寫法有下列問題:

  • 成本高昂NullReferenceException 長期以來都是 .NET 應用程式中最常見的崩潰原因之一。
  • 隱蔽性強:編譯器無法在編譯時期發現這些問題,只能等到執行時才爆發。
  • 難以追蹤:在大型專案中,null 可能在調用鏈的任何一層出現。

可為 Null 的實值型別


C# 2.0 引入了可為 Null 的實值型別(Nullable Value Types),也就是 System.Nullable<T> 泛型,讓 intboolDateTime 等實值型別也能表達「無值」的狀態。

語法:簡潔的 T?

雖然你可以寫 Nullable<int>,但 C# 提供了更簡潔的語法糖 T?,這也是目前推薦的寫法:

Nullable<int> x;   // 完整寫法,太囉嗦
int? y;            // 簡潔寫法,推薦!


賦值與取值

int? age = null;      // 可以是 null
age = 25;             // 也可以有值

// 取值方式 1:直接使用
if (age == 25) { ... }

// 取值方式 2:透過 Value 屬性
if (age.Value == 25) { ... }  // 注意:若 age 為 null 會拋出異常!

這裡要特別小心:使用 .Value 前,務必先檢查是否為 null(透過 .HasValue 屬性或 != null),否則會拋出 InvalidOperationException

底層結構:Nullable<T>

Nullable<T> 其實是一個簡單的 struct:

public struct Nullable<T> where T : struct
{
    private bool hasValue;  // 是否有值
    private T value;        // 儲存的實際值
    // ...
}

這個簡潔的設計意味著:

  • Nullable<T> 需要一點額外的記憶體空間來儲存 hasValue 旗號,故 int? 會比 int 佔用更多空間。
  • 檢查 HasValue 比檢查 != null 更直接,因為它直接存取內部欄位。
  • 當你寫 x = null 時,編譯器實際上是將 hasValue 設為 false

判斷是否有值

判斷一個 Nullable 變數是否有值,是日常開發中最頻繁的操作:

int? score = null;

// 方法 1:比較運算子
if (score != null) { Console.WriteLine(score); }

// 方法 2:HasValue 屬性(更明確)
if (score.HasValue) { Console.WriteLine(score.Value); }

兩種寫法都可以,但一般而言,HasValue 的語意會比 != null 來得更直觀、明確一點。

與非 Nullable 型別的轉換

從一般實值型別轉換為對應的 Nullable 型別是隱含的(安全的),但反過來則需要明確轉換(因為可能失敗):

// 一般型別 → Nullable:隱含轉換
int i = 10;
int? j = i;

// Nullable → 一般型別:需要明確轉換
int? x = 10;
int y = x;        // ✗ 編譯錯誤!
int z = (int)x;   // ✓ 明確轉換,但若 x 為 null 會在執行時拋出異常

採用明確轉換(explicit conversion)的寫法時,等於是告訴編譯器:「我知道我在做什麼」。當然,你也需要對執行時期的錯誤負責。

Nullable 的運算行為

了解 Nullable 型別的運算規則很重要。首先,任何與 null 的運算,結果都是 null。如以下範例所示:

int? a = null;
int? b = 10;
int? c = a + b;  // c 是 null
int? d = a * b;  // d 也是 null

這符合數學上的直覺:如果你不知道其中一個變數的值,那麼運算結果自然也是未知的。這個行為稱為「null 傳播」(null propagation):只要運算式中有一個運算元是 null,整個結果就是 null。

其次,Nullable 與一般型別可以混合運算,但結果仍是 Nullable:

int? m = 10;
int n = 5;
int? result = m * n;  // result = 50(型別是 int?)

即使運算元中包含非 Nullable 的型別,只要有一個是 Nullable,結果就會自動提升為 Nullable。

即使 m 有值而 n 是一般型別,編譯器仍會將結果視為 int?,以保持型別的一致性與安全性。


寫出優雅的 Null 處理邏輯

為了讓程式碼更簡潔且安全,C# 提供了多種專門針對 null 處理的運算子,包括:

  • Null 聯合運算子(??:用於提供預設值,避免 null 傳遞。
  • Null 聯合指派運算子(??=:可簡化延遲初始化與預設值指派的寫法。
  • Null 條件運算子(?. 和 ?[]:讓你能安全地存取物件成員或索引,即使物件本身為 null 也不會拋出例外。
  • Null 條件指派運算子(C# 14):進一步簡化「只有在物件不為 null 時才賦值」的情境。

本節將逐一介紹這些運算子的語法、使用時機與實務範例,並說明它們如何協助你寫出更穩固、易維護的現代 C# 程式碼。

Null 聯合運算子(??

Null 聯合運算子(null-coalescing operator)是最常用的運算子之一,寫法為兩個問號 ?? 。它的語意很直白:「如果左邊不是 null,就用左邊;否則用右邊。」

範例:

// 提供預設值
string name = GetUserName() ?? "訪客";

這行程式碼等同於:

var temp = GetUserName();
string name = (GetUserName() != null) ? temp : "訪客";


Null 聯合指派運算子(??=

Null 聯合指派運算子(null-coalescing assignment operator)是 C# 8 引入的語法糖,專門處理「如果變數是 null,就給它賦值」的情境,常用於延遲初始化

範例:

private List<string>? _cache;

public List<string> GetCache()
{
    _cache ??= LoadFromDatabase();  // 第一次調用才載入
    return _cache;
} 
這段程式碼的意思是:只有當 _cache  null 的時候,才會執行 LoadFromDatabase() 並將結果指派給它;如果 _cache 已經有值,則直接回傳。這是一種簡潔的 singleton(單例模式)與快取實作方式,既保證了延遲載入的效能,程式碼又容易閱讀。

Null 條件運算子(?.

Null 條件運算子(conditional operator)大概是讓 C# 開發者最「有感」的改進(又稱貓王運算子)。有了它,我們可以優雅地處理深層物件存取:

// 只要 customer 或 Order 為 null,結果就是 null
int? orderId = customer?.Order?.Id;

只要鏈條中任何一個環節是 null,整個運算式就會立即短路(short-circuit)並回傳 null,而不會拋出異常。


搭配索引子(?[]

string firstItem = list?[0];  // 如果 list 為 null,結果就是 null

鏈式調用(短路行為)

Null 條件運算子搭配串接寫法也是安全的,例如:

string? Convert(object? obj)
{
    return obj?.ToString().ToUpper();
}

說明:

  • 若 obj 為 null 便會立即返回,無論右邊串接了多少方法或屬性都無關。因此,也有人以「短路」來形容這種行為。
  • 這裡的 ToString() 保證不會回傳 null,所以可以直接呼叫 .ToUpper()

一旦熟悉此語法,我們可以用更少的程式碼來處理更多複雜邏輯,同時也能避免執行時期的 null 參考錯誤。例如:

int? orderCount = customer?.Orders?.Count();

即使 customer 或 Orders 為 null,程式也不會崩潰,而是優雅地返回 null

如果不使用 ?.,你得這樣寫:

int? orderCount = customer == null ? 
   null : (customer.Orders == null ? null : customer.Orders.Count());

提醒:請注意這些範例中,用來接收結果的變數都是宣告為 nullable(如 int?char?)。這是必須的,因為回傳的結果有可能是 null。

Null 條件指派(C# 14 新功能)

C# 14 進一步擴展了 ?. 和 ?[] 運算子的功能,現在它們可以出現在指派運算子的左側。這讓你能夠更簡潔地處理「只有當物件不是 null 時才賦值」的情境。

以往的寫法需要先做 null 檢查:

// C# 13 及更早版本
if (customer is not null)
{
    customer.Order = GetCurrentOrder();
}

現在可以直接這樣寫:

// C# 14:null-conditional assignment
customer?.Order = GetCurrentOrder();

這行程式碼的意思是:「只有當 customer 不是 null 時,才將 GetCurrentOrder() 的結果指派給 customer.Order。」

重要細節:右側的運算式只有在左側不是 null 時才會被求值。這意味著如果 customer 是 null,GetCurrentOrder() 根本不會被呼叫。

此功能也支援複合指派運算子:

// 也可以搭配 += 等複合指派
customer?.Points += 100;  // 只有 customer 不是 null 時才加分

// ✗ 以下會編譯錯誤:
counter?.Value--;         // 錯誤:遞增/遞減運算子不能搭配 ?. 使用

注意: -- 和 ++ 運算子(無論前綴或後綴)都不能與 null 條件指派(null-conditional assignment)一起使用。

組合技:現代 C# 的建議寫法

我們可以把上述運算子組合起來,寫出既安全又具備預設值的強大邏輯:

// 若 user 為 null,或 Name 為 null,則回傳 "匿名"
string displayName = user?.Name ?? "匿名";

// 讀取設定,如果中間任何物件為 null,就使用預設值 128
int bufferSize = config?.Settings?.BufferSize ?? 128;

下回預告

有了這些強大的運算子,處理 Null 確實方便多了。但真正的治本之道,是讓編譯器幫我們預防 Null 的產生。

下一篇文章將深入探討 C# 8.0 最具革命性的功能——Nullable Reference Types (NRT),看看如何讓編譯器成為你的空值安全守門員。

Keep learning!

本文摘錄自《現代 C#:AI 時代的開發者修煉》,內容有因應部落格文章風格做一些調整和修剪。

相關文章

沒有留言:

技術提供:Blogger.
回頂端⬆️