C# 編譯器解譯之 null-state 靜態分析的屬性
在可為 Null 的啟用內容中,編譯器會執行程式碼的靜態分析,以判斷所有參考型別變數的 null-state:
- not-null:靜態分析會判斷變數是否具有非 Null 值。
- maybe-null:靜態分析無法判斷變數是否獲指派非 Null 值。
這些狀態可讓編譯器在可能對 Null 值進行取值時提供警告,並擲回 System.NullReferenceException。 這些屬性會根據引數和傳回值的狀態,提供編譯器有關引數、傳回值和物件成員之 null-state 的語意資訊。 當 API 已正確標註此語意資訊時,編譯器會提供更精確的警告。
本文提供每個可為 Null 參考型別屬性的簡短描述,以及如何使用這些屬性。
讓我們從下列範例開始。 假設程式庫具有下列 API 來擷取資源字串。 這個方法最初的編譯是在可為 Null 遺忘內容中進行:
bool TryGetMessage(string key, out string message)
{
if (_messageMap.ContainsKey(key))
message = _messageMap[key];
else
message = null;
return message != null;
}
上述範例遵循 .NET 中熟悉的 Try*
模式。 此 API 有兩個參考參數:key
和 message
。 此 API 具有下列與這些參數 null-state 相關的規則:
- 呼叫端不應該傳遞
null
作為key
的引數。 - 呼叫端可以傳遞變數,其值為作為
message
引數的null
。 - 如果
TryGetMessage
方法傳回true
,則message
的值不是 Null。 如果傳回值為false
,則的值message
為 null。
key
的規則可以簡潔表示:key
應該是不可為 Null 的參考型別。 message
參數較為複雜。 其允許作為引數的變數 null
,但保證在成功時,out
引數不是 null
。 在這些情節中,您需要更豐富的詞彙來描述預期。 下面所述的 NotNullWhen
屬性描述 message
參數所用引數的 null-state。
注意
新增這些屬性可提供編譯器 API 規則的詳細資訊。 在可為 Null 啟用的內容中編譯呼叫程式碼時,編譯器會在違反這些規則時警告呼叫端。 這些屬性不會對實作啟用更多檢查。
屬性 | 類別 | 意義 |
---|---|---|
AllowNull | 先決條件 | 不可為 Null 的參數、欄位或屬性可能是 Null。 |
DisallowNull | 先決條件 | 可為 Null 的參數、欄位或屬性永遠不應為 Null。 |
MaybeNull | 後置條件 | 不可為 Null 的參數、欄位、屬性或傳回值可能是 Null。 |
NotNull | 後置條件 | 可為 Null 的參數、欄位、屬性或傳回值永遠不會是 Null。 |
MaybeNullWhen | 有條件的後置條件 | 當方法傳回指定的 bool 值時,不可為 Null 的引數可能是 null。 |
NotNullWhen | 有條件的後置條件 | 當方法傳回指定的 bool 值時,可為 Null 的引數不會是 null。 |
NotNullIfNotNull | 有條件的後置條件 | 如果指定參數的引數不是 Null,則傳回值、屬性或引數不是 Null。 |
MemberNotNull | 方法和屬性協助程式方法 | 當方法傳回時,列出的成員不會是 Null。 |
MemberNotNullWhen | 方法和屬性協助程式方法 | 當方法傳回指定的 bool 值時,所列成員不會是 null。 |
DoesNotReturn | 執行不到的程式碼 | 方法或屬性永遠不會傳回。 換句話說,其一律會擲回例外狀況。 |
DoesNotReturnIf | 執行不到的程式碼 | 如果相關聯的 bool 參數具有指定的值,這個方法或屬性永遠不會傳回。 |
上述描述是對每個屬性有效的快速參考。 下列各節將更徹底地描述這些屬性的行為和意義。
前置條件:AllowNull
和 DisallowNull
請考慮永不傳回 null
的讀取/寫入屬性,因為其具有合理的預設值。 呼叫端會在將其設定為該預設值時將 null
傳遞至 set 存取子。 例如,請考慮在聊天室中要求螢幕名稱的傳訊系統。 如果未提供,系統會產生隨機名稱:
public string ScreenName
{
get => _screenName;
set => _screenName = value ?? GenerateRandomScreenName();
}
private string _screenName;
當您在可為 Null 的遺忘內容中編譯上述程式碼時,一切都可運作。 啟用可為 Null 的參考型別之後,ScreenName
屬性就會變成不可為 Null 的參考。 對 get
存取子而言是正確的:其永遠不會傳回 null
。 呼叫端不需要檢查 null
的傳回屬性。 但現在將屬性設定為 null
會產生警告。 若要支援這種型別的程式碼,請如下列程式碼所示,將 System.Diagnostics.CodeAnalysis.AllowNullAttribute 屬性新增至屬性:
[AllowNull]
public string ScreenName
{
get => _screenName;
set => _screenName = value ?? GenerateRandomScreenName();
}
private string _screenName = GenerateRandomScreenName();
您可能需要新增 using
指示詞,System.Diagnostics.CodeAnalysis 才能使用本文所討論的這個和其他屬性。 屬性會套用至屬性,而不是 set
存取子。 AllowNull
屬性會指定前置條件,而且僅適用於引數。 get
存取子具有傳回值,但沒有參數。 因此,AllowNull
屬性只適用於 set
存取子。
上述範例示範在引數上新增 AllowNull
屬性時要尋找的內容:
- 該變數的一般合約是其不應該是
null
,因此您想要不可為 Null 的參考型別。 - 呼叫端會以引數的形式傳遞
null
,但這不是最常見的使用方式。
您最常需要此屬性來取得屬性,或 in
、out
和 ref
引數。 當變數通常是非 Null 時,AllowNull
屬性會是最佳選擇,但您必須允許將 null
作為前置條件。
與使用 DisallowNull
的情節相反:您可以使用這個屬性來指定可為 Null 參考型別的引數不應該為 null
。 請考慮屬性,其中 null
是預設值,但用戶端只能將其設定為非 Null 值。 請考慮下列程式碼:
public string ReviewComment
{
get => _comment;
set => _comment = value ?? throw new ArgumentNullException(nameof(value), "Cannot set to null");
}
string _comment;
上述程式碼是表達設計的最佳方式,ReviewComment
可能是 null
,但無法設定為 null
。 一旦此程式碼具有可為 Null 的感知,您就可以使用 System.Diagnostics.CodeAnalysis.DisallowNullAttribute 更清楚地將此概念表達給呼叫端:
[DisallowNull]
public string? ReviewComment
{
get => _comment;
set => _comment = value ?? throw new ArgumentNullException(nameof(value), "Cannot set to null");
}
string? _comment;
在可為 Null 的內容中,ReviewComment
get
存取子可以傳回 null
的預設值。 編譯器會警告必須在存取之前檢查此值。 此外,其也會警告呼叫端,即使其可以是 null
,呼叫端不應該明確地將其設定為 null
。 DisallowNull
屬性也會指定前置條件,不會影響 get
存取子。 當您觀察下列特性時,請使用 DisallowNull
屬性:
- 變數可能是核心情節中的
null
,通常是第一次具現化時。 - 變數不應該明確設定為
null
。
這些情況在原本為 null 遺忘程式碼中很常見。 可能是物件屬性是在兩個不同的初始化作業中設定。 某些屬性的設定可能只會在某些非同步工作完成之後才會進行。
AllowNull
和 DisallowNull
屬性可讓您指定變數上的前置條件可能不符合這些變數上可為 Null 的註釋。 這些提供 API 特性的詳細資料。 這項其他資訊可協助呼叫端正確使用 API。 請記住,您會使用下列屬性來指定前置條件:
- AllowNull:不可為 Null 的引數可能是 Null。
- DisallowNull:可為 Null 的引數不應該是 Null。
前置條件:MaybeNull
和 NotNull
假設您有一個具有下列簽章的方法:
public Customer FindCustomer(string lastName, string firstName)
您可能已撰寫類似此項的方法,以在找不到搜尋的名稱時傳回 null
。 null
清楚指出找不到記錄。 在此範例中,您可能會將傳回型別從 Customer
變更為 Customer?
。 將傳回值宣告為可為 Null 的參考型別,可清楚指定此 API 的意圖:
public Customer? FindCustomer(string lastName, string firstName)
基於泛型可 Null 性所涵蓋的原因,技術可能無法產生符合 API 的靜態分析。 您可能有遵循類似模式的泛型方法:
public T Find<T>(IEnumerable<T> sequence, Func<T, bool> predicate)
找不到搜尋的項目時,方法會傳回 null
。 您可以藉由將 MaybeNull
註釋新增至方法傳回,來釐清方法在找不到項目時傳回 null
:
[return: MaybeNull]
public T Find<T>(IEnumerable<T> sequence, Func<T, bool> predicate)
上述程式碼會通知呼叫端傳回值可能實際上是 Null。 其也會通知編譯器,即使型別不可為 Null,方法還是可能會傳回 null
運算式。 當您有傳回其型別參數執行個體的泛型方法 (T
),您可以使用 NotNull
屬性來表示其永遠不會傳回 null
。
您也可以指定傳回值或引數不是 Null,即使型別是可為 Null 的參考型別也一樣。 下列方法是如果其第一個引數為 null
,則會擲回的協助程式方法:
public static void ThrowWhenNull(object value, string valueExpression = "")
{
if (value is null) throw new ArgumentNullException(nameof(value), valueExpression);
}
您可以如下所示呼叫此常式:
public static void LogMessage(string? message)
{
ThrowWhenNull(message, $"{nameof(message)} must not be null");
Console.WriteLine(message.Length);
}
啟用 Null 參考型別之後,您想要確保上述程式碼會編譯而不發出警告。 當方法傳回時,value
參數保證為非 Null。 不過,可以接受使用 Null 參考呼叫 ThrowWhenNull
。 您可以建立 value
可為 Null 的參考型別,並將 NotNull
後置條件新增至參數宣告:
public static void ThrowWhenNull([NotNull] object? value, string valueExpression = "")
{
_ = value ?? throw new ArgumentNullException(nameof(value), valueExpression);
// other logic elided
上述程式碼清楚表示現有的合約:呼叫端可以傳遞具有 null
值的變數,但如果方法傳回而不擲回例外狀況,則引數保證永遠不會為 Null。
您可以使用下列屬性來指定無條件的後置條件:
條件式後置條件:NotNullWhen
、MaybeNullWhen
和 NotNullIfNotNull
您很可能熟悉 string
方法 String.IsNullOrEmpty(String)。 這個方法會在引數為 null 或空字串時傳回 true
。 其是 null-check 的形式:如果方法傳回 false
,呼叫端就不需要對引數進行 null-check。 若要讓類似這樣的方法成為可為 Null 感知,您可以將引數設定為可為 Null 的參考型別,並新增 NotNullWhen
屬性:
bool IsNullOrEmpty([NotNullWhen(false)] string? value)
這樣會通知編譯器,傳回值為 false
的任何程式碼不需要 Null 檢查。 新增屬性會通知編譯器的靜態分析:IsNullOrEmpty
會在傳回 false
時執行必要的 Null 檢查,引數不是 null
。
string? userInput = GetUserInput();
if (!string.IsNullOrEmpty(userInput))
{
int messageLength = userInput.Length; // no null check needed.
}
// null check needed on userInput here.
對於 .NET Core 3.0,String.IsNullOrEmpty(String) 方法將如上所示進行註釋。 程式碼基底中可能有類似的方法,會檢查物件的狀態是否有 Null 值。 編譯器無法辨識自訂 Null 檢查方法,而且您必須自行新增註釋。 當您新增屬性時,編譯器的靜態分析會知道測試過的變數是否已經過 Null 檢查。
這些屬性的另一個用途是 Try*
模式。 會透過傳回值對 ref
和 out
引數的後置條件進行通訊。 請考慮先前顯示的這個方法 (在可為 Null 停用的內容):
bool TryGetMessage(string key, out string message)
{
if (_messageMap.ContainsKey(key))
message = _messageMap[key];
else
message = null;
return message != null;
}
上述方法遵循一般 .NET 慣用語:傳回值表示是否已將 message
設定為找到的值,或者如果找不到任何訊息,則為預設值。 如果方法傳回 true
,則 message
的值不是 null;否則,方法會將 message
設為 null。
在可為 Null 的啟用內容中,您可以使用 NotNullWhen
屬性來傳達該慣用語。 當您為可為 Null 的參考型別加上參數註釋時,請將 message
設為 string?
並新增屬性:
bool TryGetMessage(string key, [NotNullWhen(true)] out string? message)
{
if (_messageMap.ContainsKey(key))
message = _messageMap[key];
else
message = null;
return message is not null;
}
在上述範例中,TryGetMessage
傳回 true
時,message
的值已知不是 Null。 您應該以相同的方式標註程式碼基底中的類似方法:引數可能等於 null
,而且在方法傳回 true
時已知不是 Null。
您可能也需要一個最終屬性。 有時候,傳回值的 Null 狀態取決於一或多個引數的 Null 狀態。 每當某些引數不是 null
時,這些方法都會傳回非 Null 值。 若要正確標註這些方法,請使用 NotNullIfNotNull
屬性。 請考慮下列 方法:
string GetTopLevelDomainFromFullUrl(string url)
如果 url
引述不是 Null,則輸出不是 null
。 啟用可為 Null 的參考之後,如果 API 可能接受 Null 引數,您就必須新增更多註釋。 您可以如下列程式碼所示,標註傳回型別:
string? GetTopLevelDomainFromFullUrl(string? url)
這也可行,但通常會強制呼叫端實作額外的 null
檢查。 合約是只有當引數 url
為 null
時,傳回值才會是 null
。 若要表示該合約,您會如下列程式碼所示標註此方法:
[return: NotNullIfNotNull(nameof(url))]
string? GetTopLevelDomainFromFullUrl(string? url)
上一個範例使用 url
參數的 nameof
運算子。 這是 C# 11 的功能。 在 C# 11 之前,您必須將參數的名稱輸入為字串。 傳回值和引數都已加上 ?
的註釋,表示其中一個可以是 null
。 屬性進一步釐清當 url
引數不是 null
時,傳回值不會是 Null。
您可以使用這些屬性來指定條件式後置條件:
- MaybeNullWhen:當方法傳回指定的
bool
值時,不可為 Null 的引數可能是 null。 - NotNullWhen:當方法傳回指定的
bool
值時,可為 Null 的引數不會是 null。 - NotNullIfNotNull:如果指定參數的引數不是 Null,則傳回值不是 Null。
協助程式方法:MemberNotNull
和 MemberNotNullWhen
當您將一般程式碼從建構函式重構為協助程式方法時,這些屬性會指定意圖。 C# 編譯器會分析建構函式和欄位初始設定式,以確保每個建構函式傳回之前,所有不可為 Null 的參考欄位都已經初始化。 不過,C# 編譯器不會透過所有協助程式方法追蹤欄位指派。 編譯器會在欄位未直接在建構函式中初始化時發出警告 CS8618
,而不是在協助程式方法中發出警告。 您可以將 MemberNotNullAttribute 新增至方法宣告,並指定初始化為方法中非 Null 值的欄位。 例如,請試想下列範例:
public class Container
{
private string _uniqueIdentifier; // must be initialized.
private string? _optionalMessage;
public Container()
{
Helper();
}
public Container(string message)
{
Helper();
_optionalMessage = message;
}
[MemberNotNull(nameof(_uniqueIdentifier))]
private void Helper()
{
_uniqueIdentifier = DateTime.Now.Ticks.ToString();
}
}
您可以將多個欄位名稱指定為 MemberNotNull
屬性建構函式的引數。
MemberNotNullWhenAttribute 具有 bool
引數。 當協助程式方法傳回 bool
時,您會使用 MemberNotNullWhen
指出協助程式方法是否初始化欄位。
呼叫方法擲回時停止可為 Null 的分析
某些方法 (通常是例外狀況協助程式或其他公用程式方法),一律會擲回例外狀況來結束。 或者,協助程式可能會根據布林值引數的值擲回例外狀況。
在第一種案例中,您可以將 DoesNotReturnAttribute 屬性新增至方法宣告。 編譯器的 null-state 分析不會檢查方法中的任何程式碼,該方法後續會呼叫以 DoesNotReturn
標註的方法。 請參考下列方法:
[DoesNotReturn]
private void FailFast()
{
throw new InvalidOperationException();
}
public void SetState(object containedField)
{
if (containedField is null)
{
FailFast();
}
// containedField can't be null:
_field = containedField;
}
編譯器不會在呼叫 FailFast
之後發出任何警告。
第二種案例中下,您會將 System.Diagnostics.CodeAnalysis.DoesNotReturnIfAttribute 屬性新增至方法的布林值參數。 您可以如下所示修改上述範例:
private void FailFastIf([DoesNotReturnIf(true)] bool isNull)
{
if (isNull)
{
throw new InvalidOperationException();
}
}
public void SetFieldState(object? containedField)
{
FailFastIf(containedField == null);
// No warning: containedField can't be null here:
_field = containedField;
}
當引數的值符合 DoesNotReturnIf
建構函式的值時,編譯器不會在該方法之後執行任何 null-state 分析。
摘要
新增可為 Null 的參考型別會提供初始詞彙,以描述對可能為 null
之變數的 API 期望。 屬性提供更豐富的詞彙,以將變數的 Null 狀態描述為前置條件和後置條件。 這些屬性更清楚地描述您的期望,並使用 API 為開發人員提供更完善的體驗。
當您更新可為 Null 內容的程式庫時,請新增這些屬性,以引導 API 的使用者正確的使用方式。 這些屬性可協助您完整描述引數和傳回值的 null-state。
- AllowNull:不可為 Null 的欄位、參數或屬性可能是 Null。
- DisallowNull:可為 Null 的欄位、參數或屬性永遠不應為 Null。
- MaybeNull:不可為 Null 的欄位、參數、屬性或傳回值可能是 Null。
- NotNull:可為 Null 的欄位、參數、屬性或傳回值永遠不會是 Null。
- MaybeNullWhen:當方法傳回指定的
bool
值時,不可為 Null 的引數可能是 null。 - NotNullWhen:當方法傳回指定的
bool
值時,可為 Null 的引數不會是 null。 - NotNullIfNotNull:如果指定參數的引數不是 Null,則參數、屬性或傳回值不是 Null。
- DoesNotReturn:方法或屬性永遠不會傳回。 換句話說,其一律會擲回例外狀況。
- DoesNotReturnIf:如果相關聯的
bool
參數具有指定的值,這個方法或屬性永遠不會傳回。