如何將C# 7類庫升級到C# 8?使用可空引用型別

weixin_34253539發表於2019-03-02

這篇文章將介紹將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。

在專案檔案中,新增或修改以下配置:

\u0026lt;/PropertyGroup\u0026gt;    \u0026lt;LangVersion\u0026gt;8.0\u0026lt;/LangVersion\u0026gt;    \u0026lt;NullableContextOptions\u0026gt;enable\u0026lt;/NullableContextOptions\u0026gt;\u0026lt;/PropertyGroup\u0026gt;

在儲存檔案後,應該會看到可空性錯誤。如果沒有看到,請嘗試構建專案。

指示一個型別可以為空

在介面方法GetPreviousValue中,返回型別可以為空。為了顯式地說明這一點,可以在object後面跟上可空型別修飾符(?)。

object? GetPreviousValue(string propertyName);

使用這個型別修飾符註解變數、引數和返回型別,就可以解決專案中的很多編譯器錯誤。

延遲載入屬性

如果一個屬性的求值成本非常高,可以使用延遲載入模式。在使用這個模式時,如果私有欄位為空,表示尚未生成欄位的值。

C# 8可以很好地處理這種情況。在不改變程式碼的情況下,它能夠正確地分析程式碼,以確定getter的結果將始終非空,儘管返回的變數可以為空。

string? m_CSharpFullName;public string CSharpFullName{    get    {        if (m_CSharpFullName == null)        {            var result = new StringBuilder(m_TypeInfo.ToString().Length);            BuildCSharpFullName(m_TypeInfo.AsType(), null, result);            m_CSharpFullName = result.ToString();        }        return m_CSharpFullName;    }}

需要注意的是,這裡存在潛在的競態條件。理論上,另一個執行緒可以將m_CSharpFullName的值設定回null,而編譯器無法檢測到。因此,在處理多執行緒程式碼時要特別小心。

一個變數的可空性由另一個變數決定

在下一個程式碼示例中,當且僅當m_ItemPropertyChanged不為空時,m_ListeningToItemEvents才為true。編譯器無法知道這個規則。如果是這種情況,你可以將(!)附加到變數(在本例中為m_ItemPropertyChanged)後面,表示它在這個時間點不會為空。

if (m_ListeningToItemEvents){    if (item is INotifyPropertyChangedWeak)        ((INotifyPropertyChangedWeak)item).AddHandler(m_ItemPropertyChanged!);    else if (item is INotifyPropertyChanged)        ((INotifyPropertyChanged)item).PropertyChanged += OnItemPropertyChanged;}

使用顯式強制轉換糾正誤報

在下一個示例中,編譯器錯誤地報告了m_Base的可空性。Values與IEnumerable的值不相容。要移除這個警告,我新增了顯式強制轉換。

readonly Dictionary\u0026lt;ValueTuple\u0026lt;TKey1, TKey2\u0026gt;, TValue\u0026gt; m_Base;IEnumerable\u0026lt;TValue\u0026gt; IReadOnlyDictionary\u0026lt;ValueTuple\u0026lt;TKey1, TKey2\u0026gt;, TValue\u0026gt;.Values{    get { return (IEnumerable\u0026lt;TValue\u0026gt;)m_Base.Values; }}

請注意編譯器將該行標記為具有冗餘強制轉換。這是正常的編譯器訊息,而不是警告,但希望在釋出時能夠得到更正。

使用臨時變數或條件強制轉換糾正誤報

在下一個示例中,編譯器指出CancelEdit所在行存在一個錯誤。雖然前面的if語句證明item.Value不為空,但編譯器不相信下次讀取item.Value時它仍然是不為空。

foreach (var item in m_CheckpointValues){    if (item.Value is IEditableObject)        ((IEditableObject)item.Value).CancelEdit();}

我們可以將item.Value儲存在一個臨時變數中。

foreach (var item in m_CheckpointValues){    object? value = item.Value;    if (value is IEditableObject)        ((IEditableObject)value).CancelEdit();}

對於這種情況,我們可以通過使用條件轉換(as操作符)後面跟上一個條件方法呼叫(?.操作符)進一步簡化它。

