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 有兩個參考參數:keymessage。 此 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 參數具有指定的值,這個方法或屬性永遠不會傳回。

上述描述是對每個屬性有效的快速參考。 下列各節將更徹底地描述這些屬性的行為和意義。

前置條件:AllowNullDisallowNull

請考慮永不傳回 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 屬性時要尋找的內容:

  1. 該變數的一般合約是其不應該是 null,因此您想要不可為 Null 的參考型別。
  2. 呼叫端會以引數的形式傳遞 null,但這不是最常見的使用方式。

您最常需要此屬性來取得屬性,或 inoutref 引數。 當變數通常是非 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 的內容中,ReviewCommentget 存取子可以傳回 null 的預設值。 編譯器會警告必須在存取之前檢查此值。 此外,其也會警告呼叫端,即使其可以是 null,呼叫端不應該明確地將其設定為 nullDisallowNull 屬性也會指定前置條件,不會影響 get 存取子。 當您觀察下列特性時,請使用 DisallowNull 屬性:

  1. 變數可能是核心情節中的 null,通常是第一次具現化時。
  2. 變數不應該明確設定為 null

這些情況在原本為 null 遺忘程式碼中很常見。 可能是物件屬性是在兩個不同的初始化作業中設定。 某些屬性的設定可能只會在某些非同步工作完成之後才會進行。

AllowNullDisallowNull 屬性可讓您指定變數上的前置條件可能不符合這些變數上可為 Null 的註釋。 這些提供 API 特性的詳細資料。 這項其他資訊可協助呼叫端正確使用 API。 請記住,您會使用下列屬性來指定前置條件:

  • AllowNull:不可為 Null 的引數可能是 Null。
  • DisallowNull:可為 Null 的引數不應該是 Null。

前置條件:MaybeNullNotNull

假設您有一個具有下列簽章的方法:

public Customer FindCustomer(string lastName, string firstName)

您可能已撰寫類似此項的方法,以在找不到搜尋的名稱時傳回 nullnull 清楚指出找不到記錄。 在此範例中,您可能會將傳回型別從 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。

您可以使用下列屬性來指定無條件的後置條件:

  • MaybeNull:不可為 Null 的傳回值可能是 Null。
  • NotNull:可為 Null 的傳回值永遠不會是 Null。

條件式後置條件:NotNullWhenMaybeNullWhenNotNullIfNotNull

您很可能熟悉 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.

注意

上述範例只有在 C# 11 和更新版本中才有效。 從 C# 11 開始,nameof 運算式在套用至方法的屬性中使用時可以參考參數和類型參數名稱。 在 C# 10 和更早版本中,您需要使用字串常值,而不是 nameof 運算式。

對於 .NET Core 3.0,String.IsNullOrEmpty(String) 方法將如上所示進行註釋。 程式碼基底中可能有類似的方法,會檢查物件的狀態是否有 Null 值。 編譯器無法辨識自訂 Null 檢查方法,而且您必須自行新增註釋。 當您新增屬性時,編譯器的靜態分析會知道測試過的變數是否已經過 Null 檢查。

這些屬性的另一個用途是 Try* 模式。 會透過傳回值對 refout 引數的後置條件進行通訊。 請考慮先前顯示的這個方法 (在可為 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 檢查。 合約是只有當引數 urlnull 時,傳回值才會是 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。

協助程式方法:MemberNotNullMemberNotNullWhen

當您將一般程式碼從建構函式重構為協助程式方法時,這些屬性會指定意圖。 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 參數具有指定的值,這個方法或屬性永遠不會傳回。