1. 程式人生 > >【譯】嘗試使用Nullable Reference Types

【譯】嘗試使用Nullable Reference Types

隨著.NET Core 3.0 Preview 7的釋出,C#8.0已被認為是“功能完整”的。這意味著它們的最大亮點Nullable Reference Types,在行為方面也被鎖定在.NET Core版本中。它將在C#8.0之後繼續改進,但現在可以認為它與C#8.0的其餘部分一樣是穩定的。

目前,我們的目標是儘可能多地收集關於可空性使用過程中的反饋以發現問題,同時收集有關在.NET Core 3.0之後我們可以做的功能的進一步改進的反饋。這是有史以來為C#構建的最大功能之一,儘管我們已盡力做好它,但我們仍然需要您的幫助!

正是基於這樣的交叉點,我們特別呼籲.NET庫作者們嘗試使用該功能並開始註解您的庫。我們很樂意聽取您的反饋並幫助解決您所遇到的任何問題。

熟悉該功能

我們建議您在使用該功能之前,先閱讀一下Nullable Reference Types文件,它包含以下功能點:

  • 概念性概述
  • 如何指定可為空的引用型別
  • 如何控制編譯器分析或覆蓋編譯器分析

如果您還不熟悉這些概念,請在繼續操作之前快速閱讀文件。

為您的庫採用可空性的第一步是放開Nullable約束。具體步驟:

確保您使用的是C#8.0

如果您的庫是基於netcoreapp3.0的,預設情況下將使用C#8.0。當我們釋出預覽8時,如果你是基於netstandard2.1構建,那麼預設情況也將使用C#8.0 。

.NET Standard本身還沒有任何可空的註解。如果您的目標是.NET Standard,即使您不需要.NET Core特定的API,您仍然可以使用.NET標準和NetCoreApp3.0的多目標。好處是編譯器將使用CoreFX中的可空註解來幫助您(在.NET Standard專案中)正確的獲取自己的註解。

如果由於某種原因無法更新TFM,可以LangVersion明確設定:

   1:  <PropertyGroup>
   2:   
   3:  <LangVersion>8.0</LangVersion>
   4:   
   5:  </PropertyGroup>

請注意,C#8.0不適用於較舊的Framework Target,例如.NET Core 2.x或.NET Framework 4.x. 因此,除非您的目標是.NET Core 3.0或.NET Standard 2.1,否則其他語言(版本)功能可能無法使用。

建議採用兩種通用方法來採用可空性

選擇專案,選擇退出檔案

此方法最適用於新檔案頻繁新增的專案,過程很簡單:

1、以下屬性應用於專案檔案:

   1:  <PropertyGroup>
   2:      <Nullable>enable</Nullable>
   3:  </PropertyGroup>

2、通過將此項新增到專案中每個現有檔案的頂部,可以(選擇性)用該專案的每個檔案中的可空性:

   1:  #nullable disable

3、擇一個檔案,刪除該#nullable disable指令,然後修復警告。重複操作直到所有#nullable disable指令都被刪除。

這種方法需要更多的前期工作,但這意味著您可以在移植時繼續在庫中工作,並確保任何新檔案自動選擇為可空性。這是我們通常建議的方法,我們目前在一些自己的程式碼庫中使用它。

一次選擇一個檔案

這種方法與前一種方法相反。

1、通過將此項新增到檔案頂部,為專案的檔案啟用可空性:

   1:  #nullable disable

2、繼續將其新增到其他檔案中,直到所有檔案都被註釋並且所有可空性警告都得到解決。

3、將以下屬性應用於專案檔案:

   1:  <PropertyGroup>
   2:      <Nullable>enable</Nullable>
   3:  </PropertyGroup>

4、刪除#nullable enable源中的所有指令。

這種方法最終需要更多工作,但它允許您立即開始修復可空性警告。

請注意,如果更適合您的工作流程,您還可以將該Nullable屬性應用於Directory.build.props檔案。

Preview7的Nullable引用型別有哪些新功能

該功能最重要的就是補充了用於處理泛型和更高階的API使用場景的工具。這些源於我們註解.NET Core的經驗。

notnull泛型約束

通常情況下,泛型是不允許為空的,如以下跟定介面:

   1:  interface IDoStuff<TIn, TOut>
   2:  {
   3:      TOut DoStuff(TIn input);
   4:  }

您可能希望僅支援不可為空的引用型別和值型別。所以代替string和int會好一點,但是如果使用了string?和int?就不應被代替了:

可以使用notnull約束來實現:

   1:  #nullable enable
   2:   
   3:  interface IDoStuff<TIn, TOut>
   4:      where TIn : notnull
   5:      where TOut : notnull
   6:  {
   7:      TOut DoStuff(TIn input);
   8:  }

如果實現類沒有同樣應用notnull約束,就會報出以下警告:

   1:  // Warning: CS8714 - Nullability of type argument 'TIn' doesn't match 'notnull' constraint.
   2:  // Warning: CS8714 - Nullability of type argument 'TOut' doesn't match 'notnull' constraint.
   3:  public class DoStuffer<TIn, TOut> : IDoStuff<TIn, TOut>
   4:  {
   5:      public TOut DoStuff(TIn input)
   6:      {
   7:          ...
   8:      }
   9:  }

為了修復這些警告,需要應用同樣的約束:

   1:  // No warnings!
   2:  public class DoStuffer<TIn, TOut> : IDoStuff<TIn, TOut>
   3:      where TIn : notnull
   4:      where TOut : notnull
   5:  {
   6:      TOut DoStuff(TIn input)
   7:      {
   8:          ...
   9:      }
  10:  }

當我們為那個類建立例項的時候,如果你使用了nullable引用型別,也會發生警告:

   1:  // Warning: CS8714 - Nullability of type argument 'string?' doesn't match 'notnull' constraint
   2:  var doStuffer = new DoStuff<string?, string?>();
   3:   
   4:  // No warnings!
   5:  var doStufferRight = new DoStuff<string, string>();

(上述警告)也適用於值型別:

   1:  // Warning: CS8714 - Nullability of type argument 'int?' doesn't match 'notnull' constraint
   2:  var doStuffer = new DoStuff<int?, int?>();
   3:   
   4:  // No warnings!
   5:  var doStufferRight = new DoStuff<int, int>();

對於那些您只想使用非空引用型別的泛型來說,這些約束是非常有用的。一個突出例子就是Dictionary<TKey, TValue>,TKey是空約束,TValue是非空約束

   1:  // Warning: CS8714 - Nullability of type argument 'string?' doesn't match 'notnull' constraint
   2:  var d1 = new Dictionary<string?, string>(10);
   3:   
   4:  // And as expected, using 'null' as a key for a non-nullable key type is a warning...
   5:  var d2 = new Dictionary<string, string>(10);
   6:   
   7:  // Warning: CS8625 - Cannot convert to non-nullable reference type.
   8:  var nothing = d2[null];

然而,並非所有泛型的可空性問題都可以通過這種方式解決。這是我們新增一些新屬性以允許您在編譯器中進行可空分析影響的地方。

T?的問題

你想知道:為什麼在指定可以用可空引用或值型別替換的泛型型別時“只”允許T?。不幸的是,答案很複雜。

通常T?意味著“任何可以為空的型別”。同時這意味著這T將意味著“任何非可空型別”,這不是真的!今天可以用可空值型別替換T (例如bool?)。這是因為T已經是一個不受約束的泛型型別。語義的這種變化可能是意料之外的,並且對於T用作無約束泛型型別的大量現有程式碼而言會引起一些悲痛。

其次,有一點非常重要就是,要注意可空引用型別和可空值型別是不一樣的。可以為Null的值型別對映到.NET中的具體類型別。所以int?實際上是Nullable<int>。但是string?,它實際上是相同的,string但有一個編譯器生成的屬性來註解它。這樣做是為了向後相容。換句話說,string?是一種假象,而int?不是。

可空值型別和可空引用型別之間的區別出現在以下模式中:

   1:  void M<T>(T? t) where T: notnull

這意味著該引數是可以為空的,並且T被約束為notnull。如果Tstring,則實際簽名M將是M<string>([NullableAttribute] T t),但如果T是a int,那麼M將是M<int>(Nullable<int> t)。這兩個簽名根本不同,而且這種差異是不可調和的。

由於可空引用型別和可空值型別的具體表示之間存在此問題,因此任何使用都T?必須要求您將其約束Tclass或者struct

您可能希望在一個方向上允許可以為空的型別(例如,僅作為輸入或輸出),並且不可以用notnull或t和t?表達。除非人為地為輸入和輸出新增單獨的泛型型別,否則就需要拆分。

Nullable的先決條件:AllowNull and DisallowNull

考慮如下程式碼:

   1:  public class MyClass
   2:  {
   3:      public string MyValue { get; set; }
   4:  }

這可能是我們在C#8.0之前支援的API。但是,string的含義現在意味著不可空string!我們可能希望實際上仍然允許null值,但總是會採用get返回string值。在這裡使用AllowNull可能會讓你感到有點迷惑:

   1:  public class MyClass
   2:  {
   3:      private string _innerValue = string.Empty;
   4:   
   5:      [AllowNull]
   6:      public string MyValue
   7:      {
   8:          get
   9:          {
  10:              return _innerValue;
  11:          }
  12:          set
  13:          {
  14:              _innerValue = value ?? string.Empty;
  15:          }
  16:      }
  17:  }

因為我們總是確保getter沒有空值,所以我希望保留型別string。但為了向後相容,我們仍然要接受空值。allownull屬性允許您指定setter接受空值。然後,呼叫方會像您預期的那樣受到影響:

   1:  void M1(MyClass mc)
   2:  {
   3:      mc.MyValue = null; // Allowed because of AllowNull
   4:  }
   5:   
   6:  void M2(MyClass mc)
   7:  {
   8:      Console.WriteLine(mc.MyValue.Length); // Also allowed, note there is no warning
   9:  }

注意:當前有一個bug,其中空值的賦值與可空分析存在衝突。這將在將來的編譯器更新中解決。

考慮另一個API:

   1:   
   2:  public static HandleMethods
   3:  {
   4:      public static void DisposeAndClear(ref MyHandle handle)
   5:      {
   6:          ...
   7:      }
   8:  }

在這種情況下,MyHandle指向的是資源控制代碼。這個API的典型用途是我們有一個非null例項,通過引用傳遞,但是當它被清除時,引用是null。這會幻讀並用以下方式表示DisallowNull

   1:  public static HandleMethods
   2:  {
   3:      public static void DisposeAndClear([DisallowNull] ref MyHandle? handle)
   4:      {
   5:          ...
   6:      }
   7:  }

如果呼叫方傳遞空值,會發出警告來告訴呼叫方,但如果在呼叫方法後嘗試“點”到控制代碼中,則會發出警告:

   1:  void M(MyHandle handle)
   2:  {
   3:      MyHandle? local = null; // Create a null value here
   4:      HandleMethods.DisposeAndClear(ref local); // Warning: CS8601 - Possible null reference assignment
   5:      
   6:      // Now pass the non-null handle
   7:      HandleMethods.DisposeAndClear(ref handle); // No warning! ... But the value could be null now
   8:      
   9:      Console.WriteLine(handle.SomeProperty); // Warning: CS8602 - Dereference of a possibly null reference
  10:  }

這兩個屬性允許我們在需要它們的情況下使用單向可空性或不可空性。

更正式的:

AllowNull屬性允許呼叫方傳遞空值,即使該型別不允許這樣做。DisAllowNull屬性不允許呼叫方傳遞null,即使該型別允許。它們可以在接受輸入的任何內容上指定:

  • 值引數 
  • in 標記的引數
  • ref 標記的引數
  • 欄位
  • 屬性
  • 索引

要點:這些屬性僅影響使用它們註解的呼叫者的方法的可空分析。註解的方法主體和介面實現類這些並不支援這些屬性。我們將來可能會對此提供支援。

可空的後置條件:MaybeNullNotNull

考慮一下範例API:

   1:  public class MyArray
   2:  {
   3:      // Result is the default of T if no match is found
   4:      public static T Find<T>(T[] array, Func<T, bool> match)
   5:      {
   6:          ...
   7:      }
   8:   
   9:      // Never gives back a null when called
  10:      public static void Resize<T>(ref T[] array, int newSize)
  11:      {
  12:          ...
  13:      }
  14:  }

這裡還有一個問題。對於引用型別為空的情況,如果Find()方法返回不出來內容,我們希望返回預設值。我們希望Resize以接受可能為空的輸入,但我們希望確保Resize呼叫的時候,引用傳遞的陣列值始終為非空。又一次,應用NotNull約束並不能解決這個問題。哎!!

現在我們可以想象一下輸出的可空性!可以這樣修改示例:

   1:  public class MyArray
   2:  {
   3:      // Result is the default of T if no match is found
   4:      [return: MaybeNull]
   5:      public static T Find<T>(T[] array, Func<T, bool> match)
   6:      {
   7:          ...
   8:      }
   9:   
  10:      // Never gives back a null when called
  11:      public static void Resize<T>([NotNull] ref T[]? array, int newSize)
  12:      {
  13:          ...
  14:      }
  15:  }

現在這些可以影響呼叫方:

   1:  void M(string[] testArray)
   2:  {
   3:      var value = MyArray.Find<string>(testArray, s => s == "Hello!");
   4:      Console.WriteLine(value.Length); // Warning: Dereference of a possibly null reference.
   5:   
   6:      MyArray.Resize<string>(ref testArray, 200);
   7:      Console.WriteLine(testArray.Length); // Safe!
   8:  }

第一個方法指定返回的T可以是空值。這意味著此方法的呼叫方在使用其結果時必須檢查是否為空。

第二個方法有一個更復雜的簽名: [NotNull] ref T[]? 陣列。這意味著作為輸入的陣列可以為空,但當呼叫Resize時,陣列不可以為空。這意味著,如果您在呼叫Resize後“點”到陣列中,將不會收到警告。但呼叫Resize後,陣列將不再為空。

後置條件:MaybeNullWhen(bool)NotNullWhen(bool)

該類除了實現Load方法外,還會根據ReloadOnChange屬性,在建構函式中註冊OnChange事件,用於重新載入配置資訊,原始碼如下:

請考慮如下示例:

   1:  public class MyString
   2:  {
   3:      // True when 'value' is null
   4:      public static bool IsNullOrEmpty(string? value)
   5:      {
   6:          ...
   7:      }
   8:  }
   9:   
  10:  public class MyVersion
  11:  {
  12:      // If it parses successfully, the Version will not be null.
  13:      public static bool TryParse(string? input, out Version? version)
  14:      {
  15:          ...
  16:      }
  17:  }
  18:   
  19:  public class MyQueue<T>
  20:  {
  21:      // 'result' could be null if we couldn't Dequeue it.
  22:      public bool TryDequeue(out T result)
  23:      {
  24:          ...
  25:      }
  26:  }

以上方法在.NET中隨處可見,其中true或false的返回值對應於引數的可空性(或可能的可空性)。MyQueue案例也有點特殊,因為它是通用的。如果結果為false,則TrydeQueue應為result提供空值,但僅當T是引用型別時才提供空值。如果T是一個結構體,則它不會為空。

所以,我想做以下三件事情:

  1. 如果IsNullOrEmpty返回false, 那麼值為非空
  2. 如果TryParse返回true, 那麼version為非空
  3. 如果TryDequeue返回false, 那麼result可以是null, 前提是它是引用型別

不幸的是,C編譯器不會將方法的返回值與其某個引數的可空性相關聯!

輸入NotNullWhen(bool)和MaybeNullWhen(bool). 現在,我們可以用以下引數更進一步:

   1:  public class MyString
   2:  {
   3:      // True when 'value' is null
   4:      public static bool IsNullOrEmpty([NotNullWhen(false)] string? value)
   5:      {
   6:          ...
   7:      }
   8:  }
   9:   
  10:  public class MyVersion
  11:  {
  12:      // If it parses successfully, the Version will not be null.
  13:      public static bool TryParse(string? input, [NotNullWhen(true)] out Version? version)
  14:      {
  15:          ...
  16:      }
  17:  }
  18:   
  19:  public class MyQueue<T>
  20:  {
  21:      // 'result' could be null if we couldn't Dequeue it.
  22:      public bool TryDequeue([MaybeNullWhen(false)] out T result)
  23:      {
  24:          ...
  25:      }
  26:  }