foreach (var item in m_CheckpointValues){    (item.Value as IEditableObject)?.CancelEdit();}

泛型和可空型別

如果你經常使用泛型,可能會遇到一個有問題的可空型別。看一下這個delegate:

public delegate void ValueChanged\u0026lt;in T\u0026gt;(T oldValue, T newValue);

這個delegate的預期設計是oldValue和newValue都可以為空。所以,你會認為加幾個問號就可以解決問題。但是,這樣做會返回下面這樣的錯誤訊息:

Error CS8627 可空型別引數必須是值型別或非可空的引用型別。可以考慮新增“class”、“struct”或型別約束。

如果你需要同時支援值型別和引用型別,那麼這個問題就沒那麼容易解決。由於你無法在型別約束中表達“or”,你需要一個用於類的delegate和一個用於結構體的delegate。

public delegate void ValueChanged\u0026lt;in T\u0026gt;(T? oldValue, T? newValue) where T : class;public delegate void ValueChanged\u0026lt;T\u0026gt;(T? oldValue, T? newValue) where T : struct;

但是,這樣不起作用,因為兩個delegate具有相同的名稱。你可以給它們起不一樣的名稱,但你必須複製使用它們的程式碼。

所幸的是,C#有一個轉義值。你可以使用#nullable指令恢復成C #7的語義,這樣就可以達到預期的效果。

#nullable disablepublic delegate void ValueChanged\u0026lt;in T\u0026gt;(T oldValue, T newValue);#nullable enable

這種方法並非沒有缺陷。禁用可空引用可能是個好東西,但也可能什麼都不是。你無法用它來讓oldValue變成可空或讓newValue變成不可空。

建構函式、反序列化器和初始化方法

對於下一個示例,你必須知道序列化器的一些技巧。有一個鮮為人知的函式用來繞過一個叫作FormatterServices.GetUninitializedObject的類建構函式。一些序列化器(如DataContractSerializer)使用它來提高效能。

如果你總是要執行建構函式中的邏輯,應該怎麼辦?這個時候需要用到OnDeserializing屬性。這個屬性充當在GetUninitializedObject之後呼叫的代理建構函式。

為了減少冗餘和出錯的可能性,開發人員通常會使用常見的初始化方法,如下面的程式碼所示。

protected AbstractModelBase(){    Initialize();} [OnDeserializing]void _ModelBase_OnDeserializing(StreamingContext context){    Initialize();}void Initialize(){    m_PropertyChangedEventManager = new PropertyChangedEventManager(this);    m_Errors = new ErrorsDictionary();}

這對null檢查器來說是個問題。由於建構函式中沒有顯式地設定上述兩個變數,因此它會把它們標記為未初始化。這意味著需要進行一些複製貼上工作來移除這個錯誤。

還有一個風險,那就是忘記包含OnDeserializing方法。由於null檢查器不理解OnDeserializing方法,因此如果出現意外空值就無法提醒你。

大多數開發人員發現這種行為令人困惑。因此,在.NET Core中,DataContractSerializer將呼叫建構函式。但這意味著如果你的目標是.NET Standard,則需要使用.NET Framework和.NET Core測試反序列化程式碼,以理解不同的行為。

可空引數和CallerMemberName

這個庫大量使用了CallerMemberName模式。根據它使用的屬性命名,基本思想是在方法的末尾新增一個可選引數。編譯器將看到CallerMemberName,並隱式地為該引數提供一個值。

public override bool IsDefined([CallerMemberName] string propertyName = null)

從理論上講,propertyNameparameter可以顯式設定為null,但人們普遍認為不應該這樣做,因為這樣可能會發生意外的錯誤。

將這行程式碼轉換為C# 8時,可能會想要將引數標記為可空。這樣具有誤導性,因為這個方法實際上並不是為處理空值而設計的。相反,你應該用空字串替換null。

public override bool IsDefined([CallerMemberName] string propertyName = \u0026quot;\u0026quot;)

還需要空引數檢查嗎?

如果要構建公共庫(即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

相關文章