2015 年 12 月

第 30 卷,第 13 期

必备 .NET - 设计 C# 7

作者 Mark Michaelis | 2015 年 12 月

Mark Michaelis当您阅读本文时,C# 7 设计团队已讨论、规划、实验和计划了大约一年之久。在本期中,我将介绍他们一直在探讨的一些观点示例。

在查看时,请注意目前这些仍是要在 C# 7 中体现的观点。虽然一些观点只是经过了团队讨论阶段,但另一些观点已进入了实验实现阶段。无论如何,所有这些概念均尚未最终敲定;很多观点可能会夭折;甚至是那些已经进入后期阶段的观点也可能会在最终确定语言的最后几个阶段被推翻。

声明可以为 null 和不可以为 null 的引用类型

C# 7 讨论中涌现的一些最重要的观点也许与进一步改进 null 的处理方式有关,类似于 C# 6.0 的 null 条件运算符。其中最简单的一项改进可能是,在执行编译器或分析器验证(访问可以为 null 的类型实例)之前,先检查类型实际上是否是不可以为 null 的类型。

如果需要不可以为 null 的引用类型,且您能够完全避免 null,会怎样? 此观点旨在声明引用类型是会允许 null (string?),还是会避免 null (string!)。从理论上讲,甚至可以假定新代码中的所有引用类型声明默认情况下都不可以为 null。然而,正如与我合著“必备 C# 6.0”一书的作者 Eric Lippert 所指出的,确保在编译时引用类型永远不为 null 极为困难 (bit.ly/1Rd5ekS)。即便如此,还是可以确定类型可能为 null且尚未取消引用的的情况,而无需检查是否是不可以为 null 的类型。或者,也可能发生以下情况:类型可能被分配为 null,尽管声明意图是分配不可以为 null 的类型。

为了扩大受益范围,设计团队正在讨论能否对参数使用不可以为 null 的类型声明,以便自动生成 null 检查(尽管这可能会成为一项可选决定,以避免任何非预期的性能降低,除非可以在编译时这样做)。

(讽刺的是,C# 2.0 添加了可以为 null 的值类型,因为在很多情况下(如从数据库中检索的数据),有必要让整数包含 null 值。现在,在 C# 7 中,设计团队正在考虑支持相反的引用类型。)

对于不可以为 null 的类型(例如,string! 文本)的引用类型支持,另一个有趣考虑是公共中间语言 (CIL) 中的实现情况。两个最常用的方案是将它映射到 NonNullable<T> 类型语法,或者像在 [Nullable] 字符串文本中一样利用属性。后者是目前的首选方法。

元组

元组是设计团队考虑为 C# 7 添加的另一项功能。此主题已在早期语言版本中多次被提出,但仍未予以落实。根据此观点,可以在集合中声明类型,这样声明中就能包含多个值;同样地,方法也可以返回多个值。若要理解此概念,请查看下面的示例代码:

public class Person
{
  public readonly (string firstName, int lastName) Names; // a tuple
  public Person((string FirstName, string LastName)) names, int Age)
  {
    Names = names;
  }
}

如列表所示,借助元组支持,您可以将类型声明为元组,其中包含两个或多个值。可以在使用数据类型的所有情景(包括字段、参数、变量声明或方法返回)中利用此功能。例如,下面的代码片段会从方法中返回元组:

public (string FirstName, string LastName) GetNames(string! fullName)
{
  string[] names = fullName.Split(" ", 2);
  return (names[0], names[1]);
}
public void Main()
{
  // ...
  (string first, string last) = GetNames("Inigo Montoya");
  // ...
}

在此列表中,有返回元组的方法,以及 GetNames 结果被分配到的第一个和最后一个变量声明。请注意,此分配是基于元组内的顺序(而不是接收变量的名称)。想想我们目前使用的一些替代方法(如数组、集合、自定义类型或输出参数),元组确实具有吸引力。

