重訪 C# 空值安全(下):擁抱 Nullable Reference Types

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



上一篇文章討論了 Nullable<T> 實值型別以及各種方便好用的 null 運算子(如 ?.  ??),這些工具讓我們能優雅地處理空值。

但這仍然不夠。因為在 C# 8 以前,參考型別(reference types,如 stringList<T>、自訂類別)預設都是可以為 null 的。這意味著變數隨時可能變成地雷。

直到 C# 8.0 引入了 Nullable Reference Types (NRT),我們終於能讓編譯器幫我們把關。

什麼是 Nullable Reference Types?

簡單來說,NRT 讓編譯器開始能夠區分「可以為 null」和「不可為 null」的參考型別。

啟用 NRT 後(.NET 6+ 專案預設啟用),變數的宣告方式發生了根本性的改變:

string name1;       // 不可為 null (Non-nullable)
string? name2;      // 可為 null (Nullable)

注意這裡的 ?:它現在不僅適用於 int? 等實值型別,也適用於參考型別。

那麼,要如何啟用 NRT 編譯選項呢?

啟用 Nullable Context

早期的 .NET 專案為了向後相容,NRT 選項預設是關閉的。從 .NET 6 開始,則預設啟用。如果要手動啟用或關閉 NRT,可以修改專案檔(.csproj)中的 Nullable 屬性:

<PropertyGroup>
  <Nullable>enable</Nullable>
</PropertyGroup>

或者,也可以只在特定檔案中啟用:

#nullable enable

這種檔案層級的控制讓你可以逐步遷移舊專案,而不需要一次性處理所有警告。

編譯器的警告機制

這不是強制性的 runtime 檢查,而是編譯時期的靜態分析。如果你試圖把 null 塞給宣告為不可為 null 的變數,編譯器會警告你:

string name = GetUserName();  // 假設 GetUserName() 回傳 string?
// 警告:可能將 null 參考指派給不可為 null 的參照

反之,如果你要使用一個宣告為 string? 的變數,編譯器會強迫你先檢查:

void PrintName(string? name)
{
    Console.WriteLine(name.Length); // 警告:name 可能為 null
    
    if (name != null)
    {
        Console.WriteLine(name.Length); // 安全!編譯器知道這裡不會是 null
    }
}

! Null 寬容運算子


有時候你比編譯器更清楚狀況。例如,你知道某個欄位雖然宣告為 non-nullable,但會在依賴注入(dependency injection)階段才被填入值:

public class Service
{
    // 告訴編譯器:閉嘴,我知道這裡預設是 null,但我保證使用前它會有值
    public ILogger Logger { get; set; } = null!; 
}

這就是 !(null-forgiving operator)。請務必謹慎使用,因為這等於是關閉了該處的安全檢查。

模式比對:更現代的 Null 檢查

有了 NRT,我們可以搭配 C# 9+ 的模式比對(pattern matching)寫出更語意化的檢查:

// 傳統寫法
if (user == null) return;

// 現代寫法
if (user is null) return;
if (user is not null) { ... }

這樣的寫法不僅讀起來像英文句子,還能避免被自訂的 == 運算子誤導。

甚至可以結合屬性模式(property pattern):

// 只有當 customer 不為 null,且 Orders 也不為 null,且 Count > 0 時才執行
if (customer is { Orders.Count: > 0 })
{
    ProcessOrers(customer);
}

這比起一連串的 ?. 或 && 檢查要清晰得多。


實戰範例:API 設計

設計 API 時,正確的 Nullable 標註能省去開發者許多猜測的麻煩。透過清楚的標註,呼叫者看一眼方法簽名就知道哪些參數或回傳值可能為 null:

#nullable enable

public class UserService
{
    // 返回值不可為 null
    public User GetUser(int id)
    {
        var user = FindUserInDatabase(id);
        return user ?? throw new UserNotFoundException(id);
    }

    // 返回值可為 null
    public User? FindUser(string email)
    {
        return FindUserByEmail(email);  // 可能找不到
    }

    // 參數不可為 null
    public void UpdateName(User user, string name)
    {
        user.Name = name;  // 編譯器確保 user 和 name 都不會是 null
    }

    // 參數可為 null
    public void UpdateNickname(User user, string? nickname)
    {
        user.Nickname = nickname;  // nickname 允許是 null
    }
}

透過這些明確的標註,API 的呼叫者不需要猜測參數或回傳值是否可能為 null,IDE 會直接給出提示。

與舊程式碼的相容性

NRT 是編譯時期檢查,並不會影響執行時期的行為。這意味著:

  • 現有的程式碼不會因為啟用 NRT 而停止運作。
  • 即使有 NRT 編譯警告,專案仍然可以編譯成功並執行(除非你開啟了「警告視為錯誤」選項)。
  • 你可以逐步導入 NRT,而不需要一次改完整個專案。

建議做法

  1. 新專案採用預設值,也就是在專案層級啟用 NRT。
  2. 舊專案先在個別檔案中啟用 #nullable enable,逐步遷移。

分離標註與警告 Context(進階)

啟用 #nullable enable 實際上做了兩件事:

  1. 啟用標註 context (annotation context):讓編譯器將所有參考型別視為非 nullable,除非加上 ?
  2. 啟用警告 context (warning context):讓編譯器產生 null 安全警告。

你可以分別控制這兩個 context:

#nullable enable annotations    // 只啟用標註,不產生警告
#nullable enable warnings       // 只啟用警告,不改變標註行為

或在專案檔中:

<PropertyGroup>
  <Nullable>annotations</Nullable>  <!-- 或 warnings -->
</PropertyGroup>

實務應用:在遷移大型舊專案時,可以先只啟用 annotation context:

#nullable enable annotations

public class LegacyService
{
    // 明確標註哪些可為 null,作為對外的「契約」
    public User? FindUser(int id) { ... }
    public void UpdateUser(User user) { ... }  // user 不可為 null
    
    // 內部實作可能仍有許多 null 檢查問題,但不會產生警告
}

這讓你的 API 能成為「良好公民」,幫助其他使用你程式碼的專案享受 NRT 的好處,而你自己的專案內部可以逐步處理 null 警告,不需要一次全部修正。

將 Null 警告視為錯誤

對於新專案,建議將 null 警告提升為錯誤,確保程式碼的 null 安全性:

<PropertyGroup>
  <Nullable>enable</Nullable>
  <WarningsAsErrors>CS8600;CS8602;CS8603</WarningsAsErrors>
</PropertyGroup>

常見的 null 相關警告代碼:

  • CS8600: 將 null 字面值或可能的 null 值轉換為不可為 null 的型別
  • CS8602: 可能的 null 參考的取值
  • CS8603: 可能傳回 null 參考

最佳實踐

學會了各種 null 處理技巧後,讓我們整理一些實務上的最佳實踐。這些原則能幫助你寫出更安全、更易維護的程式碼。

1. 優先使用明確的型別標註

// 1. 保證有值(若找不到則拋出異常)
public User GetUser(int id) { ... }

// 2. 可能無值(若找不到則回傳 null)
public User? FindUser(int id) { ... }

2. 盡早檢查,減少 null 擴散

// ✗ null 一路傳遞
public void ProcessOrder(Order? order)
{
    var items = order?.Items;  // items 可能為 null
    var count = items?.Count;  // count 可能為 null
    // ...整個函式都在處理 null
}

// ✓ 提早返回(Guard Clause)
public void ProcessOrder(Order? order)
{
    if (order is null) return;
    
    // 以下程式碼都能假設 order 不是 null
    var items = order.Items;
    var count = items.Count;
}

3. 使用 [NotNull] 和 [MaybeNull] 屬性(進階)

對於無法用 NRT 完整表達的情況,可以使用屬性來提供額外資訊:

using System.Diagnostics.CodeAnalysis;

public bool TryGetUser(int id, [NotNullWhen(true)] out User? user)
{
    user = FindUser(id);
    return user != null;
}

// 使用
if (TryGetUser(123, out var user))
{
    Console.WriteLine(user.Name);  // 編譯器知道這裡 user 不是 null
}

使用 [NotNullWhen(true)] 屬性後,編譯器就能夠了解:當方法回傳 true 時,out 參數絕對不會是 null

4. 善用現代語法組合

將 null 條件運算子、null 聯合運算子和 pattern matching 組合使用,可以寫出既簡潔又安全的程式碼:

// 組合技:?. + ?? + is not null
var name = user?.Profile?.DisplayName ?? user?.Name ?? "Unknown";

if (name is not null)
{
    Console.WriteLine($"Hello, {name}!");
}

這段程式碼展示了現代 C# 的精簡:透過語法組合,我們用一行程式碼就處理了多層 null 檢查和預設值邏輯。如果覺得邏輯密度太高、不易理解,或擔心除錯不方便,亦可拆成兩段:

// 先嘗試取得顯示名稱
string? nameFromProfile = user?.Profile?.DisplayName;

// 再決定最終顯示名稱(Profile 優先,其次 Name,最後 Unknown)
var name = nameFromProfile ?? user?.Name ?? "Unknown";


最後稍微整理一下,要在現代 C# 中徹底解決 Null 問題,請遵循以下原則:

  1. 全面啟用 NRT:新專案務必預設開啟 <Nullable>enable</Nullable>。舊專案可以逐檔遷移(使用 #nullable enable)。
  2. 誠實宣告:如果一個參數可能為 null,就加上 ?。這不僅是給編譯器看的,更是給呼叫者看的 API 契約。
  3. 提早檢查(fail fast):在方法的開頭使用 ArgumentNullException.ThrowIfNull(arg) 來攔截非法值,不要讓 null 在系統中流竄。
  4. 善用組合技?.?? 和模式比對是你的好朋友,用它們來消除巢狀的 if

結語

Null reference 曾經是 C# 開發者的夢魘,但隨著語言的演進,我們已經擁有了一套完整的工具來馴服它。從觀念上的轉變(預設不可為 null),到語法上的支援(各種運算子),我們終於可以自信地寫出更安全、更穩固的程式碼。

希望這上下集的兩篇文章能幫助你重新認識 C# 的空值安全機制。

Keep coding!

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

相關文章

沒有留言:

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