可為空的參考類型

Tip

剛開始開發軟體嗎? 先從 入門 教學開始。

有其他語言的經驗嗎? 如果你用過 Kotlin 的可空型、TypeScript strictNullChecks或 Swift 的選用值,這個模型應該很熟悉。 C# 使用靜態分析與警告診斷,而非獨立型別。 快速瀏覽 使用註解表達意圖空態分析,然後前往 教學課程:使用可為 Null 和不可為 Null 的參考型別表達您的設計意圖,以實際應用這項功能。

可空參考型別 是一組功能,用來最小化程式碼拋 System.NullReferenceException棄 的機率。 你宣告哪些變數是預定要保留 null 、哪些不是,編譯器會在這些宣告與程式碼使用方式不符時發出警告。 程式的執行時行為沒有改變。 可空的參考型別完全是編譯時的功能。

三個基礎組件協同運作:

  • 變數註解stringstring?)表達哪些參考是要允許 null
  • 空狀態分析 會追蹤運算式的值在程式碼中的每個位置是否為 非空可能為空
  • API 上的屬性描述更細緻的契約,例如「此參數可以是 null,但回傳值只有在參數為空時才為空。」

編譯器會將這些訊號結合起來產生診斷結果。 非可空變數出現警告表示該變數可能會收到 null。 對可空變數的警告意味著程式碼可能會在沒有空檢查的情況下 去參照 該變數。 解參考是指使用變數所指向的值。 例如,對其呼叫方法(variable.Method())、讀取屬性(variable.Property),或對其進行索引存取(variable[0])。 對值為 null 的變數進行解除參考時,會在執行階段擲回例外狀況。 不論哪種警告,都表示程式碼的行為與其設計不符。

可空值上下文

從近期.NET範本建立的專案會在專案檔案中設定為<Nullable>enable</Nullable>,因此本文的指引依照原文適用。 如果您使用的是較舊的專案,請開啟 .csproj,並檢查 <PropertyGroup> 是否包含以下這一行;如果沒有,請加入:

<Nullable>enable</Nullable>

欲了解更多關於遷移大型應用程式的資訊,請參閱可 空除遷移策略 的文章,了解更多設定與指令。

用註解表達意圖

每個參考型態變數預設為 不可空 。 附加 ? 以宣告 可空的 參考型別:

public static void Annotations()
{
    string required = "always set";   // non-nullable: assigning null produces a warning
    string? optional = null;          // nullable: holding null is allowed

    Console.WriteLine(required.Length);

    if (optional is not null)
    {
        Console.WriteLine(optional.Length);
    }
}

註解不會改變執行時類型。 stringstring? 兩者皆為 System.String。 它 ? 會告知編譯者你的設計意圖。 這個意圖塑造了編譯器所產生的警告:

  • 不可空變數的預設空狀態非空。 如果您指派的值可能為 null,編譯器就會提出警告。
  • 可空變數的預設空狀態可能空。 如果你在未先檢查變數的情況下取消引用,編譯器會警告。

使用註解讓型別系統中顯示必要值與選用值。 以下 Person 類型將 FirstNameLastName 宣告為不可為 null——每個人都必須擁有這兩者——並將 MiddleName 宣告為可為 null,因為不是每個人都有。

public sealed class Person(string firstName, string lastName)
{
    public string FirstName { get; } = firstName;
    public string? MiddleName { get; init; }
    public string LastName { get; } = lastName;

    public override string ToString() => MiddleName is null
        ? $"{FirstName} {LastName}"
        : $"{FirstName} {MiddleName} {LastName}";
}

public static void DesignIntent()
{
    Person p1 = new("Ada", "Lovelace") { MiddleName = "King" };
    Console.WriteLine(p1);
    // Output: Ada King Lovelace

    Person p2 = new("Grace", "Hopper");
    Console.WriteLine(p2);
    // Output: Grace Hopper
}

註解驅動了 ToString 的實作。 由於 FirstNameLastName 不可空,覆寫直接在 插值字串$"..." 將表達式嵌入佔位符的 {} 語法)中使用它們,且不需空檢查。 MiddleName 可為 null,因此覆寫會先將其與 null 比較,並且只在其存在時才將它包含進去。 編譯器會強制區分兩者:如果程式碼在預期應為不可為 Null 的位置傳入可能為 Null 的值,就會發出警告;而如果建構函式未初始化不可為 Null 的成員,也會產生警告。

空狀態分析

編譯器會追蹤每個表達式的 空狀態 。 該狀態有以下兩種數值之一:

  • 非零:表達式已知不是 null
  • 可能-零:表達式可能是 null

當編譯器分析你的程式碼時,局部變數的空狀態會被更新。 有兩件事會改變: 指派空檢查。 賦值後,變數的空狀態與右側的表達式相符。 若表達式為空或可空,變數即為可能空。 若表達式為非空值,變數即非空。 在進行空檢查後,變數的空狀態會反映所採取的分支。