可以将许多选项与元组结合使用。下面介绍了一些审议选项:

  • 元组可以有命名或未命名的属性,如下所示:
var name = ("Inigo", "Montoya")

和:

var name = (first: "John", last: "Doe")
  • 结果可以是匿名类型或显式变量,如下所示:
var name = (first: "John", last: "Doe")

或:

(string first, string last) = GetNames("Inigo Montoya")
  • 您可以将数组转换成元组,如下所示:
var names = new[]{ "Inigo", "Montoya" }
  • 您可以按名称访问各个元组项,如下所示:
Console.WriteLine($”My name is { names.first } { names.last }.”);
  • 可以推断未明确指定的数据类型(大体上遵循匿名类型使用的相同方法)

尽管元组还有很多复杂之处,但在大多数情况下,元组遵循的是语言内架构完善的结构,所以它们可以强有力地支持 C# 7 中的功能。

模式匹配

模式匹配也是 C# 7 设计团队经常讨论的主题。或许,关于模式匹配的一种更易理解的呈现是,在 case 语句中支持表达式模式(而不仅仅是常量)的扩展 switch(和 if)语句。(若要与扩展 case 语句对应,switch 表达式类型不能局限于拥有对应的常量值的类型)。借助模式匹配,您可以查询模式的 switch 表达式。例如,您能够查询 switch 表达式是特定的类型、具有特定成员的类型,还是匹配特定“模式”或表达式的类型。例如,假设 obj 可能是 Point 类型,并且其 x 值大于 2:

object obj;
// ...
switch(obj) {
  case 42:
    // ...
  case Color.Red:
    // ...
  case string s:
    // ...
  case Point(int x, 42) where (Y > 42):
    // ...
  case Point(490, 42): // fine
    // ...
  default:
    // ...
}

有趣的是,当给定的表达式作为 case 语句时,也有必要允许表达式作为 goto case 语句上的自变量。

为了支持 Point 类型的 case 语句,Point 上必须有一些处理模式匹配的成员类型。在此示例中,需要可提取两个 int 类型的自变量的成员。例如,成员:

public static bool operator is (Point self out int x, out int y) {...}

请注意,如果没有 where 表达式,case Point(490, 42) 可能永远无法达到,进而会导致编译器发出错误或警告。

switch 语句的限制因素之一是,它不返回值,而是执行代码块。模式匹配的新增功能可以支持返回值的 switchexpression,如下所示:

string text = match (e) { pattern => expression; ... ; default => expression }

同样,is 运算符可支持模式匹配,不仅允许进行类型检查,还支持就类型上是否存在特定成员进行更通用的查询。

记录

作为 C# 6.0 中考虑添加的简化“构造函数”声明语法(但最终遭到拒绝)的延续,支持在类定义中嵌入构造函数声明,我们将这种概念称为“记录”。 例如,假设声明如下:

class Person(string Name, int Age);

此简单语句会自动生成以下内容:

  • 构造函数:
public Person(string Name, int Age)
{
  this.Name = Name;
  this.Age = Age;
}
  • 只读属性,从而创建不可变类型
  • 等同性实现(如 GetHashCode、等于、运算符 ==、运算符 != 等)
  • ToString 的默认实现
  • “is”运算符的模式匹配支持

尽管会生成大量代码(考虑到仅仅一个很短的代码行就创建了它的全部),但我们希望可以为手动编码(本质上是样本实现)提供相应的重要快捷方式。此外,所有代码都可以被视为显式实现中的“默认”代码,其中的任意内容将具有优先权,并阻止生成相同的成员。

与记录有关的一个更棘手的问题是,如何处理序列化。相当典型的做法大概是将记录用作数据传输对象 (DTO),但仍不明确如何(若有措施)支持此类记录的序列化。

与记录相关的是,支持 with 表达式。借助 with 表达式,您可以根据现有对象对新对象进行实例化。以 person 对象声明为例,您可以通过以下 with 表达式新建一个实例:

Person inigo = new Person("Inigo Montoya", 42);
Person humperdink = inigo with { Name = "Prince Humperdink" };

生成的与 with 表达式对应的代码如下所示:

Person humperdink = new Person(Name: "Prince Humperdink", Age: inigo.42 );

不过,另一建议是与其依赖 with 表达式的构造函数签名,更可取的做法是将它转换成 with 方法的调用,如下所示:

Person humperdink = inigo.With(Name: "Prince Humperdink", Age: inigo.42);

异步流

为了加强 C# 7 中的异步支持,处理异步序列的概念非常新奇。以 IAsyncEnumerable 为例,它的属性为 Current 且方法为 Task<bool> MoveNextAsync。您可以使用 foreach 循环访问 IAsyncEnumerable 实例,并让编译器负责异步调用流中的每个成员,即执行 await 以确定序列(可能是信道)中是否有要处理的另一元素。对此,还有很多需要评估的注意事项;其中最不需要注意的是,所有返回 IAsyncEnumerable 的 LINQ 标准查询运算符可能会出现的 LINQ 膨胀。此外,如何公开 CancellationToken 支持和 Task.ConfigureAwait 仍不确定。

命令行上的 C#

我热衷于研究 Windows PowerShell 如何让 Microsoft .NET Framework 可用于命令行接口 (CLI),我特别感兴趣的一个方面(也许是我最喜欢的一项审议功能)是支持在命令行上使用 C#;通常将这个概念称为支持读取、求值、打印、循环 (REPL)。正如人们所希望的一样,REPL 支持会随附 C# 脚本功能,这在不繁琐的简单方案中不需要使用所有常见形式(如类声明)。没有编译步骤,REPL 会需要新指令来引用程序集和 NuGet 包,以及导入其他文件。目前正在讨论中的方案会支持:

  • 用于引用其他程序集或 NuGet 包的 #r。变体是 #r!,它甚至允许访问内部成员,尽管有一些约束。(这适用于您有要访问的程序集的源代码的情况。)
  • 用于添加整个目录的 #l(与 F# 类似)。
  • 用于导入其他 C# 脚本文件的 #load,方法与您在项目中添加脚本文件几乎相同,不同之处在于现在顺序很重要。(请注意,可能不支持导入 .cs 文件,因为不允许在 C# 脚本中使用命名空间。)
  • 在执行的同时开启性能诊断的 #time。

您可以期待即将与 Visual Studio 2015 Update 1 一同发布的首版 C# REPL(以及支持相同功能集的已更新交互式窗口)。有关更多信息,请访问 Itl.tc/CSREPL,以及查看我下个月的专栏。

总结

虽然有准备了一年的材料,但若要探究设计团队的所有工作,我们还有其他太多信息需要了解。即使是我介绍的那些观点,您也还是需要考虑其他许多详细信息(注意事项和优势)。不过,我希望您现在已经了解设计团队一直在探讨的观点,以及他们正如何寻求改进已经非常出色的 C# 语言。如果您想直接查阅 C# 7 设计说明,并提供您自己的反馈意见,则可以跳转到 bit.ly/CSharp7DesignNotes 进行讨论。


Mark Michaelis是 IntelliTect 的创始人,担任首席技术架构师和培训师。在近二十年的时间里,他一直是 Microsoft MVP,并且自 2007 年以来一直担任 Microsoft 区域总监。Michaelis 还是多个 Microsoft 软件设计评审团队(包括 C#、Microsoft Azure、SharePoint 和 Visual Studio ALM)的成员。他在开发者会议上发表了演讲,并撰写了大量书籍,包括最新的“必备 C# 6.0(第 5 版)”(itl.tc/­EssentialCSharp)。可通过他的 Facebook facebook.com/Mark.Michaelis、博客 IntelliTect.com/Mark、Twitter @markmichaelis 或电子邮件 mark@IntelliTect.com 与他取得联系。

衷心感谢以下 Microsoft 技术专家对本文的审阅: Mads Torgerson