- A+
11月10日,C# 9.0已经正式发布。一些新的特性也随之而来,这个版本主要焦点放在了数据的简洁性和不可变性表达上。
1. init关键字
1.1 仅初始化属性 — init关键字
对象初始化方式对于创建对象来说是一种非常灵活和可读的方式,特别对一口气创建含有嵌套结构的树型对象来说更有用。一个简单的初始化例子如下:
var person = new Person { FirstName = "Mads", LastName = "Torgersen" };
原来要进行对象初始化,我们不得不写一些含有set访问器的属性,并且在构造函数的初次调用中,通过给属性赋值来实现。
public class Person { public string? FirstName { get; set; } public string? LastName { get; set; } }
这种方式最大的局限就是,对于初始化来说,属性必须是可变的,也就是说,set访问器对于初始化来说是必须的。而其他情况下又不需要set,因此这个setter就不合适了。为了解决这个问题,仅仅只用来初始化的init访问器出现了.。例如:
public class Person { public string? FirstName { get; init; } public string? LastName { get; init; } }
init访问器是一个只在对象初始化时用来赋值的set访问器的变体,并且除过初始化进行赋值外,后续其他的赋值操作是不允许的。上面定义的Person对象,在下面代码中第一行初始化可以,第二行再次赋值就不被允许了。
var person = new Person { FirstName = "Mads", LastName = "Nielsen" }; // OK person.LastName = "Torgersen"; // ERROR!
1.2 init属性访问器和只读字段
因为init访问器只能在初始化时被调用,所以在init属性访问器中可以改变封闭类的只读字段。
public class Person { private readonly string firstName = "<unknown>"; private readonly string lastName = "<unknown>"; 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))); } }
2 记录 / Records
传统面向对象的编程的核心思想是一个对象有着唯一标识,封装着随时可变的状态。C#也是一直这样设计和工作的。但是一些时候,你就非常需要刚好对立的方式。原来那种默认的方式往往会成为阻力,使得事情变得费时费力。如果你发现你需要整个对象都是不可变的,且行为像一个值,那么你应当考虑将其声明为一个record类型。
public record Person { public string? FirstName { get; init; } public string? LastName { get; init; } }
一个record仍然是一个类,但是关键字record赋予这个类额外的几个像值的行为。通常说,records由他们的内容来界定,不是他们的标识。从这一点上讲,records更接近于结构,但是他们依然是引用类型。
2.1 with表达式
当使用不可变的数据时,一个常见的模式是从现存的值创建新值来呈现一个新状态。例如,如果Person打算改变他的姓氏(last name),我们就需要通过拷贝原来数据,并赋予一个不同的last name值来呈现一个新Person。这种技术被称为非破坏性改变。作为描绘随时间变化的person,record呈现了一个特定时间的person的状态。为了帮助进行这种类型的编程,records就提出了一个新的表达式——with表达式:
var person = new Person { FirstName = "Mads", LastName = "Nielsen" }; var otherPerson = person with { LastName = "Torgersen" };
with表达式使用初始化语法来说明新对象在哪里与原来对象不同。with表达式实际上是拷贝原来对象的整个状态值到新对象,然后根据对象初始化器来改变指定值。这意味着属性必须有init或者set访问器,才能用with表达式进行更改。
一个record隐式定义了一个带有保护访问级别的“拷贝构造函数”,用来将现有record对象的字段值拷贝到新对象对应字段中:
protected Person(Person original) { /* 拷贝所有字段 */ } // generated
with表达式就会引起拷贝构造函数被调用,然后应用对象初始化器来有限更改属性相应值。如果你不喜欢默认的产生的拷贝构造函数,你可以自定以,with表达式也会进行调用。
2.2 基于值的相等
所有对象都从object类型继承了 Equals(object),这是静态方法
Object.Equals(object, object)
用来比较两个非空参数的基础。
结构重写了这个方法,通过递归调用每个结构字段的Equals方法,从而有了“基于值的相等”,Recrods也是这样。这意味着只要他们的值保持一致,两个record对象可以不是同一个对象就会相等。例如我们将修改的Last name又修改回去了:
var originalPerson = otherPerson with { LastName = "Nielsen" };
现在我们会得到 ReferenceEquals(person, originalPerson)
= false (他们不是同一对象),但是 Equals(person, originalPerson)
= true (他们有同样的值).。与基于值的Equals一起的,还伴有基于值的GetHashCode()的重写。另外,records实现了IEquatable<T>并重载了==和 !=这两个操作符,以便于基于值的行为在所有的不同的相等机制方面显得一致。
基于值的相等和可变性不总是契合的很好。一个问题是改变值可能引起GetHashCode的结果随时变化,如果这个对象被存放在哈希表中,就会出问题。我们没有不允许使用可变的record,但是我们不鼓励那样做,除非你已经想到了后果。
如果你不喜欢默认Equals重写的字段与字段比较行为,你可以进行重写。你只需要认真理解基于值的相等时如何在records中工作原理,特别是涉及到继承的时候,后面我们会提到。
2.3 继承 / Inheritance
记录(record)可以从其他记录(record)继承:
public record Student : Person { public int ID; }
with表达式和值相等性与记录的继承结合的很好,因为他们考虑到了整个运行时对象,不只是静态的已知类型。比如,我创建一个Student对象,将其存在Person变量里。
Person student = new Student { FirstName = "Mads", LastName = "Nielsen", ID = 129 };
with表达式仍然拷贝整个对象并保持着运行时的类型:
var otherStudent = student with { LastName = "Torgersen" }; WriteLine(otherStudent is Student); // true
同样地,值相等性确保两个对象有着同样的运行时类型,然后比较他们的所有状态:
Person similarStudent = new Student { FirstName = "Mads", LastName = "Nielsen", ID = 130 }; WriteLine(student != similarStudent); //true, 由于ID值不同
2.4 位置记录 / Positional records
有时,有更多的位置定位方式对一个记录是很有用的,在那里,记录的内容是通过构造函数的参数传入,并且通过位置解构函数提取出来。你完全可能会在记录中定义你自己的构造和解构函数(注意不是析构函数)。如下所示:
public record Person { public string FirstName { get; init; } public string LastName { get; init; } public Person(string firstName, string lastName) => (FirstName, LastName) = (firstName, lastName); public void Deconstruct(out string firstName, out string lastName) => (firstName, lastName) = (FirstName, LastName); }
也可以用更精简的语法表达上面同样的内容。
public record Person(string FirstName, string LastName);
该方式声明了公开的、仅仅初始化的自动属性、构造函数和解构函数,和2.1种第一行代码带有大括号的声明方式不同。现在你就可以写如下代码:
var person = new Person("Mads", "Torgersen"); // 位置构造函数 / positional construction var (f, l) = person; // 位置解构函数 / deconstruction
当然,如果你不喜欢产生的自动属性,你可以你自己自定义的同名属性代替,产生的构造函数和解构函数将会只使用你自定义的那个。在这种情况下,该参数处于你用于初始化的作用域内。例如,你想让FirstName是个保护属性:
public record Person(string FirstName, string LastName) { protected string FirstName { get; init; } = FirstName; }
一个位置记录可以像下面这样调用父类构造函数。
public record Student(string FirstName, string LastName, int ID) : Person(FirstName, LastName);
3 顶层程序(Top-Level Programs)
通常,我们写一个简单的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像魔法一样也是可用的。
using static System.Console; using System.Threading.Tasks; WriteLine(args[0]); await Task.Delay(1000); return 0;
本地函数作为语句的另一种形式,也是允许在顶层程序代码中使用的。在顶层代码段外部的任何地方调用他们都会产生错误。
4 增强的模式匹配
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 提出了关系运算符<,<=等对应的模式。所以你现在可以将上面模式中的DeliveryTruck部分写成一个嵌套的
switch表达式:
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, },
中间的分支使用了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) { ... }
实际上,在is not表达式里,允许你给Customer指定名称,以便后续使用。
if (e is not Customer c) { throw ... } // 如果这个分支抛出异常或者返回... var n = c.FirstName; // ... 这里,c肯定已经被赋值了,不会为空
5 类型推导new表达式
类型推导是从一个表达式所在的位置根据上下文获得它的类型时使用的一个术语。例如null和lambda表达式总是涉及到类型推导的。
在C#中,new表达式总是要求一个具体指定的类型(除了隐式类型数组表达式)。现在,如果表达式被指派给一个明确的类型时,你可以忽略new关键字后面的类型。
Point p = new (3, 5);
当有大量重复,这个特别有用。例如下面数组初始化:
Point[] ps = { new (1, 2), new (5, 2), new (5, -3), new (1, -3) };
6 返回值类型支持协变
有时候,在子类的一个重写方法中返回一个更具体的、且不同于父类方法的返回类型更为有用,C# 9.0对这种情况提供了支持。如下列子中,子类Tiger的在重写父类Animal的GetFood方法时,返回值使用了Meat而不是Food,就更为形象具体。
abstract class Animal { public abstract Food GetFood(); ... } class Tiger : Animal { public override Meat GetFood() => ...; }