可以影響到呼叫方:

   1:  void StringTest(string? s)
   2:  {
   3:      if (MyString.IsNullOrEmpty(s))
   4:      {
   5:          // This would generate a warning:
   6:          // Console.WriteLine(s.Length);
   7:          return;
   8:      }
   9:   
  10:      Console.WriteLine(s.Length); // Safe!
  11:  }
  12:   
  13:  void VersionTest(string? s)
  14:  {
  15:      if (!MyVersion.TryParse(s, out var version))
  16:      {
  17:          // This would generate a warning:
  18:          // Console.WriteLine(version.Major);
  19:          return;
  20:      }
  21:   
  22:      Console.WriteLine(version.Major); // Safe!
  23:  }
  24:   
  25:  void QueueTest(MyQueue<string> q)
  26:  {
  27:      if (!q.TryDequeue(out var s))
  28:      {
  29:          // This would generate a warning:
  30:          // Console.WriteLine(s.Length);
  31:          return;
  32:      }
  33:   
  34:      Console.WriteLine(s.Length); // Safe!
  35:  }

這使得呼叫者可以使用與以前相同的模式來處理API,而不需要編譯器發出任何假的警告:

  • 如果IsNullOrEmpty是true, “點”進去就是安全的
  • 如果TryParse是true, version會被解析並被安全“點”進去
  • 如果TryDequeue是false, 則結果可能為空,需要進行檢查(例如:當型別為結構體時返回false為非空,而對於引用型別為false則意味著它可能為空)

NotNullWhen(bool)表示即使型別允許,引數也不能為空,條件是該方法的bool返回值。MaybeNullWhen(bool)表示即使型別不允許引數為空,引數也可以為空,條件也是該方法的bool返回值。它們可以在任何引數型別上指定。

輸入和輸出之間的空相關性

NotNullIfNotNull(string)

如下範例

   1:  class MyPath
   2:  {
   3:      public static string? GetFileName(string? path)
   4:      {
   5:          ...
   6:      }
   7:  }

在這種情況下,我們希望返回一個可能為空的字串,並且我們還應該能夠接受一個空值作為輸入。所以這個方法簽名完成了我想要表達的。

但是,如果路徑不為空,我們希望確保始終返回一個字串。也就是說,我們希望getFileName的返回值不為空,以路徑為空為條件。這是無法表達的。

輸入NotNullIfNotNull(字串)。這個屬性可以使您的程式碼異常複雜,所以小心使用它!以下是在我的API中使用它的方法:

   1:  class MyPath
   2:  {
   3:      [return: NotNullIfNotNull("path")]
   4:      public static string? GetFileName(string? path)
   5:      {
   6:          ...
   7:      }
   8:  }

對呼叫方的影響

   1:  void PathTest(string? path)
   2:  {
   3:      var possiblyNullPath = MyPath.GetFileName(path);
   4:      Console.WriteLine(possiblyNullPath.Length); // Warning: Dereference of a possibly null reference
   5:      
   6:      if (!string.IsNullOrEmpty(path))
   7:      {
   8:          var goodPath = MyPath.GetFileName(path);
   9:          Console.WriteLine(goodPath.Length); // Safe!
  10:      }
  11:  }

NotNullIfNotNull(string)屬性表示任何輸出值都是非空的,條件是指定名稱的給定引數可以為空。可以參考如下指定:

  • 方法返回值
  • ref標記的引數

流特性:DoesNotReturnDoesNotReturnIf(bool)

您可以您的程式中使用影響控制流的多種方法。例如,一個異常幫助器方法,如果呼叫,它將引發異常;或者一個斷言方法,如果輸入為真或假,它將引發異常。

您可能希望做一些類似斷言一個值是非空的事情,我們認為如果編譯器能夠理解的話,您也會喜歡它。

輸入DoesNotReturn 和DoesNotReturnIf(bool)。下面是一個示例,可以選用以下兩種方法之一:

   1:  internal static class ThrowHelper
   2:  {
   3:      [DoesNotReturn]
   4:      public static void ThrowArgumentNullException(ExceptionArgument arg)
   5:      {
   6:          ...
   7:      }
   8:  }
   9:   
  10:  public static class MyAssertionLibrary
  11:  {
  12:      public static void MyAssert([DoesNotReturnIf(false)] bool condition)
  13:      {
  14:          ...
  15:      }
  16:  }

當在方法中呼叫ThrowArgumentNullException時,它將引發異常。DoesNotReturn向編譯器發出一個訊號,說明在該點之後不需要進行可以為空的分析,因為程式碼是不可訪問的。

當呼叫MyAssert並且傳遞給它的條件為false時,它將引發異常。條件引數使用了DoesNotReturnIf(false)註解以使編譯器知道,如果條件為false,程式流將不會繼續。如果要斷言值的可空性,這將很有用。在MyAssert後面的程式碼路徑中(值!=null);編譯器可以假定值不是null。

不能在方法上使用DoesNotReturn。 DoesNotReturnIf(bool)可用於輸入引數。

註解的演進

一旦註解了公共API,您將需要考慮更新API可能會產生下游影響的情況:

  • 在沒有任何註解的地方新增可為空的註釋可能會給使用者程式碼帶來警告。
  • 刪除可為空的註釋也會引入警告(例如,介面實現)

可以為空的註解是公共API不可分割的一部分。新增或刪除註解會引入新的警告。我們建議從預覽版開始,在預覽版中徵求反饋意見,目的是在完整發布後不更改任何註解。雖然通常情況下不太可能,但我們還是建議這樣做。

Microsoft框架和庫的當前狀態

因為可以為空的引用型別是新的,所以大多數微軟編寫的C#框架和庫還沒有被適當的註解。

也就是說,.NET Core的“Core Lib”部分(約佔.NET核心共享框架的20%)已經完全更新。它包括諸如System、System.IO和System.Collections.Generic這樣的名稱空間。我們正在尋找對我們這些決策的反饋,以便我們能夠在它們的廣泛之前儘快做出適當的調整。

儘管仍有約80%的corefx需要註釋,但大多數使用的API都是完全註釋的。

空引用型別的路線圖

當前,我們將完全可以為空的引用型別體驗視為處於預覽狀態。它是穩定的,但是將這個特性廣泛應用到到在我們自己的技術和更大的.NET生態系統中,需要一些時間來完成。

也就是說,我們鼓勵庫開發者現在就開始為他們的庫做註解。這個特性只會隨著更多的庫採用空特性而變得更好,從而幫助.NET成為一個更加空-安全的語言。

在未來一年左右的時間裡,我們將繼續改進這個特性,並將其應用到整個Microsoft框架和庫中。

對於該語言,特別是編譯器分析,我們將進行大量的增強,以便儘可能減少您需要做的事情,如使用空-容錯操作。其中許多增強功能已經在Roslyn上進行了跟蹤。

對於corefx,我們將對剩下的大約80%的API進行註解,並根據反饋進行適當的調整。

對於ASP.NET Core和Entity Framework,我們將在添加了一些新的CoreFX 和編譯器特性之後對公共API進行註解。

我們還沒有計劃如何註釋WinForms和WPF APIs,但我們很高興聽到您對這些事情重要的反饋!

最後,我們將繼續在Visual Studio中增強C#工具。我們對功能有多種想法來幫助使用該功能,但我們也希望您能提供寶貴意見!

下一步

如果您仍在閱讀,並且沒有嘗試過在您的程式碼中使用這個功能,特別是您的庫程式碼,就請嘗試一下,並就您認為應該有所不同的內容向我們提供反饋。在.NET中使無法預料到的NullReferenceExceptions異常的消失就是一個漫長的過程,但我們希望從長遠來看,開發人員不再需要擔心被隱式的空值咬到。你可以幫助我們。嘗試並開始註解您的庫。對你的經驗的反饋將有助於縮短這段旅程。

原文:https://devblogs.microsoft.com/dotnet/try-out-nullable-reference-types/

作者:DotNet Core圈圈
文章來自DotNET圈圈,版權歸原作者,在轉載時,請務必保留本版權宣告和二維碼。

分享.NET Core原始碼研究成果,並持續關注微服務、DevOps以及容器領域。願我們共同努力,推動.NET生態的完善,促進.NET社群的進步