public static void NullStateTracking()
{
    string? message = null;

    // Warning: dereference of a possibly null reference.
    Console.WriteLine(message.Length);

    message = "Hello, World!";

    // No warning: the compiler tracks that message is now not-null.
    Console.WriteLine(message.Length);
}

在前述範例中,第一次解除參考時會發出警告,因為 messagemaybe-null。 在指派為非 null 常值後,編譯器知道 message不是 null,因此第二次解參考是安全的。

空態分析涵蓋 if 檢查、 模式匹配 (如 is nullis { } 等表達式,用以測試值的形狀)以及循環或提前返回的控制流程:

 public sealed class Node(string name)
 {
     public string Name { get; } = name;
     public Node? Parent { get; init; }
 }

 public static void FlowAnalysis(Node start)
 {
     Node? current = start;
     while (current is not null)
     {
         // Inside the loop, the compiler knows current is not-null.
         Console.WriteLine(current.Name);

         current = current.Parent;
     }
}

分析不會直接延伸到方法本身。 如果你需要一個方法向呼叫者傳達空狀態,可以在其簽名中使用 可空的分析屬性

使用 ! 覆寫警告

有時候你知道的比編譯器還多。 null 寬容運算子!會宣告某個運算式不是 Null,即使分析結果並非如此:

public static void NullForgiving()
{
    // "ada" matches a switch arm that returns a non-null string,
    // but the return type is string? so the compiler treats the
    // result as maybe-null.
    string? maybeName = LookUpName("ada");

    // The ! tells the compiler "trust me, this isn't null." We just
    // passed "ada", which the switch maps to "Ada Lovelace".
    int length = maybeName!.Length;
    Console.WriteLine(length); // => 12
}

// Returns string? because the wildcard arm yields null.
private static string? LookUpName(string id) => id switch
{
    "ada" => "Ada Lovelace",
    _ => null,
};

謹慎地使用 !。 每次出現這種情況,都是編譯器再也無法保護你的地方。 建議加入空檢查、重組程式碼,或註解相關 API,讓編譯器自行得出正確結論。

描述 API 合約的屬性

參數或回傳類型的註解並不總是足夠有表達力。 方法可能接受可能為空的參數,但保證結果非空。 測試方法可能只有在其參數非空時才回傳 true 。 利用 可空分析屬性 來傳達這些契約:

public static bool IsPresent([NotNullWhen(true)] string? value) =>
    !string.IsNullOrEmpty(value);

public static void NullAnalysisAttributes()
{
    string? input = ReadInput();

    if (IsPresent(input))
    {
        // No null-forgiving operator needed: the attribute tells the compiler
        // input is not-null when IsPresent returns true.
        Console.WriteLine(input.Length);
    }
}

private static string? ReadInput() => "hello";

NotNullWhenAttribute 參數告訴編譯器,當 IsPresent 返回 true時,該參數 非空。 在 if 區塊內,編譯器會將 value 視為非 Null,不需要使用 Null 寬容運算子。 自 .NET 5 起,所有 .NET 執行時 API 皆有註解,因此分析對呼叫它們的程式碼有利。

已知陷阱

有兩種模式可能會讓不可為 Null 的參考在沒有警告的情況下持有 null。 這兩種模式都是靜態分析的限制,而非程式碼的錯誤。

預設結構

你可以透過使用 defaultnew()建立一個包含不可空參考欄位的結構體。 此方法使結構體的欄位未初始化:

public struct Student
{
    public string FirstName;
    public string? MiddleName;
    public string LastName;
}

public static void DefaultStructPitfall()
{
    Student s = default;            // No warning, but FirstName and LastName are null.
    Console.WriteLine(s.FirstName?.Length ?? -1);
}

欄位在執行階段會保存 null,但編譯器不會發出警告。 如果你必須使用結構體,建議偏好 required member,即呼叫者必須透過物件初始化器初始化的成員,或是呼叫者必須呼叫的參數化建構子。

參考與結構體陣列

一個新的非空參考型態陣列包含所有 null 元素,直到你指派每個元素:

public static void ArrayPitfall()
{
    string[] values = new string[3];      // Elements are null at run time.
    Console.WriteLine(values[0]?.Length ?? -1);

    string[] initialized = ["a", "b", "c"]; // Collection expression initializes every slot.
    Console.WriteLine(initialized[0].Length);
}

同樣的陷阱也適用於結構體陣列:每個元素都從結構體的預設值開始,因此每個元素的不可空參考欄位都從 開始。null

在建立陣列時,先初始化陣列元素。 集合運算式[1, 2, 3] 常值語法)與 目標型別推斷的 new(在編譯器可推斷型別時寫成 new())讓完整初始化更簡潔。