C#

最新和改进的 C# 6.0

Mark Michaelis

尽管 C# 6.0 尚未完成,但现在这些功能正处于接近完成的关键时刻。自 2014 年 5 月发布文章“C# 6.0 语言预览版”(msdn.microsoft.com/magazine/dn683793.aspx) 以来,下一版本的 Visual Studio 的 CTP3 版本中对 C# 6.0 进行了一些变更和改进(代号为“14”)。

本文中,我将介绍各种新功能,并提供针对五月所讨论的功能的更新。我还会保留对 C# 6. 每个功能的全面、最新的博客介绍更新。请访问 itl.tc/csharp6。其中的很多示例均来自我的“Essential C# 6.0”(Addison-Wesley Professional) 一书的下一版。

Null 条件运算符

即使是 .NET 开发新手,也可能非常熟悉 NullReferenceException。有一个例外是几乎总是会指出一个 Bug,因为开发人员在调用 (null) 对象的成员之前未进行充分的 null 检查。请看看以下示例:

public static string Truncate(string value, int length)
{
  string result = value;
  if (value != null) // Skip empty string check for elucidation
  {
    result = value.Substring(0, Math.Min(value.Length, length));
  }
  return result;
}

如果不进行 null 检查,此方法会引发 NullReferenceException。尽管这很简单,但检查字符串参数是否为 null 的过程却稍微有些繁琐。通常,考虑到比较的频率,该繁琐的方法可能没有必要。C# 6.0 包括一个新的 null 条件运算符,可帮助您更加简便地编写这些检查:

public static string Truncate(string value, int length)
{          
  return value?.Substring(0, Math.Min(value.Length, length));
}
[TestMethod]
public void Truncate_WithNull_ReturnsNull()
{
  Assert.AreEqual<string>(null, Truncate(null, 42));
}

根据 Truncate_WithNull_ReturnsNull 方法所演示的内容,如果对象的值实际上为 null,则 null 条件运算符将返回 null。这带来了一个问题,即 null 条件运算符在调用链中出现时会是什么情况?如以下示例中所示:

public static string AdjustWidth(string value, int length)
{
  return value?.Substring(0, Math.Min(value.Length, length)).PadRight(length);
}
[TestMethod]
public void AdjustWidth_GivenInigoMontoya42_ReturnsInigoMontoyaExtended()
{
  Assert.AreEqual<int>(42, AdjustWidth("Inigo Montoya", 42).Length);
}

尽管 Substring 是通过 null 条件运算符进行调用的,并且 null value?.Substring 似乎返回了 null,但语言行为按您的想法进行。这简化了对 PadRight 的调用过程,并立即返回 null,从而避免会导致出现 NullReferenceException 的编程错误。这个概念称为“null 传播”。

Null 条件运算符会根据具体条件进行 null 检查,然后再调用目标方法以及调用链中的所有其他方法。这将可能产生一个令人惊讶的结果,例如,text?.Length.GetType 语句中的结果。

如果 null 条件运算符在调用目标为 null 时返回 null,那么调用会返回值类型的成员时最终会是什么数据类型(假定值类型不能为 null)?例如,从 value?.Length 返回的数据类型不能只是 int。答案当然是:可以为 null 的类型(int?)。实际上,尝试仅将结果分配给 int 将会出现编译错误:

int length = text?.Length; // Compile Error: Cannot implicitly convert type 'int?' to 'int'

Null 条件具有两种语法形式。首先,问号在点运算符前面 (?.)。其次,将问号和索引运算符结合使用。例如,给定一个集合(而非在索引到集合之前显式进行 null 检查),您就可以使用 null 条件运算符执行此操作:

public static IEnumerable<T> GetValueTypeItems<T>(
  IList<T> collection, params int[] indexes)
  where T : struct
{
  foreach (int index in indexes)
  {
    T? item = collection?[index];
    if (item != null) yield return (T)item;
  }
}

此示例使用了运算符 ?[…] 的 null 条件索引形式,导致仅在集合不为 null 时才索引到集合。通过 null 条件运算符的此形式,T? item = collection?[index] 语句在行为上相当于:

T? item = (collection != null) ? collection[index] : null.

请注意,null 条件运算符仅可检索项目,不会分配项目。如果给定 null 集合,那么这意味着什么?

请注意针对引用类型使用 ?[…] 时的隐式歧义。由于引用类型可以为 null,因此对于集合是否为 null,或者是否元素本身实际上就是 null 而言,来自 ?[…] 运算符的 null 结果不明确。

Null 条件运算符的一个非常有用的应用程序解决了 C# 自 C# 1.0 以来一直存在的的一个特性,即在调用委托之前检查是否为 null。我们来看一下图 1 中显示的 C# 2.0 代码。

图 1 在调用委托之前检查是否为 Null

class Theremostat
{
  event EventHandler<float> OnTemperatureChanged;
  private int _Temperature;
  public int Temperature
  {
    get
    {
      return _Temperature;
    }
    set
    {
      // If there are any subscribers, then
      // notify them of changes in temperature
      EventHandler<float> localOnChanged =
        OnTemperatureChanged;
      if (localOnChanged != null)
      {
        _Temperature = value;
        // Call subscribers
        localOnChanged(this, value);
      }
    }
  }
}

通过使用 null 条件运算符,整个 set 实现过程就可简化为:

OnTemperatureChanged?.Invoke(this, value)

现在,您只需对将 null 条件运算符作为前缀的 Invoke 进行调用,不再需要将委托实例分配给本地变量,从而实现线程安全,甚至是在调用委托之前显式检查值是否为 null。

C# 开发人员都很想知道在最新的四个版本中是否对此内容有所改进。答案是最终进行了改进。仅此一项功能就可以改变调用委托的方式。

另一个 null 条件运算符普及的常见模式是与 coalesce 运算符结合使用。您无需在调用 Length 之前对 linesOfCode 进行 null 检查,而是可以编写项目计数算法,如下所示:

List<string> linesOfCode = ParseSourceCodeFile("Program.cs");
return linesOfCode?.Count ?? 0;

在这种情况下,任何空集合(无项目)和 null 集合均标准化为返回相同数量。总之,null 条件运算符将实现以下功能:

  • 如果操作数为 null,则返回 null
  • 如果操作数为 null,则简化调用链中的其他调用
  • 如果目标成员返回一个值类型,则返回可以为 null 的类型 (System.Nullable<T>)。
  • 以线程安全的方式支持委托调用
  • 可用作成员运算符 (?.) 和索引运算符 (?[…])

自动属性初始化表达式

有过正确实现结构经验的所有 .NET 开发人员无疑都为一个问题所困扰:需要使用多少语法才能使类型固定不变(为 .NET 标准建议的类型)。此问题实际上是只读属性存在的问题:

  1. 定义为只读的支持字段
  2. 构造函数内支持字段的初始化
  3. 属性的显式实现(而非使用自动属性)
  4. 返回支持字段的显式 getter 实现

所有这一切仅仅是为了“正确地”实现固定不变的属性。之后,此情况还会针对类型的所有属性重复发生。因此,正确操作需要比不堪一击的方法付出明显更多的努力。发布了自动属性初始化表达式(CTP3 还包括对初始化表达式的支持)这个新功能后,C# 6.0 就可派上用场了。自动属性初始化表达式允许直接在属性的声明内分配属性。对于只读属性,它负责确保属性固定不变所需的所有繁琐程序。例如,请看本示例中的 FingerPrint 类:

public class FingerPrint
{
  public DateTime TimeStamp { get; } = DateTime.UtcNow;
  public string User { get; } =
    System.Security.Principal.WindowsPrincipal.Current.Identity.Name;
  public string Process { get; } =
    System.Diagnostics.Process.GetCurrentProcess().ProcessName;
}

如代码所示,属性初始化表达式允许向属性分配一个初始值作为属性声明的一部分。属性可以是只读的(只包含 getter),也可以是读/写(包含 setter 和 getter)的。如果是只读的,则基础支持字段将通过只读修饰符自动声明。这就确保了在初始化之后会固定不变。

