Tip
剛開始開發軟體嗎? 先從 入門 教學開始。
有其他語言的經驗嗎? 如果你用過 Kotlin 的可空型、TypeScript strictNullChecks或 Swift 的選用值,這個模型應該很熟悉。 C# 使用靜態分析與警告診斷,而非獨立型別。 快速瀏覽 使用註解表達意圖 和 空態分析,然後前往 教學課程:使用可為 Null 和不可為 Null 的參考型別表達您的設計意圖,以實際應用這項功能。
可空參考型別 是一組功能,用來最小化程式碼拋 System.NullReferenceException棄 的機率。 你宣告哪些變數是預定要保留 null 、哪些不是,編譯器會在這些宣告與程式碼使用方式不符時發出警告。 程式的執行時行為沒有改變。 可空的參考型別完全是編譯時的功能。
三個基礎組件協同運作:
-
變數註解 (
string與string?)表達哪些參考是要允許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);
}
}
註解不會改變執行時類型。
string 和 string? 兩者皆為 System.String。 它 ? 會告知編譯者你的設計意圖。 這個意圖塑造了編譯器所產生的警告:
- 不可空變數的預設空狀態為非空。 如果您指派的值可能為
null,編譯器就會提出警告。 - 可空變數的預設空狀態為可能空。 如果你在未先檢查變數的情況下取消引用,編譯器會警告。
使用註解讓型別系統中顯示必要值與選用值。 以下 Person 類型將 FirstName 和 LastName 宣告為不可為 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 的實作。 由於 FirstName 和 LastName 不可空,覆寫直接在 插值字串 ( $"..." 將表達式嵌入佔位符的 {} 語法)中使用它們,且不需空檢查。
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);
}
在前述範例中,第一次解除參考時會發出警告,因為 message 是 maybe-null。 在指派為非 null 常值後,編譯器知道 message不是 null,因此第二次解參考是安全的。
空態分析涵蓋 if 檢查、 模式匹配 (如 is null 或 is { } 等表達式,用以測試值的形狀)以及循環或提前返回的控制流程:
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。 這兩種模式都是靜態分析的限制,而非程式碼的錯誤。
預設結構
你可以透過使用 default 或 new()建立一個包含不可空參考欄位的結構體。 此方法使結構體的欄位未初始化:
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())讓完整初始化更簡潔。