Build 2020上公布的C# 9.0 新特性
在微软的Build 2020开发者大会中,微软就正在成形的C#9.0的一些主要特性进行了介绍和分享。
1.init属性访问器
对象初始化方式对于创建对象来说是一种非常灵活和可读的方式,特别是对树状嵌入型对象的创建来说。一个简单的例子如下:
new Person { FirstName = "Scott", LastName = "Hunter" }
原来要进行对象初始化,我们必须要做就是写一些属性,并且在构造函数的初次调用中,通过给属性的setter赋值来实现。
public class Person { public string FirstName { get; set; } public string LastName { get; set; } }
就这种方式而言,set访问器对于初始化来说是必须的,但是如果你想要的是只读属性,这个setter就不合适了。因为除过初始化,其他情况不需要,而且很多情况下容易引起属性值改变。为了解决这个矛盾,只用来初始化的init访问器出现了.。例如:
public class Person { public string FirstName { get; init; } public string LastName { get; init; } }
init访问器是一个只在对象初始化时用来赋值的setter的变体,并且除过初始化进行赋值外,后续其他的赋值操作是不允许的。上面的Person例子,在下面代码中第一行初始化可以,第二次赋值就不被允许了。
var person = new Person { FirstName = "Mads", LastName = "Nielsen" }; // OK person.LastName = "Torgersen"; // ERROR!
2. init属性访问器和只读字段
因为init访问器只能在初始化时被调用,原来只能在构造函数里进行初始化的只读字段,现在可以像在构造函数里一样,在ini属性访问器中对这些只读字段进行赋值操作。
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))); } }
3. Records
如果你需要整个对象都是不可变的,且行为像一个值类型,你可以考虑将其声明为record。
public data class Person { public string FirstName { get; init; } public string LastName { get; init; } }
关键字data用来将类标记为record。这样,类就具有了像值一样的行为。records意味看起来更像值,也就是数据,而且很少像对象。不是说他们有可变的封装的状态,而是通过创建代表新状态的records来呈现随时间变化的状态。records不是被他们的标识符界定,而是被他们的内容所界定的。
4. With表达式
当用不可变的数据类型时,一个常见的模式是从现存的值创建新值来呈现一个新状态。例如,如果Person打算改变了他的姓氏(last name),我们就需要通过拷贝原来数据,并赋予一个不同的last name值来呈现一个新Person。这种技术被称为非破坏性改变。作为呈现随时间变化的person,record呈现了一个特定时间的person的状态。为了帮助这种类型的编程处理,records就提出了一个新的表达式,这就是with表达式:
var otherPerson = person with { LastName = "Hanselman" };
with表达式使用初始化语法来说明新对象在哪里与原来对象不同。
一个record隐式定义了一个带有保护访问级别的“拷贝构造函数”,用来将原有record对象的字段值拷贝到新对象对应字段中:
protected Person(Person original) { /* copy all the fields */ } // generated
with表达式就会引起拷贝构造函数进行调用,然后应用对象初始化器来有限更改属性相应值。如果你不喜欢默认的产生的拷贝构造函数,你可以自定以,with表达式也会进行调用。
5. 基于值的相等
所有对象都从object类型继承了 Equals(object),这是静态方法
Object.Equals(object, object)
用来比较两个非空参数的基础。
结构重写这个方法,通过递归调用每个结构字段的Equals方法,从而有了“基于值的相等”,Recrods也是这样。这意味着只要他们的值保持一致,两个record对象可以不是同一个对象就会相等。例如我们修改回了last name:
var originalPerson = otherPerson with { LastName = "Hunter" };
现在我们会有 ReferenceEquals(person, originalPerson)
= false (他们不是同一对象),但是 Equals(person, originalPerson)
= true (他们有同样的值).。
如果你不喜欢默认Equals重写的字段与字段比较行为,你可以进行重写。你只需要认真理解基于值的相等时如何在records中工作原理,特别是涉及到继承的时候,后面我们会提到。
与基于值的Equals一起的,还伴有基于值的GetHashCode()的重写。
6.data成员
不可变的Records的成员是带有init的public属性,可以通过with表达式进行无破坏性修改的。为了优化这种共有情况,records改变了形如string FirstName的默认意思,即在结构和类中声明的隐式私有字段,在records中成了公有,仅初始化自动属性。
public data class Person { string FirstName; string LastName; }
上面这段声明,就是跟下面这段代码意思相同:
public data class Person { public string FirstName { get; init; } public string LastName { get; init; } }
这个使得record声明看起来优美而清晰直观。如果你真的需要一个私有字段,你可以显式添加private修饰符。
private string firstName;
7. Positional records
在record是可以指定构造函数和解构函数(注意不是析构函数)的。
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);
该方式声明了公开的带有仅仅初始化的自动属性、构造函数和解构函数,和第6条第一段代码带有大括号的声明方式是不同的。现在你就可以写如下代码:
var person = new Person("Scott", "Hunter"); // positional construction var (f, l) = person; // positional deconstruction
当然,如果你不喜欢产生的自动属性,你可以你自己自定义的同名属性代替,产生的构造函数和解构函数将会只使用你自定义的那个。
8.Records和变化
record基于值这种语义没有很好应对可变状态这种情况。想象给字典插入一个record对象。要查找到它,就得根据Equals和(一些时候)GetHashCode。 但是如果record改变了状态,它们也会跟着改变。这样,我们就可能再找不到这个record。在哈希表实现中,这样可能会损害数据结构,由于放置位置是基于它的哈希码得到的。
现实中,存在有一些record内部的可变状态的有效的高级应用,如缓存。但是在重写默认行为来忽略这种状态所涉及的人工工作可能是相当多的。
9. with表达式和继承
众所周知,基于值相等和非破坏性变化值的方式在和继承纠结在一起时,非常具有挑战性。下来,我们添加一个继承的record类Student来说明我们的例子:
public data class Person { string FirstName; string LastName; } public data class Student : Person { int ID; }
下来,我们通过创建一个Student,但是把他存放到Person变量中,来说明with表达式的使用:
Person person = new Student { FirstName = "Scott", LastName = "Hunter", ID = GetNewId() }; otherPerson = person with { LastName = "Hanselman" };
在最后一行,编译器不知道person实际存放的是一个Student,这样,新person对象就不会得到正确的拷贝,即就不会像4中的第一段代码拷贝那样,得到同样ID值。
C#为了使这个得以正确工作。Records有一个隐藏的virtual方法,用于执行整体对象的克隆。每个派生的record类型重写了这个方法,以调用那个类型的拷贝构造函数,派少的record构造函数也受约束于父record的拷贝构造函数。with表达式简单调用这个隐藏的“clone”方法,应用对象初始化器给结果。
10. 基于值相等和继承
类似于with表达式的支持,基于值的相等性也必须是“virtual”,在这种意义上来说,Students需要比较所有student字段,即使在比较时静态已知类型是像Person这样的基类,也容易通过重写已经有的virtual 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#考虑自动为你实现这个,实现的方式是records有一个虚拟保护的属性,叫做EqualityContract。每个派生的record重写它,为了比较相等,两个对象必须有同样的EqualityContract。
11.顶级程序
通常,我们写一个简单的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也是可用的。
本地方法是语句的另一个形式,也是允许在顶级程序代码用的。在顶级代码段外部的任何地方调用他们都会产生错误。
12. 增强的模式匹配
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)) };
(1)简单类型模式
当前,当类型匹配的时候,一个类型模式需要声明一个标识符——即使这标识符是_,像上面代码中的DeliveryTruck _
。但是在C#9.0中,你可以只写类型,如下所示:
DeliveryTruck => 10.00m,
(2)关系模式
C#9.0 提出了关系运算符对应的模式,例如<,<=等等。所以你现在可以用switch表达式将下上面模式中的DeliveryTruck部分写成下面样子:
DeliveryTruck t when t.GrossWeightClass switch { > 5000 => 10.00m + 5.00m, < 3000 => 10.00m - 2.00m, _ => 10.00m, },
这的 > 5000
和 < 3000是关系模式。
(3)逻辑模式
最后,你可以用逻辑操作符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, },
中间的case使用了and 来组合两个关系模式形成了一个表达区间的模式。
not模式的常见的使用是将它用在null常量模式上,如not null。例如我们要根据是否为空来把一个未知分支的处理进行拆分:
not null => throw new ArgumentException($"Not a known vehicle type: {vehicle}", nameof(vehicle)), null => throw new ArgumentNullException(nameof(vehicle))
用于取代笨拙的双括号,在包含了is表达式的if条件语句中使用not也会很方便:
if (!(e is Customer)) { ... }
你可以这样写:
if (e is not Customer) { ... }
13. 增强的类型推导
类型推导是当一个表达式从它所被使用的地方的上下文中获得它的类型时,我们经常使用的一个专业术语。例如null和lambda表达式总是涉及到类型推导的。
在C#9.0中,先前没有实现类型推导的一些表达式现在也可以用他们的上下文来进行类型推导了。
(1)类型推导的new表达式
在C#中,new表达式总是要求一个具体指定的类型(除了隐式类型数组表达式)。现在,如果表达式被指派给一个明确的类型时,你可以忽略new关键字后面的类型。
Point p = new (3, 5);
(2)类型推导的??和?:
一些时候,条件表达式??和?:在分支中没有明显的共享类型,目前这种情况是不允许的,但是在C#9.0中,如果各分支可以转换 为目标类型,这种情况时允许的。
Person person = student ?? customer; // Shared base type int? result = b ? 0 : null; // nullable value type
14.支持协变的返回值
一些时候,在子类的一个重写方法中返回一个更具体的、且不同于父类方法定义的返回类型更为有用,C# 9.0对这种情况提供了支持。
abstract class Animal { public abstract Food GetFood(); ... } class Tiger : Animal { public override Meat GetFood() => ...; }