之前寫過同樣主題的筆記,這次涵蓋 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> 泛型,讓 int、bool、DateTime 等實值型別也能表達「無值」的狀態。語法:簡潔的 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 時代的開發者修煉》,內容有因應部落格文章風格做一些調整和修剪。
沒有留言: