【譯】嘗試使用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 Core圈圈,版權歸原作者,在轉載時,請務必保留本版權聲明和二維碼。

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM