可為 Null 的參考型別
可為 Null 的參考型別 是一組功能,能將程式碼在執行階段引發異常的可能性降至最低 System.NullReferenceException。 這三項功能可協助您避免這些例外狀況,其中之一包括將參考型別明確標示為可為 null的功能。
- 改善的靜態流程分析可判斷變數在反參考之前是否可能為
null
。 - 標註 API 的屬性,讓流程分析可以判斷 Null 狀態。
- 變數註釋,開發人員用來明確宣告變數預期的 Null 狀態。
編譯器會在編譯過程中追蹤您程式碼中每個運算式的 Null 狀態。 null 狀態 有兩個值之一:
-
非空:已知運算式為非
null
。 -
maybe-null: 運算式可能是
null
。
變數註釋會決定參考型別變數的 可空性:
-
「不可為 Null」: 如果您將
null
值或 「可能為 Null」 運算式指派給變數,編譯程式會發出警告。 非 null 的變數預設的 null 狀態為非 null。 -
「可為 Null」: 您可以將
null
值或 「可能為 Null」 運算式指派給變數。 當變數的 Nll 狀態是 「可能為 Null」 時,如果您反參考變數,編譯器就會發出警告。 變數的預設 Null 狀態為 「可能為 Null」。
本文的其餘部分說明這三個功能範疇如何在程式碼可能要解除參考null
值時產生警告。 使用 .
(點) 運算子來解參考變數,即存取其一個成員,如下列範例所示:
string message = "Hello, World!";
int length = message.Length; // dereferencing "message"
當您解參考值為 null
的變數時,執行階段會拋出 System.NullReferenceException。
同樣地,當使用[]
表示法來存取某個物件的成員,而該物件是null
的時候,可能會產生警告。
using System;
public class Collection<T>
{
private T[] array = new T[100];
public T this[int index]
{
get => array[index];
set => array[index] = value;
}
}
public static void Main()
{
Collection<int> c = default;
c[10] = 1; // CS8602: Possible dereference of null
}
您將了解:
- 編譯器的 Null 狀態分析: 編譯器如何判斷運算式為「非 Null」還是「可能為 Null」。
- 套用至 API 的屬性 為編譯器的 Null 狀態分析提供更多內容。
- 可空性變數註釋 提供有關變數意圖的資訊。 批註適用於欄位、參數和傳回值,以設定預設 Null 狀態。
- 管理 泛型型別引數 的規則。 新增了新的條件約束,因為型別參數可以是參考型別或實值型別。
?
字尾會針對可為 Null 的實值型別和可為 Null 的參考型別以不同的方式實現。 - 可空值的上下文 能協助您遷移大型專案。 您可以在應用程式的部分中,在進行遷移時於可為 Null 的上下文中啟用警告和註解。 解決更多警告之後,您可以啟用整個專案的這兩個設定。
最後,您會了解 struct
類型和陣列中的 Null 狀態分析常見陷阱。
您也可以在我們的 Learn 模組課程中探索 C# 的可空性安全性概念。
空狀態分析
空狀態分析 會追蹤參考的 空狀態。 運算式為 非空 或 可能為空。 編譯器會以兩種方式判斷變數是否為不是 Null:
- 變數已指派了一個確定為非空的值。
- 變數已針對
null
進行檢查,且自該檢查以後未曾指派。
編譯程式無法判斷為非 null 的任何變數都會被視為 可能為 null。 此分析會在您不小心反參考 null
值的情況下提供警告。 編譯器會根據 Null 狀態產生警告。
- 當變數 非空時,該變數可以安全地解參照。
- 當變數為可能是 null 時,必須檢查該變數,以確保它在反參考之前不是
null
。
請考慮下列範例:
string? message = null;
// warning: dereference null.
Console.WriteLine($"The length of the message is {message.Length}");
var originalMessage = message;
message = "Hello, World!";
// No warning. Analysis determined "message" is not-null.
Console.WriteLine($"The length of the message is {message.Length}");
// warning!
Console.WriteLine(originalMessage.Length);
在上述範例中,編譯器會在列印第一則訊息時判斷 message
是否為可能是 null。 第二則訊息則不會產生警告。 最後一行程式碼會產生警告,因為 originalMessage
可能是 null。 下列範例示範更實用的用法,讓您可以遍歷節點樹狀結構到根節點,並在遍歷過程中處理每個節點:
void FindRoot(Node node, Action<Node> processNode)
{
for (var current = node; current != null; current = current.Parent)
{
processNode(current);
}
}
先前的程式碼不會針對反參考變數 current
產生任何警告。 靜態分析顯示當 current
為可能是 null 時不會被解引用。 檢查變數 current
是否與 null
一致,然後在存取 current.Parent
之前,以及將 current
傳遞給 ProcessNode
動作之前。 先前的範例展示編譯器在初始化、指派或與 null
比較時,如何判斷區域變數的null-state。
空值狀態分析不會追蹤已呼叫的方法。 因此,所有建構函式所呼叫之通用協助程式方法中初始化的欄位可能會產生具有下列訊息的警告:
結束建構函式時,不可為 Null 的屬性 'name' 必須含有不是 null 的值。
您可以使用下列兩種方式之一來解決這些警告:建構函式鏈結,或輔助方法上的可為 null 的屬性。 下列程式碼將示範各項作業。
Person
類別使用其他所有建構函式呼叫的通用建構函式。
Student
類別具有標註 System.Diagnostics.CodeAnalysis.MemberNotNullAttribute 屬性的協助程式方法:
using System.Diagnostics.CodeAnalysis;
public class Person
{
public string FirstName { get; set; }
public string LastName { get; set; }
public Person(string firstName, string lastName)
{
FirstName = firstName;
LastName = lastName;
}
public Person() : this("John", "Doe") { }
}
public class Student : Person
{
public string Major { get; set; }
public Student(string firstName, string lastName, string major)
: base(firstName, lastName)
{
SetMajor(major);
}
public Student(string firstName, string lastName) :
base(firstName, lastName)
{
SetMajor();
}
public Student()
{
SetMajor();
}
[MemberNotNull(nameof(Major))]
private void SetMajor(string? major = default)
{
Major = major ?? "Undeclared";
}
}
可空狀態分析和編譯器產生的警告可協助您避免藉由解參考null
所造成的程式錯誤。
解決可為 Null 警告 這篇文章提供用於更正您程式碼中可能出現的警告的技術。 從空狀態分析中產生的診斷僅為警告。
API 簽章的屬性
Null 狀態分析需要開發人員提供提示,才能了解 API 的語意。 某些 API 會提供 Null 檢查,而且應該將變數的 Null 狀態從可能是 null 變更為不是 null。 其他 API 根據輸入引數的null 狀態,返回不為 null或可能為 null的運算式。 例如,請考慮下列以大寫顯示訊息的程式碼:
void PrintMessageUpper(string? message)
{
if (!IsNull(message))
{
Console.WriteLine($"{DateTime.Now}: {message.ToUpper()}");
}
}
bool IsNull(string? s) => s == null;
根據檢查,任何開發人員都會考慮此程式碼安全,且不應產生警告。 不過,編譯器並不知道 IsNull
會提供 Null 檢查,因此將 message
視為可能為 Null 的變數,對 message.ToUpper()
陳述式發出警告。 使用 NotNullWhen
屬性來修正此警告:
bool IsNull([NotNullWhen(false)] string? s) => s == null;
這個屬性會通知編譯器,如果 IsNull
傳回 false
,則參數 s
不是 Null。 編譯器會將 null-state 變更為 not-null,這個變化發生在 if (!IsNull(message)) {...}
區塊內的 message
。 不會發出任何警告。
屬性提供關於參數、返回值以及用於調用成員的物件實例的成員的空值狀態的詳細資訊。 如需每個屬性的詳細資訊,請參閱有關可為 null 參考屬性的語言參考文章。 自 .NET 5 起,所有 .NET 執行階段 API 都會加上註釋。 您可以藉由標註 API 來改善靜態分析,以提供引數和傳回值的 Null 狀態語意資訊。
可為 Null 的變數註釋
Null 狀態分析可為區域變數提供穩健的分析。 編譯器需要您提供成員變數的更多資訊。 編譯器需要更多資訊,才能在成員的左括弧中設定所有欄位的 Null 狀態。 任何可存取的建構函式都可以用來初始化物件。 如果成員欄位可能設定為 null
,編譯器必須在每個方法的開頭假設其 null 狀態為可能是 null。
您可以使用註釋來宣告變數是可為 Null 的參考型別還是不可為 Null 的參考型別。 這些註釋為變數的 null 狀態提供重要說明:
-
參考不應為 Null。 不可為 Null 參考變數的預設狀態為 「非 Null」。 編譯器會實施規則,確保對這些變數取值 (Dereference) 的過程是安全的,而不會事先檢查這些變數是不是 Null:
- 變數必須初始化為非 Null 值。
- 變數永遠不可指派
null
值。 當程式碼將可能是 Null 運算式指派給不應該是 Null 的變數時,編譯器會發出警告。
-
參考可能為 Null。 可為 Null 參考變數的預設狀態為可能是 null。 編譯器會強制執行規則,以確保您已正確檢查
null
參考:- 只有在編譯器可以保證變數的值不是
null
時,才能解引用該變數。 - 這些變數可以使用預設
null
值初始化,而且可以在其他程式代碼中指派值null
。 - 當程式碼將 「可能是 Null」 運算式指派給可能為 Null 的變數時,編譯器不會發出警告。
- 只有在編譯器可以保證變數的值不是
任何不可為 Null 的參考變數,其初始的 null 狀態是 not-null。 任何可為 Null 的參考變數都具有「可能是 Null」 的初始 Null 狀態。
可為 Null 參考型別的語法與可為 Null 實值型別語法相同:即在變數的型別後附加?
。 例如,下列變數宣告代表可為 null 的字串變數,name
:
string? name;
啟用可為 Null 的參考型別時,任何型別名稱未附加 ?
的變數,都是 不可為 Null 的參考型別。 這包含您啟用此功能時現有程式碼中所有的參考型別變數。 不過,任何使用 var
宣告的隱式型別區域變數都是可為 Null 的參考型別。 如前幾節所示,靜態分析會判斷區域變數的 Null 狀態,以在確認它們在反參考前是否為「可能是 Null」。
有時候,當您明知變數不為空,但編譯器卻判斷其空值狀態為可能為空時,您必須忽略警告。 您可以在變數名稱後面使用 null 容許運算子!
,強制 null 狀態成為不是 null。 例如,若您知道 name
變數並非為 null
,但編譯器卻發出警告,您可以撰寫下列程式碼來覆寫編譯器的分析:
name!.Length;
可為 Null 的參考型別和可為 Null 的實值型別提供類似的語意概念: 變數可以代表值或物件,或該變數可能是 null
。 不過,可為 Null 的引用型別和可為 Null 的值型別以不同方式實現:可為 Null 的值型別是使用 System.Nullable<T> 實現,而可為 Null 的引用型別是通過編譯器讀取的屬性來實現。 例如,string?
和 string
都以相同的型別表示:System.String。 不過,int?
和 int
分別以 System.Nullable<System.Int32>
和 System.Int32 表示。
可為 Null 的參考型別是編譯時功能。 這表示呼叫端可能會忽略警告,並刻意使用 null
作為預期不可為 Null 參考的方法引數。 程式庫作者應該納入空值引數的執行階段檢查。
ArgumentNullException.ThrowIfNull 是在執行階段檢查參數是否為 null 的首選方法。 此外,若移除所有可為 Null 的註釋(?
和 !
),則程式的運行行為保持不變。 其唯一目的是表達設計意圖,並提供 Null 狀態分析的資訊。
重要
啟用可空性註釋可以變更 Entity Framework Core 判斷是否需要資料成員的方式。 如需詳細資訊,請參閱 Entity Framework Core 基本概念:使用可為 Null 的參考型別一文。
泛型
泛型需要詳細的規則來處理 T?
,以適用於任何型別參數 T
。 因為歷史以及可為 Null 的實值型別與可為 Null 的參考型別的不同實作方式,規則必須詳細說明。
可為 null 實值型別是透過使用System.Nullable<T>結構來實作的。
可為 null 參考型別被作為向編譯器提供語意規則的型別註釋來實現。
- 如果
T
的型別引數是引用類型,則T?
參考相應的可空引用類型。 例如,如果T
是string
,則T?
為string?
。 - 如果
T
型別引數是值類型,則T?
會參考相同的值類型T
。 例如,如果T
是int
,則T?
也是int
。 - 如果
T
的型別引數是可為 null 參考型別,則T?
會參考相同的可為 Null 參考型別。 例如,如果T
是string?
,則T?
也是string?
。 - 如果
T
的型別引數是可為 null 實值型別,則T?
會參考相同的可為 Null 實值型別。 例如,如果T
是int?
,則T?
也是int?
。
若為傳回值,T?
相當於 [MaybeNull]T
;若為引數值,T?
相當於 [AllowNull]T
。 如需詳細資訊,請參閱語言參考中的 Null 狀態分析的屬性一文。
您可以使用條件約束來指定不同的行為:
-
class
條件約束表示T
必須是不可為 Null 的參考型別 (例如string
)。 如果您使用可為 Null 的參考類型,例如將T
用於string?
,編譯器會產生警告。 -
class?
條件約束表示T
必須是參考型別,不論是不可為 Null (string
) 還是可為 Null 的參考型別 (例如string?
)。 當型別參數是可為 Null 的參考型別 (例如string?
) 時,T?
的運算式會參考該相同的可為 null 參考型別,例如string?
。 -
notnull
條件約束表示T
必須是不可為 Null 的參考型別或不可為 Null 的實值型別。 如果您在型別參數中使用可為 Null 的參考型別或可為 Null 的實值型別,編譯器會產生警告。 此外,當T
是實值型別時,傳回值就是該實值型別,而不是對應的可為 Null 實值型別。
這些限制式有助於將如何使用 T
的詳細資訊提供給編譯器。 這可協助開發人員選擇 T
的型別,並在使用泛型型別的執行個體時提供更好的 null 狀態分析。
可空值上下文
可為 Null 的上下文 會決定如何處理可為 Null 的參考型別註釋,以及靜態 Null 狀態分析會產生哪些警告。 可空性上下文包含兩個標誌:註釋設定和警告設定。
預設情況下,現有項目的 批註 與 警告 設定都是停用的。 從 .NET 6 (C# 10 開始,預設會針對 新的 項目啟用這兩個旗標。 可為 Null 環境設定兩個不同旗標的原因在於,使遷移在引入可為 Null 參考型別之前的現有大型專案更為容易。
針對小型專案,您可以啟用可為 Null 的參考型別、修正警告並繼續。 不過,對於較大的專案和多項目解決方案,可能會產生大量的警告。 您可以逐檔案使用 pragmas 來啟用可為 Null 的參考型別,當您開始使用這些型別時。 在現有的程式碼基底中開啟防止擲回 System.NullReferenceException 的新功能時,可能會發生干擾:
- 所有明確定義型別的參考變數都會被視為不可為 Null 的參考型別。
- 泛型中
class
條件約束的意義已變更為表示不可為 Null 的參考型別。 - 由於有這些新規則,因此產生新的警告。
可為 Null 註釋的環境決定編譯器的行為。 可為 Null 的上下文 設定有四種可能的組合:
-
雙方皆停用:程式碼忽略可空性。
停用 與啟用可空參考型別之前的行為相符,但新語法將產生警告而非錯誤。
- 系統會停用可為 Null 的警告。
- 所有參考型別變數都是可為 Null 的參考型別。
- 使用
?
尾碼來宣告可空參照型別會產生警告。 - 您可以使用 null 容許運算子
!
,但沒有任何作用。
-
同時啟用:編譯器會啟用所有 null 參考分析和所有語言功能。
- 所有新的可為 Null 的警告都已啟用。
- 您可以使用
?
尾碼來宣告可空的參考型別。 - 沒有
?
尾碼的參考型別變數是非空引用型別。 - 消除空值檢查運算子會抑制可能解引用
null
的警告。
- 啟用 警告:編譯程式會執行所有空值分析,並在程式碼可能解參考
null
時發出警告。- 所有新的可為 Null 警告都會啟用。
- 使用
?
尾碼來宣告可為 Null 的參考型別會產生警告。 - 所有參考型別變數都允許為 Null。 不過,除非使用
?
尾碼宣告,否則成員在所有方法起始大括弧時的空值狀態均為非空。 - 您可以使用 null 释疑运算子
!
。
-
註解啟用:編譯器在程式碼可能解參考
null
,或將可能為 null 的表達式指派給不可為 Null 的變數時,不會發出警告。- 系統會停用所有新的可為 Null 警告。
- 您可以使用
?
尾碼來宣告可為 Null 的參考型別。 - 沒有
?
尾碼的參考型別變數是不可為 Null 的參考型別。 - 您可以使用 null 容許運算子
!
,但這樣做沒有任何效果。
專案的可為 Null 註釋內容和可為 Null 警告內容都可在您的 .csproj 檔案中使用 <Nullable>
元素 來設定。 此元素會設定編譯器解譯型別可 NULL 性的方式及所發出的警告。 下表顯示允許的值,並摘要說明它們所指定的內容。
上下文 | 取消參考警告 | 任務分配警告 | 參考型別 |
? 尾碼 |
! 運算子 |
---|---|---|---|---|---|
disable |
停用 | 停用 | 全部都是可為空的 | 產生警告 | 沒有作用 |
enable |
啟用 | 啟用 | 除非用? 宣告,否則不可為 Null。 |
宣告可為空值型別 | 針對可能的 null 賦值隱藏警告 |
warnings |
啟用 | 不適用 | 所有皆可為 Null,但在方法開頭的左大括弧處,成員會被視作 「非 Null」。 | 產生警告 | 抑制針對可能的 null 指派的警告 |
annotations |
停用 | 停用 | 除非以 ? 宣告,否則不可為 Null |
宣告可為空值的型別 | 沒有作用 |
在 已停用 的內容中編譯的程式碼裡,參考型別變數是 可空性忽略 的。 您可以將 null
常值或 「可能為 Null」 變數指派給 「忽略可空性」 的變數。 不過,null 忽略性變數的預設狀態為非 null。
您可以選擇最適合您專案的設定:
- 針對您不想依據診斷或新功能來更新的舊版專案,請選擇 [停用]。
- 選擇 [警告] 來判斷程式碼可能擲回 System.NullReferenceException 的位置。 您可以在修改程式碼之前先處理這些警告,以啟用不可為 Null 的參考型別。
- 在啟用警告之前,選擇 [註釋] 來表達您的設計意圖。
- 選擇 [啟用] 以保護您想防止空值參考例外的新專案和使用中專案。
範例:
<Nullable>enable</Nullable>
您也可以在原始程式碼中的任何位置使用指令來設定相同的標誌。 當您移轉大型程式碼基底時,這些指令是最有用的。
-
#nullable enable
:將批註和警告旗標設定為 啟用。 -
#nullable disable
:將批註和警告旗標設定為 停用。 -
#nullable restore
:將批註旗標和警告旗標還原至項目設定。 -
#nullable disable warnings
:將警告旗標設定為 停用。 -
#nullable enable warnings
:將警告旗標設定為 啟用。 -
#nullable restore warnings
:將警告旗標還原至項目設定。 -
#nullable disable annotations
:將批註旗標設定為 停用。 -
#nullable enable annotations
:將批註旗標設定為 啟用。 -
#nullable restore annotations
:將批註旗標還原至項目設定。
針對任何程式碼,您可以設定下列任何組合:
警告旗標 | 註釋標記 | 使用 |
---|---|---|
專案預設值 | 專案預設值 | 預設 |
啟用 | 停用 | 修正分析警告 |
啟用 | 專案預設值 | 修正分析警告 |
專案預設值 | 啟用 | 新增型別註釋 |
啟用 | 啟用 | 程式碼已移轉 |
禁用 | 啟用 | 修正警告之前標註程式碼 |
停用 | 停用 | 將舊版程式碼新增至移轉的專案 |
專案預設值 | 停用 | 很少 |
停用 | 專案預設值 | 很少 |
這九種組合可讓您更精細地控制編譯器因程式碼而發出的診斷。 您可以在您要更新的任何區域中啟用更多功能,而不會看到尚未準備好要解決的其他警告。
重要
全域空性上下文不適用於生成的代碼文件。 不論採用任何一個策略,對於所有標記為已生成的源文件,可空相關的內容將被停用。 這表示產生的檔案中的所有 API,都不會有標註。 已產生的檔案不會顯示關於可為 Null 的警告。 有四種方式可將檔案標記為是產生的檔案:
- 在 .editorconfig 中,於套用至該檔案的區段中,指定
generated_code = true
。 - 將
<auto-generated>
或<auto-generated/>
置於檔案頂端的註解中。 它可以在註解的任一行,但註解區塊必須是檔案中的第一個元素。 - 使用 TemporaryGeneratedFile_ 做為檔案名稱的開頭
- 使用 .designer.cs、.generated.cs、.g.cs 或 .g.i.cs 做為檔案名稱的結尾。
產生器可以選擇使用 #nullable
前置處理指示詞。
根據預設,停用可為 Null 的註釋和警告旗標 <Nullable>enable</Nullable>
元素,將這些旗標設定為啟用 。
這些選項提供兩種不同的策略,讓您可以更新現有的程式碼基底,以使用可為 Null 的參考型別。
已知陷阱
包含參考型別的陣列和結構是可為 Null 參考和靜態分析 (決定 Null 安全性) 中的已知錯誤。 在這兩種情況下,不可為 Null 的參考可能會被初始化為 null
,而不產生警告。
結構體
包含不可為 Null 參考型別的結構體允許指派 default
,而不會產生任何警告。 請考慮下列範例:
using System;
#nullable enable
public struct Student
{
public string FirstName;
public string? MiddleName;
public string LastName;
}
public static class Program
{
public static void PrintStudent(Student student)
{
Console.WriteLine($"First name: {student.FirstName.ToUpper()}");
Console.WriteLine($"Middle name: {student.MiddleName?.ToUpper()}");
Console.WriteLine($"Last name: {student.LastName.ToUpper()}");
}
public static void Main() => PrintStudent(default);
}
在上述範例中,FirstName
和 LastName
雖為 null,但在 PrintStudent(default)
中沒有任何警告。
另一個較常見的案例是處理泛型結構時。 請考慮下列範例:
#nullable enable
public struct S<T>
{
public T Prop { get; set; }
}
public static class Program
{
public static void Main()
{
string s = default(S<string>).Prop;
}
}
在上述範例中,屬性 Prop
在執行階段為 null
。 它會指派給不可為 Null 的字串,而不會有任何警告。
陣列
陣列在可為 Null 參考型別中也是已知的陷阱。 請考慮不會產生任何警告的下列範例:
using System;
#nullable enable
public static class Program
{
public static void Main()
{
string[] values = new string[10];
string s = values[0];
Console.WriteLine(s.ToUpper());
}
}
在上述範例中,陣列的宣告顯示它含有不可為 Null 的字串,而其元素全部初始化為 null
。 然後,變數 s
會指派 null
值 (陣列的第一個元素)。 最後,變數 s
會反參考,造成執行階段例外狀況。
建構函數
即使該建構函式擲回例外狀況,類別的建構函式仍會呼叫完成項。
下列範例示範該行為:
public class A
{
private string _name;
private B _b;
public A(string name)
{
ArgumentNullException.ThrowIfNullOrEmpty(name);
_name = name;
_b = new B();
}
~A()
{
Dispose();
}
public void Dispose()
{
_b.Dispose();
GC.SuppressFinalize(this);
}
}
public class B: IDisposable
{
public void Dispose() { }
}
public void Main()
{
var a = new A(string.Empty);
}
在前述範例中,如果 name
參數是 null
,當 _b.Dispose();
執行時,將會擲回 System.NullReferenceException。 當建構函式成功完成時,對 _b.Dispose();
的呼叫永遠不會擲回。 不過,編譯程式不會發出任何警告,因為靜態分析無法判斷方法(例如建構函式)是否完成,而不會擲回運行時間例外狀況。