整理 | 弯月
责编 | 郭芮
微软正在推进C# 9.0的开发,C# 9.0 将成为.NET 5 开发平台的一部分,预计于 11 月发布。微软.NET团队C#首席设计师Mads Torgersen表示,C# 9.0已初具规模,本文就分享下该语言下一版本中添加的一些主要功能。
C#的每个新版本都力求提升通用编程方面的清晰度与简单性,C# 9.0也不例外,尤其注重支持数据形状的简洁与不可变表示。下面,我们就来详细介绍!
仅可初始化的属性
对象的初始化器非常了不起。它们为客户端创建对象提供了一种非常灵活且易于阅读的格式,而且特别适合嵌套对象的创建,我们可以通过嵌套对象一次性创建整个对象树。下面是一个简单的例子:
new Person
{
FirstName = "Scott",
LastName = "Hunter"
}
对象初始化器还可以让程序员免于编写大量类型的构造样板代码,他们只需编写一些属性即可!
public class Person
{
public string FirstName { get; set; }
public string LastName { get; set; }
}
目前的一大限制是,属性必须是可变的,只有这样对象初始化器才能起作用,因为它们需要首先调用对象的构造函数(在这种情况下调用的是默认的无参构造函数),然后分配给属性设置器。
仅可初始化的属性可以解决这个问题!它们引入了init访问器。init访问器是set访问器的变体,它只能在对象初始化期间调用:
public class Person
{
public string FirstName { get; init; }
public string LastName { get; init; }
}
在这种声明下,上述客户端代码仍然合法,但是后续如果你想为FirstName和LastName属性赋值就会出错。
初始化访问器和只读字段
由于init访问器只能在初始化期间被调用,所以它们可以修改所在类的只读字段,就像构造函数一样。
public class Person
{
private readonly string firstName;
private readonly string lastName;
public string FirstName
{
get => firstName;
init => firstName = (value ?? throw new ArgumentNullException(nameof(FirstName)));
}
public string LastName
{
get => lastName;
init => lastName = (value ?? throw new ArgumentNullException(nameof(LastName)));
}
}
记录
如果你想保持某个属性不变,那么仅可初始化的属性非常有用。如果你希望整个对象都不可变,而且希望其行为宛如一个值,那么就应该考虑将其声明为记录:
public data class Person
{
public string FirstName { get; init; }
public string LastName { get; init; }
}
上述类声明中的data关键字表明这是一个记录,因此它具备了其他一些类似于值的行为,后面我们将深入讨论。一般而言,我们更应该将记录视为“值”(数据),而非对象。它们不具备可变的封装状态。相反,你可以通过创建表示新状态的新记录来表示随着时间发生的变化。记录不是由标识确定,而是由其内容确定。
With表达式
处理不可变数据时,一种常见的模式是利用现有的值创建新值以表示新状态。例如,如果想修改某人的姓氏,那么我们会用一个新对象来表示,这个对象除了姓氏之外和旧对象完全一样。通常我们称该技术为非破坏性修改。记录代表的不是某段时间的某个人,而是给定时间点上这个人的状态。
为了帮助大家习惯这种编程风格,记录允许使用一种新的表达方式:with表达式:
var otherPerson = person with { LastName = "Hanselman" };
with表达式使用对象初始化的语法来说明新对象与旧对象之间的区别。你可以指定多个属性。
记录隐式地定义了一个protected“复制构造函数”,这种构造函数利用现有的记录对象,将字段逐个复制到新的记录对象中:
protected Person(Person original) { /* copy all the fields */ } // generated
with表达式会调用复制构造函数,然后在其上应用对象初始化器,以相应地更改属性。
如果你不喜欢自动生成的复制构造函数,那么也可以自己定义,with表达式就会调用自定义的复制构造函数。
基于值的相等性
所有对象都会从object类继承一个虚的Equals(object)方法。在调用静态方法Object.Equals(object, object)且两个参数均不为null时,该Equals(object)就会被调用。
结构体可以重载这个方法,获得“基于值的相等性”,即递归调用Equals来比较结构的每个字段。记录也一样。
这意味着,如果两个记录对象的值一致,则二者相等,但两者不一定是同一对象。例如,如果我们再次修改前面那个人的姓氏:
var originalPerson = otherPerson with { LastName = "Hunter" };
现在,ReferenceEquals(person, originalPerson) = false(它们不是同一个对象),但Equals(person, originalPerson) = true (它们拥有相同的值)。
如果你不喜欢自动生成的Equals覆盖默认的逐字段比较的行为,则可以编写自己的Equals重载。你只需要确保你理解基于值的相等性在记录中的工作原理,尤其是在涉及继承的情况下,具体的内容我们稍后再做介绍。
除了基于值的Equals之外,还有一个基于值的GetHashCode()重载方法。
数据成员
在绝大多数情况下,记录都是不可变的,它们的仅可初始化的属性是公开的,可以通过with表达式进行非破坏性修改。为了优化这种最常见的情况,我们改变了记录中类似于string FirstName这种成员声明的默认含义。在其他类和结构声明中,这种声明表示私有字段,但在记录中,这相当于公开的、仅可初始化的自动属性!因此,如下声明:
public data class Person { string FirstName; string LastName; }
与之前提到过的下述声明完全相同:
public data class Person
{
public string FirstName { get; init; }
public string LastName { get; init; }
}
我们认为这种方式可以让记录更加优美而清晰。如果你需要私有字段,则可以明确添加private修饰符:
private string firstName;
位置记录
有时,用参数位置来声明记录会很有用,内容可以根据构造函数参数的位置来指定,并且可以通过位置解构来提取。
你完全可以在记录中指定自己的构造函数和析构函数:
public data class Person
{
string FirstName;
string LastName;
public Person(string firstName, string lastName)
=> (FirstName, LastName) = (firstName, lastName);
public void Deconstruct(out string firstName, out string lastName)
=> (firstName, lastName) = (FirstName, LastName);
}
但是,我们可以用更短的语法表达完全相同的内容(使用成员变量的大小写方式来命名参数):
public data class Person(string FirstName, string LastName);
上述声明了仅可初始化的公开的自动属性以及构造函数和析构函数,因此你可以这样写:
var person = new Person("Scott", "Hunter"); // positional construction
var (f, l) = person; // positional deconstruction
如果你不喜欢生成的自动属性,则可以定义自己的同名属性,这样生成的构造函数和析构函数就会自动使用自己定义的属性。
记录和修改
记录的语义是基于值的,因此在可变的状态中无法很好地使用。想象一下,如果我们将记录对象放入字典,那么就只能通过Equals和GethashCode找到了。但是,如果记录更改了状态,那么在判断相等时它代表的值也会发生改变!可能我们就找不到它了!在哈希表的实现中,这个性质甚至可能破坏数据结构,因为数据的存放位置是根据它“到达”哈希表时的哈希值决定的!
而且,记录也可能有一些使用内部可变状态的高级方法,这些方法完全是合理的,例如缓存。但是可以考虑通过手工重载默认的行为来忽略这些状态。
with表达式与继承
众所周知,考虑继承时基于值的相等性和非破坏性修改是一个难题。下面我们在示例中添加一个继承的记录类Student:
public data class Person { string FirstName; string LastName; }
public data class Student : Person { int ID; }
在如下with表达式的示例中,我们实际创建一个Student,然后将其存储到Person变量中:
Person person = new Student { FirstName = "Scott", LastName = "Hunter", ID = GetNewId() };
otherPerson = person with { LastName = "Hanselman" };
在最后一行的with表达式中,编译器并不知道person实际上包含一个Student。而且,即使otherPerson不是Student对象,它也不是合法的副本,因为它包含了与第一个对象相同的ID属性。
C#解决了这个问题。记录有一个隐藏的虚方法,能够确保“克隆”整个对象。每个继承的记录类型都会通过重载这个方法来调用该类型的复制构造函数,而继承记录的复制构造函数会调用基类的复制构造函数。with表达式只需调用这个隐藏“clone”方法,然后在结果上应用对象初始化器即可。
基于值的相等性与继承
与with表达式的支持类似,基于值的相等性也必须是“虚的”,即两个Student对象比较时需要比较所有字段,即使在比较时,能够静态地得知类型是基类,比如Person。这一点通过重写已经是虚方法的Equals方法可以轻松实现。
然而,相等性还有另外一个难题:如果需要比较两个不同类型的Person怎么办?我们不能简单地选择其中一个来决定是否相等:相等性应该是对称的,因此无论两个对象中的哪个首先出现,结果都应该相同。换句话说,二者之间必须就相等性达成一致!
我们来举例说明这个问题:
Person person1 = new Person { FirstName = "Scott", LastName = "Hunter" };
Person person2 = new Student { FirstName = "Scott", LastName = "Hunter", ID = GetNewId() };
这两个对象彼此相等吗?person1可能会认为相等,因为person2拥有Person的所有字段,但person2可能会有不同的看法!我们需要确保二者都认同它们是不同的对象。
C#可以自动为你解决这个问题。具体的实现方式是:记录拥有一个名为EqualityContract的受保护虚属性。每个继承的记录都会重载这个属性,而且为了比较相等,两个对象必须具有相同的EqualityContract。
顶层程序
使用C#编写一个简单的程序需要大量的样板代码:
using System;
class Program
{
static void Main()
{
Console.WriteLine("Hello World!");
}
}
这不仅对初学者来说难度太高,而且代码混乱,缩进级别也太多。
在C# 9.0中,你只需编写顶层的主程序:
using System;
Console.WriteLine("Hello World!");
任何语句都可以。程序必须位于using之后,文件中的任何类型或名称空间声明之前,而且只能在一个文件中,就像只有一个Main方法一样。
如果你想返回状态代码,则可以利用这种写法。如果你想await,那么也可以这么写。此外,如果你想访问命令行参数,则args可作为“魔术”参数使用。
局部函数是语句的一种形式,而且也可以在顶层程序中使用。在顶层语句之外的任何地方调用局部函数都会报错。
改进后的模式匹配
C# 9.0中添加了几种新的模式。下面我们通过如下模式匹配教程的代码片段来看看这些新模式:
public static decimal CalculateToll(object vehicle) =>
vehicle switch
{
...
DeliveryTruck t when t.GrossWeightClass > 5000 => 10.00m + 5.00m,
DeliveryTruck t when t.GrossWeightClass < 3000 => 10.00m - 2.00m,
DeliveryTruck _ => 10.00m,
_ => throw new ArgumentException("Not a known vehicle type", nameof(vehicle))
};
简单类型模式
当前,类型模式需要在类型匹配时声明一个标识符,即使该标识符是表示放弃的_也可以,如上面的DeliveryTruck _。而如今你可以像下面这样编写类型:
DeliveryTruck => 10.00m,
关系模式
C# 9.0中引入了与关系运算符<、<=等相对应的模式。因此,你可以将上述模式的DeliveryTruck写成嵌套的switch表达式:
DeliveryTruck t when t.GrossWeightClass switch
{
> 5000 => 10.00m + 5.00m,
< 3000 => 10.00m - 2.00m,
_ => 10.00m,
},
此处的 > 5000和< 3000是关系模式。
逻辑模式
最后,你还可以将模式与逻辑运算符(and、or和not)组合在一起,它们以英文单词的形式出现,以避免与表达式中使用的运算符混淆。例如,上述嵌套的switch表达式可以按照升序写成下面这样:
DeliveryTruck t when t.GrossWeightClass switch
{
< 3000 => 10.00m - 2.00m,
>= 3000 and <= 5000 => 10.00m,
> 5000 => 10.00m + 5.00m,
},
中间一行通过and将两个关系模式组合到一起,形成了表示间隔的模式。
not模式的常见用法也可应用于null常量模式,比如not null。例如,我们可以根据是否为null来拆分未知情况的处理方式:
not null => throw new ArgumentException($"Not a known vehicle type: {vehicle}", nameof(vehicle)),
null => throw new ArgumentNullException(nameof(vehicle))
此外,如果if条件中包含is表达式,那么使用not也很方便,可以避免笨拙的双括号:
if (!(e is Customer)) { ... }
你可以这样写:
if (e is not Customer) { ... }
改进后的目标类型推断
“目标类型推断”指的是表达式从所在的上下文中获取类型。例如,null和lambda表达式始终是目标类型推断。
在C# 9.0中,有些以前不是目标类型推断的表达式也可以通过上下文来判断类型。
支持目标类型推断的new表达式
C# 中的new表达式始终要求指定类型(隐式类型的数组表达式除外)。现在, 如果有明确的类型可以分配给表达式,则可以省去指定类型。
Point p = new (3, 5);
目标类型的??与?:
有时,条件判断表达式中??与?:的各个分支之间并不是很明显的同一种类型。现在这种情况会出错,但在C# 9.0中,如果两个分支都可以转换为目标类型,就没有问题:
Person person = student ?? customer; // Shared base type
int? result = b ? 0 : null; // nullable value type
协变的返回值
有时,我们需要表示出继承类中重载的某个方法的返回类型要比基类中的类型更具体。C# 9.0允许以下写法:
abstract class Animal
{
public abstract Food GetFood();
...
}
class Tiger : Animal
{
public override Meat GetFood() => ...;
}
更多内容
更多有关C# 9.0推出的新功能,请参照这个GitHub代码库(https://github.com/dotnet/roslyn/blob/master/docs/Language%20Feature%20Status.md)。
编程快乐!
参考链接:https://devblogs.microsoft.com/dotnet/welcome-to-c-9-0/
【END】
更多精彩推荐
☞Flash 已死,Deno 当立?
☞OceanBase 十年:一群追梦人的成长史
☞2 年 6 个月 11 天,外包到阿里的修仙之路!| 原力计划
☞服务器软件大扫盲!
☞绝悟之后再超神,腾讯30篇论文入选AI顶会ACL
☞中本聪并没有出现,那真相是?
你点的每个“在看”,我都认真当成了喜欢