總結
在 C# 8.0 以后將引用類型默認不可為空, 編譯器使用靜態分析,幫助開發人員盡可能地規避由空引用帶來的代碼問題。C# 8.0之前引用類型默認為空,也使用無法運行靜態流分析。使用 ? 作為可為空聲明,這對值類型和引用類型都適用。!表示忽略可空警告
編譯器靜態分析對象的屬性、字段、參數、 方法返回值、參數ref out、中Nullable Reference Types
特性 。在編寫代碼時候編譯器會根據【可空的引用類型特性】給出相應的警告,它使得程序在編譯期更為安全,避免了運行時 NullReferenceException
的發生,我衷心希望大家都能應用上這個新特性,特別是開發公共庫的作者們。而且因為這個特性是可以針對某個文件,某段代碼進行開啟或者關閉,是一個漸進式的特性,所以我們可以逐步引進,不會對項目產生影響。
- 前置條件( precondition):AllowNull 和 DisallowNull 用例在調用某個方法時必須滿足的條件。
- 后置條件(postcondition):MaybeNull 和 NotNull實現在方法返回時必須達到的要求。
-
后條件的后置條件(Conditional post-conditions):
NotNullWhen
,MaybeNullWhen
, andNotNullIfNotNull
這就是 C# 8.0 Nullable Reference Types
特性的絕大部分的應用場景,還有一些較為小眾的場景比如控制流屬性:MemberNotNull、MemberNotNullWhen、 DoesNotReturn
和 DoesNotReturnIf(bool)
沒有介紹到,大家感興趣的話可以自行去了解。
啟用可空上下文
從C#8.0開始,我們可以通過啟用可空上下文,讓VS在開發過程中可以檢查我們出現的空指針引用異常。
啟用可空上下文的方式有兩種:
1、修改.csproj文件,添加<Nullable>enable</Nullable>節點,如:
<PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net5.0</TargetFramework> <Nullable>enable</Nullable> </PropertyGroup>
Nullable節點的值enable表示啟用,disable表示停用。
此外需要注意,這種啟用方式是全局性的,修改默認行為,默認是disable,但是從.net6開始,項目默認是啟用可空上下文的,項目文件.csproj中會默認包含Nullable節點。
2、使用預編譯指令#nullable enable來啟用可空上下文,如:
//enable表示啟用 #nullable enable //disable表示停用 #nullable disable
使用預編譯指令表示局部性啟用,和修改.csproj文件的方式配合使用。而#nullable enable和#nullable disable配合使用可以實現塊級的局部性啟用。
泛型和可空引用類型特性
T=引用類型 => T?= string?
T=值類型 => T?= int
T=可空引用類型 => T?= string?
T=可空值類型 => T?= int?
對於返回值,T?等於[MaybeNull]T;對於參數值,T?等價於[AllowNull]T。有關更多信息,請參閱語言參考中關於屬性的空狀態分析的文章。泛型約束class標識不可為空的引用類型,class?表示可為空的引用類型
public class Node<T> where T:notnull { private T item; private Node<T>? next; }
正文
在啟用了空值(在.cs文件頭部添加預處理命令 #nullable enable或在項目配置文件(*.csproj)中修改,默認是啟用的 <Nullable>enable</Nullable>)的上下文中,編譯器對代碼執行靜態分析,以確定所有引用類型變量的空狀態:
- not-null:靜態分析確定變量具有非空值。
- maybe-null:靜態分析無法確定是否為變量分配了非空值。
添加這些屬性將為編譯器提供有關 API 規則的更多信息。在啟用了空值的上下文中編譯調用代碼時,編譯器將在調用方違反這些規則時發出警告。這些屬性不會對實現進行更多檢查。
屬性 | 類別 | 意義 |
---|---|---|
AllowNull | 前提 | 將不可為空的參數、字段或屬性(作用與setter)設置為可能為空。 |
DisallowNull | 前提 | 將可為空的參數、字段或屬性(作用與setter)設置為永遠不應為 null。 |
MaybeNull | 后置條件 | 將不可為空的參數(ref\out)、字段、屬性(作用與getter)或返回值設置為可能為空。 |
NotNull | 后置條件 | 將可為空的參數(ref\out)、字段、屬性(作用與getter)或返回值設置為永遠不會為空。 |
MaybeNullWhen | 有條件的后置條件 | 當方法返回指定的值時,不可為 null 的參數可能為 null。bool |
NotNullWhen | 有條件的后置條件 | 當方法返回指定的值時,可為 null 的參數將不會為 null。bool |
NotNullIfNotNull | 有條件的后置條件 | 如果指定參數的參數不為 null,則返回值、屬性或參數不為空。 |
MemberNotNull | 方法和屬性幫助程序方法 | 當方法返回時,列出的成員不會為空。 |
MemberNotNullWhen | 方法和屬性幫助程序方法 | 當方法返回指定的值時,列出的成員將不會為 null。bool |
DoesNotReturn | 無法訪問的代碼 | 方法或屬性永遠不會返回。換句話說,它總是引發異常。 |
DoesNotReturnIf | 無法訪問的代碼 | 如果關聯的參數具有指定的值,則永遠不會返回此方法或屬性。bool |
notnull
泛型約束
我們先看一下,一個簡單的泛型接口定義:
interface IDoStuff<TIn, TOut> { TOut DoStuff(TIn input); }
在這個接口定義中,我們可以很清楚的知道,這個接口可以接受兩個泛型參數,一個輸入,一個輸出,那么我們如何把非空引用這個新特性,加在泛型約束中呢?
答案是:notnull
我們來看一下實現:
interface IDoStuff<TIn, TOut> where TIn : notnull where TOut : notnull { TOut DoStuff(TIn input); }
Nice! 這樣我們就得到了一個具有非空類型約束的接口定義了,我們來試着寫一個實現:
// Warning: CS8714 - Nullability of type argument 'TIn' doesn't match 'notnull' constraint. // Warning: CS8714 - Nullability of type argument 'TOut' doesn't match 'notnull' constraint. public class DoStuffer<TIn, TOut> : IDoStuff<TIn, TOut> { public TOut DoStuff(TIn input) { ... } }
可以看到,如果我們的實現沒有加上非空類型約束,就會出現對應的警告信息。我們來修復一下:
// No warnings! public class DoStuffer<TIn, TOut> : IDoStuff<TIn, TOut> where TIn : notnull where TOut : notnull { TOut DoStuff(TIn input) { ... } }
我們來繼續創建幾個這個類的實例,看一下效果:
// Warning: CS8714 - Nullability of type argument 'string?' doesn't match 'notnull' constraint var doStuffer = new DoStuff<string?, string?>(); // No warnings! var doStufferRight = new DoStuff<string, string>();
同樣的,值類型也一樣有效:
// Warning: CS8714 - Nullability of type argument 'int?' doesn't match 'notnull' constraint var doStuffer = new DoStuff<int?, int?>(); // No warnings! var doStufferRight = new DoStuff<int, int>();
在泛型編程中,當你需要限定只有非空引用類型可以被當作類型參數時,非常有用。一個現成的例子是 Dictionary<TKey, TValue>
,其中的 TKey
是被約束為 notnull
,禁止了 null
作為 key:
// Warning: CS8714 - Nullability of type argument 'string?' doesn't match 'notnull' constraint var d1 = new Dictionary<string?, string>(10); // And as expected, using 'null' as a key for a non-nullable key type is a warning... var d2 = new Dictionary<string, string>(10); // Warning: CS8625 - Cannot convert to non-nullable reference type. var nothing = d2[null];
可空的前提條件:AllowNull和
DisallowNull
可空的前提條件:被賦值對象對值的要求AllowNull或DisallowNull
AllowNull
的使用
先來看一個例子:
public class MyClass { public string MyValue { get; set; } }
這是一個 C# 8.0 之前很常見的例子,但是從 C# 8.0 開始,這意味着 string
表示一個非可空的 string
! 有些情況下,我們需要可以使用 null
對它賦值,但是 get
的時候能拿到一個 string
的值,我們可以通過 AllowNull
來實現:
public class MyClass { private string _innerValue = string.Empty; [AllowNull] public string MyValue { get { return _innerValue; } set { _innerValue = value ?? string.Empty; } } }
這樣我們就可以保證 getter
得到的值永遠都不會為 null
,但是 setter
依然可以設置 null
值:
void M1(MyClass mc) { mc.MyValue = null; // Allowed because of AllowNull } void M2(MyClass mc) { Console.WriteLine(mc.MyValue.Length); // Also allowed, note there is no warning }
DisallowNull的使用
public static HandleMethods { public static void DisposeAndClear(ref MyHandle handle) { ... } }
在這個情況下,MyHandle
指向某個資源。通常使用這個API的時候我們有一個 not null
的實例通過 ref
傳遞進去,但是當這個資源被這個API Clear
之后,這個引用就會變成 null
. 我們如何能同時兼顧 handle
可為 null
,但是傳參的時候又不可為 null
呢?答案是 DisallowNull
:
public static HandleMethods { public static void DisposeAndClear([DisallowNull] ref MyHandle? handle) { ... } }
我們來看一下效果:
void M(MyHandle handle) { MyHandle? local = null; // Create a null value here HandleMethods.DisposeAndClear(ref local); // Warning: CS8601 - Possible null reference assignment // Now pass the non-null handle HandleMethods.DisposeAndClear(ref handle); // No warning! ... But the value could be null now Console.WriteLine(handle.SomeProperty); // Warning: CS8602 - Dereference of a possibly null reference }
這兩個屬性可以允許我們在需要的情況下使用單向的可空性或者不可空性。
可空的后置條件:MaybeNull
和NotNull
可空的后置條件:返回對象的情況(MaybeNull
、NotNull
)
可以使用以下特性指定無條件后置條件:
先看一下下面這個API:
public class MyArray { // Result is the default of T if no match is found public static T Find<T>(T[] array, Func<T, bool> match) { } // Never gives back a null when called public static void Resize<T>(ref T[] array, int newSize) { } }
現在我們有一個問題,我們希望 Find
在找不到元素的時候返回 default
,default
在 T
為引用類型的時候為 null
,另一方面,我們希望 Resize
能接受一個可能為 null
的數組,但是當 Resize
調用之后, array
絕不會為 null
. 我們如何才能實現這樣的效果呢?
答案是 [MaybeNull]
和 [NotNull]
,通過這兩個屬性,我們可以實現這種奇妙的效果!讓我們來修改一下我們的API:
public class MyArray { // Result is the default of T if no match is found [return: MaybeNull] public static T Find<T>(T[] array, Func<T, bool> match) { ... } // Never gives back a null when called public static void Resize<T>([NotNull] ref T[]? array, int newSize) { } }
現在這些可以影響調用方:
void M(string[] testArray) { var value = MyArray.Find<string>(testArray, s => s == "Hello!"); Console.WriteLine(value.Length); // Warning: Dereference of a possibly null reference. MyArray.Resize<string>(ref testArray, 200); Console.WriteLine(testArray.Length); // Safe! }
第一個方法指定返回的T可以是空值。這意味着此方法的調用方在使用其結果時必須檢查是否為空。
第二個方法有一個更復雜的簽名: [NotNull] ref T[]? 數組。這意味着作為輸入的數組可以為空,但當調用Resize時,數組不可以為空。這意味着,如果您在調用Resize后“點”到數組中,將不會收到警告。但調用Resize后,數組將不再為空。
條件性的后置條件:MaybeNullWhen(bool)
和 NotNullWhen(bool)
MaybeNullWhen(bool)
和 NotNullWhen(bool)
思考一下另外一個例子:
public class MyString { // True when 'value' is null public static bool IsNullOrEmpty(string? value) { ... } } public class MyVersion { // If it parses successfully, the Version will not be null. public static bool TryParse(string? input, out Version? version) { ... } } public class MyQueue<T> { // 'result' could be null if we couldn't Dequeue it. public bool TryDequeue(out T result) { ... } }
像這樣的API,在我們平時的開發過程中都會經常使用到,根據API返回的 true
和 false
,決定了我們傳進去的參數是 null
還是 notnull
.
我們希望實現下面三個效果: 1. 當 IsNullOrEmpty
返回 false
的時候,value
不為空 2. 當 TryParse
返回 true
的時候,version
不為空 3. 當 TryDequeue
返回 false
的時候,result
可能會為空(當 T
為引用類型的時候)
通過 NotNullWhen(bool)
和 MaybeNullWhen(bool)
,我們能實現這個更為奇妙的效果,讓我們來修改一下代碼:
public class MyString { // True when 'value' is null public static bool IsNullOrEmpty([NotNullWhen(false)] string? value) { ... } } public class MyVersion { // If it parses successfully, the Version will not be null. public static bool TryParse(string? input, [NotNullWhen(true)] out Version? version) { ... } } public class MyQueue<T> { // 'result' could be null if we couldn't Dequeue it. public bool TryDequeue([MaybeNullWhen(false)] out T result) { ... } }
然后看一下效果:
void StringTest(string? s) { if (MyString.IsNullOrEmpty(s)) { // This would generate a warning: // Console.WriteLine(s.Length); return; } Console.WriteLine(s.Length); // Safe! } void VersionTest(string? s) { if (!MyVersion.TryParse(s, out var version)) { // This would generate a warning: // Console.WriteLine(version.Major); return; } Console.WriteLine(version.Major); // Safe! } void QueueTest(MyQueue<string> q) { if (!q.TryDequeue(out var result)) { // This would generate a warning: // Console.WriteLine(result.Length); return; } Console.WriteLine(result.Length); // Safe! }
我們可以看到: 如果 IsNullOrEmpty
返回 false
, 那么 s
不為空並且可以安全的訪問內在的屬性 如果 TryParse
返回 true
, 那么 version
不為空並且可以安全的訪問內在的屬性 * 如果 TryDequeue
返回 false
, 那么 result
可能為空,反之則不為空
NotNullWhen(bool) 用法
#nullable enable using System.Diagnostics.CodeAnalysis; Screen screen = new(); string? userInput = null; if (Screen.IsNullOrEmpty(userInput)) { // 警告 CS8600 將 null 文本或可能的 null 值轉換為不可為 null 類型。 string NonullCheckHere = userInput;//因為返回是true是空類型所以 要進行空檢測 } else { //因為返回是false 不是空類型,所以這邊不在需要進行空檢測 string NonullCheck = userInput; int messageLength = userInput.Length; // no null check needed. } Console.Read(); public class Screen { public static bool IsNullOrEmpty([NotNullWhen(false)] string? value) => (value == null || 0 == value.Length) ? true : false; }
輸入與輸出的可空性依賴
思考一下這個例子:
class MyPath { public static string? GetFileName(string? path) { ... } }
在這個例子中,我們的參數和返回值都是一個可空的 string
,但是我們希望實現這樣的一個效果:當參數 path
不為空的時候,返回值也不為空。
在這種情況下,返回參數的可空性依賴於傳入的參數,我們要如何實現呢?
通過 NotNullIfNotNull(string)
這個屬性,我們可以實現這個最有意思的需求,讓我們看一下修改之后的代碼:
該類除了實現Load方法外,還會根據ReloadOnChange屬性,在構造函數中注冊OnChange事件,用於重新加載配置信息,源碼如下:
請考慮如下示例:
class MyPath { [return: NotNullIfNotNull("path")] public static string? GetFileName(string? path) { ... } }
看一下效果:
void PathTest(string? path) { var possiblyNullPath = MyPath.GetFileName(path); Console.WriteLine(possiblyNullPath.Length); // Warning: Dereference of a possibly null reference if (!string.IsNullOrEmpty(path)) { var goodPath = MyPath.GetFileName(path); Console.WriteLine(goodPath.Length); // Safe! } }
[MemberNotNull] 與 [MemberNotNullWhen]
如下圖所示,由於編譯器無法保證 _mayNullStr.Length 不會引發空引用異常,所以拋出編譯錯誤 CS8602;
此時可以通過添加 MemberNotNull 特性,顯式地告訴編譯器方法 PromisStrNotNull() 可以保證 _mayNullStr 不為 Null。
/// <summary> /// 返回 true 時,<see cref="_mayNullStr"/> 不為 null /// </summary> /// <returns></returns> [MemberNotNullWhen(true, nameof(_mayNullStr))] private bool StrNotNullWhenReturnTrue() { if (DateTime.Now.DayOfWeek == DayOfWeek.Friday) { _mayNullStr = "明天不用上班啦!"; return true; } _mayNullStr = null; return false; }