如何將 C# 7 類庫升級到 C# 8?使用可空引用型別
這篇文章將介紹將 C# 7 類庫升級到 C# 8(支援可空引用型別)的一個案例。本案例中使用的專案 Tortuga Anchor 由一組 MVVM 風格的基類、反射程式碼和各種實用程式函式組成。之所以選擇這個專案,是因為它很小,並且同時包含了慣用和不常用的 C# 模式。
關鍵要點
- 為每個專案啟用可空引用型別。
- 使用泛型時,可能需要禁用可空引用型別。
- 可以通過在本地變數中快取屬性來修復警告。
- 公開方法仍然需要進行 Null 引數檢查。
- .NET Framework 和.NET Core 的反序列化方式是不一樣的。
這篇文章將介紹將 C# 7 類庫升級到 C# 8(支援可空引用型別)的一個案例。本案例中使用的專案 Tortuga Anchor 由一組 MVVM 風格的基類、反射程式碼和各種實用程式函式組成。之所以選擇這個專案,是因為它很小,並且同時包含了慣用和不常用的 C# 模式。
專案設定
目前, 可空引用型別僅適用於.NET Standard 和.NET Core 專案。在 Visual Studio 2019 釋出時, 應該也支援.NET Framework。
在專案檔案中,新增或修改以下配置:
複製程式碼
</PropertyGroup> <LangVersion>8.0</LangVersion> <NullableContextOptions>enable</NullableContextOptions> </PropertyGroup>
在儲存檔案後,應該會看到可空性錯誤。如果沒有看到,請嘗試構建專案。
指示一個型別可以為空
在介面方法 GetPreviousValue 中,返回型別可以為空。為了顯式地說明這一點,可以在 object 後面跟上可空型別修飾符(?)。
複製程式碼
object?GetPreviousValue(stringpropertyName);
使用這個型別修飾符註解變數、引數和返回型別,就可以解決專案中的很多編譯器錯誤。
延遲載入屬性
如果一個屬性的求值成本非常高,可以使用延遲載入模式。在使用這個模式時,如果私有欄位為空,表示尚未生成欄位的值。
C# 8 可以很好地處理這種情況。在不改變程式碼的情況下,它能夠正確地分析程式碼,以確定 getter 的結果將始終非空,儘管返回的變數可以為空。
複製程式碼
string? m_CSharpFullName; publicstringCSharpFullName { get { if(m_CSharpFullName ==null) { varresult =newStringBuilder(m_TypeInfo.ToString().Length); BuildCSharpFullName(m_TypeInfo.AsType(),null, result); m_CSharpFullName = result.ToString(); } returnm_CSharpFullName; } }
需要注意的是,這裡存在潛在的競態條件。理論上,另一個執行緒可以將 m_CSharpFullName 的值設定回 null,而編譯器無法檢測到。因此,在處理多執行緒程式碼時要特別小心。
一個變數的可空性由另一個變數決定
在下一個程式碼示例中,當且僅當 m_ItemPropertyChanged 不為空時,m_ListeningToItemEvents 才為 true。編譯器無法知道這個規則。如果是這種情況,你可以將(!)附加到變數(在本例中為 m_ItemPropertyChanged)後面,表示它在這個時間點不會為空。
複製程式碼
if(m_ListeningToItemEvents) { if(itemisINotifyPropertyChangedWeak) ((INotifyPropertyChangedWeak)item).AddHandler(m_ItemPropertyChanged!); elseif(itemisINotifyPropertyChanged) ((INotifyPropertyChanged)item).PropertyChanged += OnItemPropertyChanged; }
使用顯式強制轉換糾正誤報
在下一個示例中,編譯器錯誤地報告了 m_Base 的可空性。Values 與 IEnumerable
複製程式碼
readonly Dictionary<ValueTuple<TKey1, TKey2>, TValue> m_Base; IEnumerable<TValue>IReadOnlyDictionary<ValueTuple<TKey1, TKey2>, TValue>.Values { get{ return (IEnumerable<TValue>)m_Base.Values; } }
請注意編譯器將該行標記為具有冗餘強制轉換。這是正常的編譯器訊息,而不是警告,但希望在釋出時能夠得到更正。
使用臨時變數或條件強制轉換糾正誤報
在下一個示例中,編譯器指出 CancelEdit 所在行存在一個錯誤。雖然前面的 if 語句證明 item.Value 不為空,但編譯器不相信下次讀取 item.Value 時它仍然是不為空。
複製程式碼
foreach (variteminm_CheckpointValues) { if(item.ValueisIEditableObject) ((IEditableObject)item.Value).CancelEdit(); }
我們可以將 item.Value 儲存在一個臨時變數中。
複製程式碼
foreach(variteminm_CheckpointValues) { object?value= item.Value; if(valueisIEditableObject) ((IEditableObject)value).CancelEdit(); }
對於這種情況,我們可以通過使用條件轉換(as 操作符)後面跟上一個條件方法呼叫(?. 操作符)進一步簡化它。
複製程式碼
foreach (variteminm_CheckpointValues) { (item.ValueasIEditableObject)?.CancelEdit(); }
泛型和可空型別
如果你經常使用泛型,可能會遇到一個有問題的可空型別。看一下這個 delegate:
複製程式碼
publicdelegatevoidValueChanged<inT>(T oldValue, T newValue);
這個 delegate 的預期設計是 oldValue 和 newValue 都可以為空。所以,你會認為加幾個問號就可以解決問題。但是,這樣做會返回下面這樣的錯誤訊息:
Error CS8627 可空型別引數必須是值型別或非可空的引用型別。可以考慮新增“class”、“struct”或型別約束。
如果你需要同時支援值型別和引用型別,那麼這個問題就沒那麼容易解決。由於你無法在型別約束中表達“or”,你需要一個用於類的 delegate 和一個用於結構體的 delegate。
複製程式碼
publicdelegatevoidValueChanged<inT>(T? oldValue, T? newValue)whereT :class; publicdelegatevoidValueChanged<T>(T? oldValue, T? newValue)whereT :struct;
但是,這樣不起作用,因為兩個 delegate 具有相同的名稱。你可以給它們起不一樣的名稱,但你必須複製使用它們的程式碼。
所幸的是,C# 有一個轉義值。你可以使用 #nullable 指令恢復成 C #7 的語義,這樣就可以達到預期的效果。
複製程式碼
#nullable disable publicdelegatevoidValueChanged<in T>(T oldValue, T newValue); #nullable enable
這種方法並非沒有缺陷。禁用可空引用可能是個好東西,但也可能什麼都不是。你無法用它來讓 oldValue 變成可空或讓 newValue 變成不可空。
建構函式、反序列化器和初始化方法
對於下一個示例,你必須知道序列化器的一些技巧。有一個鮮為人知的函式用來繞過一個叫作 FormatterServices.GetUninitializedObject 的類建構函式。一些序列化器(如 DataContractSerializer)使用它來提高效能。
如果你總是要執行建構函式中的邏輯,應該怎麼辦?這個時候需要用到 OnDeserializing 屬性。這個屬性充當在 GetUninitializedObject 之後呼叫的代理建構函式。
為了減少冗餘和出錯的可能性,開發人員通常會使用常見的初始化方法,如下面的程式碼所示。
複製程式碼
protectedAbstractModelBase() { Initialize(); } [OnDeserializing] void_ModelBase_OnDeserializing(StreamingContextcontext) { Initialize(); } voidInitialize() { m_PropertyChangedEventManager =newPropertyChangedEventManager(this); m_Errors =newErrorsDictionary(); }
這對 null 檢查器來說是個問題。由於建構函式中沒有顯式地設定上述兩個變數,因此它會把它們標記為未初始化。這意味著需要進行一些複製貼上工作來移除這個錯誤。
還有一個風險,那就是忘記包含 OnDeserializing 方法。由於 null 檢查器不理解 OnDeserializing 方法,因此如果出現意外空值就無法提醒你。
大多數開發人員發現這種行為令人困惑。因此, 在.NET Core 中,DataContractSerializer 將呼叫建構函式。 但這意味著如果你的目標是.NET Standard, 則需要使用.NET Framework 和.NET Core 測試反序列化程式碼,以理解不同的行為。
可空引數和 CallerMemberName
這個庫大量使用了 CallerMemberName 模式。根據它使用的屬性命名,基本思想是在方法的末尾新增一個可選引數。編譯器將看到 CallerMemberName,並隱式地為該引數提供一個值。
複製程式碼
publicoverrideboolIsDefined([CallerMemberName]stringpropertyName =null)
從理論上講,propertyNameparameter 可以顯式設定為 null,但人們普遍認為不應該這樣做,因為這樣可能會發生意外的錯誤。
將這行程式碼轉換為 C# 8 時,可能會想要將引數標記為可空。這樣具有誤導性,因為這個方法實際上並不是為處理空值而設計的。相反,你應該用空字串替換 null。
複製程式碼
publicoverrideboolIsDefined([CallerMemberName]stringpropertyName ="")
還需要空引數檢查嗎?
如果要構建公共庫(即 NuGet),那麼是的,所有公開方法仍然需要檢查空引數。使用庫的應用程式可能不一定會使用可空引用型別。事實上,他們甚至可能根本不使用 C# 8。
如果你的所有應用程式程式碼都使用了可空引用型別,那麼答案仍然是“可能是”。雖然從理論上講,你不會看到任何意外的空值,但由於動態程式碼、反射或誤用(!)操作符,它們仍然可能會出現。
結論
在一個只有不到 60 個類檔案的專案中,其中 24 個類檔案需要更改。但沒有一個是特別重要的,整個過程花了不到一個小時。總之,這是一個無痛的過程,大多數事情都像預期的那樣。我希望大多數專案都能從這個特性中獲益,並且在 C# 8 釋出後就應該使用這個特性。
關於作者
Jonathan Allen在 90 年代後期開始為一家醫療診所做 MIS 專案,逐步將 Access 和 Excel 應用到企業解決方案中。在花了五年時間為金融行業編寫自動化交易系統之後,他成為了多個專案的顧問,其中包括機器人倉庫的 UI、癌症研究軟體的中間層,以及一家大型房地產保險公司對大資料的需求。在他的空閒時間,他喜歡學習和寫作與 16 世紀武術相關的東西。
英文原文: https://www.infoq.com/articles/csharp-nullable-reference-case-study