初始化表达式可以是任意表达式。例如,通过使用条件运算符,您可以设置默认初始化值:

public string Config { get; } = string.IsNullOrWhiteSpace(
  string connectionString =
    (string)Properties.Settings.Default.Context?["connectionString"])?
  connectionString : "<none>";

本示例中,请注意之前的文章中所讨论的如何使用声明表达式(请参阅 itl.tc/?p=4040)。如果您需要的不只是表达式,可以将初始化重构到静态方法中,然后对其进行调用。

Nameof 表达式

CTP3 版本中介绍的另一个新增功能是支持 nameof 表达式。您将多次需要在代码中使用“魔幻字符串”。此类“魔幻字符串”是映射到您代码中的程序元素的普通 C# 字符串。例如,引发 ArgumentNullException 时,使用一个字符串表示无效对应参数的名称。遗憾的是,这些魔幻字符串未经过编译时验证,任意程序元素更改(例如,重命名参数)都不会自动更新魔幻字符串,从而导致不一致,而编译器根本不会发现此问题。

在其他情况下,例如引发 OnPropertyChanged 事件时,可以通过提取名称的树表达式技术避免出现魔幻字符串。鉴于操作简单(只识别程序元素名称),所以这或许有点让人头疼。无论哪种情况,解决方案都不太理想。

若要解决这一特性,C# 6.0 提供了对“程序元素”名称的访问权限,无论是类名称、方法名称、参数名称还是特定属性名称(可能是对于使用反射的情况)。例如,图 2 中的代码使用 nameof 表达式提取参数的名称。

图 2 使用 Nameof 表达式提取参数名称

