【译】尝试使用Nullable Reference Types

本文来自DotNET Core圈圈,由艾心翻译

随着.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圈圈,版权归原作者,在转载时,请务必保留本版权声明和二维码。

原文发布于微信公众号 - DotNet Core圈圈(gh_2705b57d3bc5)

原文发表时间:2019-08-08

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券