共用方式為


C# 編譯器解譯之 null-state 靜態分析的屬性

在可為 Null 的啟用內容中,編譯器會執行程式碼的靜態分析,以判斷所有參考型別變數的 null-state

  • not-null:靜態分析會判斷變數是否具有非 Null 值。
  • 可能為零值:靜態分析無法判斷變數是否被指派為非空值。

這些狀態使編譯器能在你可能取消引用空值時發出警告,拋出 System.NullReferenceException。 這些屬性為編譯器提供關於參數、回傳值及物件成員的 空狀態 語意資訊。 屬性用來釐清參數的狀態和回傳值。 當你的 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 的引數。
  • 呼叫端可以傳遞變數,其值為作為 null 引數的 message
  • 如果 TryGetMessage方法傳回 true,則 message 的值不是 Null。 如果傳回值為 false,則的值 message 為 null。

key 的規則可以簡潔表示:key 應該是不可為 Null 的參考型別。 message 參數較為複雜。 其允許作為引數的變數 null,但保證在成功時,out 引數不是 null。 在這些情節中,您需要更豐富的詞彙來描述預期。 屬性NotNullWhen描述參數所用message參數的空狀態

注意

新增這些屬性可提供編譯器 API 規則的詳細資訊。 當呼叫程式碼在可空化的啟用情境中編譯時,編譯器會在呼叫者違反這些規則時發出警告。 這些屬性不會對實作啟用更多檢查。

屬性 類別 意義
AllowNull 先決條件 不可空的參數、欄位或屬性可能是空。
DisallowNull 先決條件 可為 Null 的參數、欄位或屬性永遠不應為 Null。
MaybeNull 後置條件 不可空的參數、欄位、屬性或回傳值可能是空的。
NotNull 後置條件 可空的參數、欄位、屬性或回傳值永遠不會是空的。
MaybeNullWhen 有條件的後置條件 當方法回傳指定 bool 值時,非可空參數可能是空的。
NotNullWhen 有條件的後置條件 當方法回傳指定的 bool 值時,可空參數就不是空的。
NotNullIfNotNull 有條件的後置條件 如果指定參數的引數不是 Null,則傳回值、屬性或引數不是 Null。
MemberNotNull 方法和屬性協助程式方法 當方法回傳時,列出的成員並非空。
MemberNotNullWhen 方法和屬性協助程式方法 當方法回傳指定 bool 值時,列出的成員並非空。
DoesNotReturn 執行不到的程式碼 方法或屬性永遠不會傳回。 換句話說,其一律會擲回例外狀況。
DoesNotReturnIf 執行不到的程式碼 如果相關聯的 bool 參數具有指定的值,這個方法或屬性永遠不會傳回。

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

前置條件:AllowNullDisallowNull

請考慮永不傳回 null 的讀取/寫入屬性,因為其具有合理的預設值。 呼叫端會在將其設定為該預設值時將 null 傳遞至 set 存取子。 例如,請考慮在聊天室中要求螢幕名稱的傳訊系統。 如果未提供,系統會產生隨機名稱:

public string ScreenName
{
    get => _screenName;
    set => _screenName = value ?? GenerateRandomScreenName();
}
private string _screenName;

當您在可為 Null 的遺忘內容中編譯上述程式碼時,一切都可運作。 啟用可為 Null 的參考型別之後,ScreenName 屬性就會變成不可為 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,但這不是最常見的使用方式。

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

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

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

AllowNullDisallowNull 屬性讓你能指定變數的前置條件可能不符合該變數的可空標註。 這些註解提供了關於你 API 特性的更詳細資訊。 這項其他資訊可協助呼叫端正確使用 API。 請記住,您會使用下列屬性來指定前置條件:

前置條件:MaybeNullNotNull

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

public Customer FindCustomer(string lastName, string firstName)

你很可能寫了類似的方法,當找不到想要的名字時回傳 nullnull 清楚指出找不到記錄。 在此範例中,您可能會將傳回型別從 Customer 變更為 Customer?。 將傳回值宣告為可為 Null 的參考型別,可清楚指定此 API 的意圖:

public Customer? FindCustomer(string lastName, string firstName)

基於通用空 性涵蓋的原因,該技術可能無法產生與你 API 相符的靜態分析。 你可能有一個通用的方法,遵循類似的模式:

public T Find<T>(IEnumerable<T> sequence, Func<T, bool> predicate)

找不到搜尋的項目時,方法會傳回 null。 您可以藉由將 null 註釋新增至方法傳回,來釐清方法在找不到項目時傳回 MaybeNull

[return: MaybeNull]
public T Find<T>(IEnumerable<T> sequence, Func<T, bool> predicate)

前述程式碼告知呼叫者,回傳值 實際上可能是 空值。 它也會通知編譯器,即使該型別不可空,方法仍可回傳 null 表達式。 當您有傳回其型別參數執行個體的泛型方法 (T),您可以使用 null 屬性來表示其永遠不會傳回 NotNull

您也可以指定傳回值或引數不是 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:不可空的回傳值可以是空。
  • NotNull:可為空的回傳值永遠不會是 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.

String.IsNullOrEmpty(String)方法如前例所示。 你的程式碼庫裡可能有類似的方法,會檢查物件的狀態是否有空值。 編譯器不認可自訂的 null 檢查方法,你需要自己加上註解。 當你加入屬性時,編譯器的靜態分析會知道測試變數何時被 null-check。

這些屬性的另一個用途是 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;
}

在上述範例中,message 傳回 TryGetMessage 時,true 的值已知不是 Null。 您應該以相同的方式標註程式碼基底中的類似方法:引數可能等於 null,而且在方法傳回 true 時已知不是 Null。

還有一個你可能還需要的屬性。 有時候,傳回值的 Null 狀態取決於一或多個引數的 Null 狀態。 當某些參數不是 null時,這些方法會回傳非空值。 若要正確標註這些方法,請使用 NotNullIfNotNull 屬性。 請考慮下列 方法:

string GetTopLevelDomainFromFullUrl(string url)

如果 url 引述不是 Null,則輸出不是 null。 啟用可空引用後,如果你的 API 能接受 null 參數,你需要新增更多註解。 您可以如下列程式碼所示,標註傳回型別:

string? GetTopLevelDomainFromFullUrl(string? url)

這方法也有效,但通常會迫使來電者實施額外的 null 檢查。 合約是只有當引數 nullurl 時,傳回值才會是 null。 若要表示該合約,您會如下列程式碼所示標註此方法:

[return: NotNullIfNotNull(nameof(url))]
string? GetTopLevelDomainFromFullUrl(string? url)

上一個範例使用 nameof 參數的 url 運算子。 傳回值和引數都已加上 ? 的註釋,表示其中一個可以是 null。 該屬性進一步說明,當 url 參數不是 null時,回傳值並非空。

您可以使用這些屬性來指定條件式後置條件:

  • MaybeNullWhen:當方法回傳指定 bool 值時,非可空參數可為空。
  • NotNullWhen:當方法回傳指定的 bool 值時,可空的參數就不是空的。
  • NotNullIfNotNull:如果指定參數的引數不是 Null,則傳回值不是 Null。

協助程式方法:MemberNotNullMemberNotNullWhen

這些屬性會指定你將建構子中的常用程式碼重構成輔助方法時的意圖。 C# 編譯器會分析建構子與欄位初始化器,以確保所有不可空的參考欄位在每個建構子返回前都已初始化。 不過,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 引數。 當協助程式方法傳回 MemberNotNullWhen 時,您會使用 bool 指出協助程式方法是否初始化欄位。

呼叫方法擲回時停止可為 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:不可空的欄位、參數或屬性可能是空。
  • DisallowNull:可為 Null 的欄位、參數或屬性永遠不應為 Null。
  • MaybeNull:不可空的欄位、參數、屬性或回傳值可能是空。
  • NotNull:可空的欄位、參數、屬性或回傳值永遠不會是空。
  • MaybeNullWhen:當方法回傳指定 bool 值時,非可空參數可能是空。
  • NotNullWhen:當方法回傳指定的 bool 值時,可空的參數就不是空的。
  • NotNullIfNotNull:如果指定參數的引數不是 Null,則參數、屬性或傳回值不是 Null。
  • DoesNotReturn:方法或屬性永遠不會傳回。 換句話說,其一律會擲回例外狀況。
  • DoesNotReturnIf:如果相關聯的 bool 參數具有指定的值,這個方法或屬性永遠不會傳回。