void ThrowArgumentNullExceptionUsingNameOf(string param1)
{
  throw new ArgumentNullException(nameof(param1));
}
[TestMethod]
public void NameOf_UsingNameofExpressionInArgumentNullException()
{
  try
  {
    ThrowArgumentNullExceptionUsingNameOf("data");
    Assert.Fail("This code should not be reached");
  }
  catch (ArgumentNullException exception)
  {
    Assert.AreEqual<string>("param1", exception.ParamName);
}

正如测试方法所演示的,ArgumentNullException 的 ParamName 属性具有 param1 值,这是使用方法中的 nameof(param1) 表达式的值集。Nameof 表达式不仅仅用于参数,您还可以使用它来检索所有编程元素,如图 3 中所示。

图 3 检索其他编程元素

namespace CSharp6.Tests
{
  [TestClass]
  public class NameofTests
  {
    [TestMethod]
    public void Nameof_ExtractsName()
    {
      Assert.AreEqual<string>("NameofTests", nameof(NameofTests));
      Assert.AreEqual<string>("TestMethodAttribute",
        nameof(TestMethodAttribute));
      Assert.AreEqual<string>("TestMethodAttribute",
        nameof(
         Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute));
      Assert.AreEqual<string>("Nameof_ExtractsName",
        string.Format("{0}", nameof(Nameof_ExtractsName)));
      Assert.AreEqual<string>("Nameof_ExtractsName",
        string.Format("{0}", nameof(
        CSharp6.Tests.NameofTests.Nameof_ExtractsName)));
    }
  }
}

Nameof 表达式仅检索最终的标识符,即使您使用更多的显式包含点的名称也是如此。此外,对于属性而言,未隐含“Attribute”后缀。相反,编译需要它。它非常适合用于清理混乱代码。

主构造函数

自动属性初始化表达式尤其适合与主构造函数结合使用。主构造函数为降低常见对象模式的繁琐程度提供了一种方法。此功能自五月以来已显著改进。更新包括:

  1. 主构造函数的可选实现主体:这将支持此前不受支持的主构造函数参数验证和初始化等。
  2. 取消字段参数:通过主构造函数参数对字段进行声明。(不将此功能按照已定义方式推出是正确的决定,因为它不再按照 C# 之前矛盾的方式强制遵循特定命名约定。)
  3. 支持表达式主体函数和属性(本文稍后将进行讨论)。

随着 Web 服务、多层应用程序、数据服务、Web API、JSON 及类似技术的普遍使用,类的一个普遍形式是数据传输对象 (DTO)。DTO 通常不会实现太多功能,而是专注于使数据存储简单化。它对于简单性的关注使得主构造函数极具新引力。例如,请看本示例中所示的固定 Pair 数据结构:

struct Pair<T>(T first, T second)
{
  public T First { get; } = first;
  public T Second { get; } = second;
  // Equality operator ...
}

构造函数定义 Pair(string first, string second) 已合并到类声明中。这会将构造函数参数指定为 first 和 second(均为类型 T)。属性初始化表达式中也引用了这些参数,并将参数分配给其对应的属性。当您发现此类定义的简单性、对不变性的支持以及必不可少的构造函数(所有属性/字段的初始化表达式)时,您将会了解到它是如何帮助您正确编写代码的。这将导致先前需要不必要的详细级别的常见模式得到显著改进。

主构造函数主体指定对主构造函数执行的操作。这将有助于您在主构造函数上实现通常在构造函数上可以实现的等同功能。例如,改进 Pair<T> 数据结构的可靠性的下一个步骤可能是提供属性验证。此类验证可以确保 Pair.First 的 null 值将无效。现在,CTP3 包括一个主构造函数主体(未声明的构造函数主体),如图 4 中所示。

图 4 实现主构造函数主体

struct Pair<T>(T first, T second)
{
  {
    if (first == null) throw new ArgumentNullException("first");
    First = first; // NOTE: Not working in CTP3
  }     
  public T First { get; }; // NOTE: Results in compile error for CTP3
  public T Second { get; } = second;
  public int CompareTo(T first, T second)
  {
    return first.CompareTo(First) + second.CompareTo(Second);
  }
// Equality operator ...
}

为清晰起见,我将主构造函数主体置于类的第一个成员中。但这不是 C# 所要求的。主构造函数主体可以按与其他类成员相关的任意顺序显示。

只读属性的另一个功能尽管在 CTP3 中没有发挥作用,但您可以从构造函数内直接分配这些属性(例如,First = first)。这不仅仅限于主构造函数,而且还适用于所有构造函数成员。

支持自动属性初始化表达式的一个有趣的结果是,它解决了早期版本中出现的需要显式字段声明的多种情况。它没有解决一个最明显的问题,即需要对 setter 进行验证的情况。另一方面,几乎已不需要声明只读字段。现在,无论何时声明只读字段,只要需要该封装级别,您都可以将只读自动属性声明为私有。

CompareTo 方法具有参数 first 和 second,这好像与主构造函数的参数名称重复。由于主构造函数名称在自动属性初始化表达式作用域内,因此,first 和 second 似乎并不明确。幸运的是,实际情况并非如此。作用域规则将依据不同维度而定,而您之前在 C# 中并未看到。

在 C# 6.0 之前,作用域始终由代码内的变量声明放置来确定。参数在其帮助声明的方法中绑定,字段在类中绑定,在 if 语句中声明的变量由 if 语句条件主体绑定。

相比之下,主构造函数参数则由时间来绑定。主构造函数参数仅在执行主构造函数时为“活动”状态。此时间范围在主构造函数主体的情况中很明显。可能对于自动属性初始化表达式的情况不太明显。

但是,与作为 C# 1.0+ 中的类初始化表达式的一部分执行的转换为语句的字段初始化表达式类似,自动属性初始化表达式也通过同样的方式实现。换言之,主构造函数参数的作用域与类初始化表达式和主构造函数主体的生命周期绑定。在自动属性初始化表达式或主构造函数主体外部对主构造函数参数进行任何引用都将产生编译错误。

还有其他几个与主构造函数相关的概念需要牢记。只有主构造函数可以调用基构造函数。您可以使用主构造函数声明后跟的基本(上下文)关键字执行此操作:

class UsbConnectionException(
  string message, Exception innerException, HidDeviceInfo hidDeviceInfo):
    Exception  (message, innerException)
{
  public HidDeviceInfo HidDeviceInfo { get;  } = hidDeviceInfo;
}

如果指定其他构造函数,则构造函数调用链必须最后调用主构造函数。这意味着主构造函数不能具有此初始化表达式。假定主构造函数也不是默认构造函数,所有其他构造函数必须具有这些初始化表达式:

public class Patent(string title, string yearOfPublication)
{
  public Patent(string title, string yearOfPublication,
    IEnumerable<string> inventors)
    ...this(title, yearOfPublication)
  {
    Inventors.AddRange(inventors);
  }
}

希望这些示例有助于展示主构造函数简化了 C#。通过主构造函数,还有机会以简单的方式来执行简单的任务,而不是以复杂的方式来执行简单的任务。它偶尔也会让类包含多个主构造函数和调用链,致使代码不易于阅读。如果您遇到主构造函数语法使代码看起来更为复杂而不是简化代码的情况,那么请不要使用主构造函数。对于 C# 6.0 的所有增强功能,如果您有不喜欢的功能,或某个功能使您的代码不易于阅读,请不要使用该功能。

表达式主体函数和表达式属性

表达式主体函数是 C# 6.0 中的另一个语法精简形式。有一些函数不包括语句体,而是以函数声明后跟表达式的形式来实现。

例如,可以这样向 Pair<T> 类添加 ToString 方法的重写:

public override string ToString() => string.Format("{0}, {1}", First, Second);

表达式主体函数没有什么彻底更改。和 C# 6.0 中的大部分功能一样,它们旨在提供简化的语法,用于实现简单的情况。当然,表达式的返回类型必须与函数声明中定义的返回类型相匹配。在这种情况下,ToString 将返回一个字符串,这同函数实现表达式返回的结果一样。返回 void 或 Task 的方法应通过同样不会返回任何结果的表达式实现。

表达式主体简化不仅仅限于函数,您还可以使用表达式实现只读(仅包含 getter)属性——表达式属性。例如,可以将 Text 成员添加到 FingerPrint 类:

public string Text =>
  string.Format("{0}: {1} - {2} ({3})", TimeStamp, Process, Config, User);

其他功能

有一些功能不再计划针对 C# 6.0 实现:

  • 索引属性运算符 ($) 不再可用,并且不针对 C# 6.0 实现。
  • 尽管索引成员语法预期在 C# 6.0 的更高版本中回归,但它在 CTP3 中不起作用:
var cppHelloWorldProgram = new Dictionary<int, string>
{
[10] = "main() {",
[20] = "    printf(\"hello, world\")",
[30] = "}"
};
  • 主构造函数中的字段参数不再属于 C# 6.0。
  • 二进制数字文本和数字文本中的数字分隔符 (‘_’) 在正式发布之前尚不确定。

有一些功能未在本文中进行讨论,因为这些功能已涵盖在五月的文章中,但静态 using 语句(请参阅 itl.tc/?p=4038)、声明表达式(请参阅 itl.tc/?p=4040,以及异常处理改进(请参阅 itl.tc/?p=4042)这些功能保持不变。

总结

显然,开发人员对 C# 兴趣盎然,希望确保其一如既往地保持卓越的功能。语言团队会非常认真地对待您的反馈,并根据用户反馈对语言进行适当修改。欢迎访问 roslyn.codeplex.com,并将您的想法告知团队。此外,请记得查看 itl.tc/csharp6 来了解 C# 6.0 发布后的更新。


Mark Michaelis 是 IntelliTect 的创始人。他还担任首席技术架构师和培训师。自 1996 年以来,他就成为 Microsoft C#、Visual Studio Team System (VSTS) 和 Windows SDK 方面的 MVP。他在 2007 年就任 Microsoft 区域总监。另外,他还是多个 Microsoft 软件设计评审团队(包括 C#、连通系统部门和 VSTS)的成员。Michaelis 在开发人员大会上发表讲话,还撰写了许多文章和书籍,目前正在撰写新一版的《Essential C#》(C# 本质论)(Addison-Wesley Professional)。

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