C # 9.0的record
阅读原文时间:2023年07月10日阅读:1

官方消息: c # 9.0已经过时了!早在五月份,我就在博客中介绍了 c # 9.0计划,下面是该文章的更新版本,以便与我们最终发布的计划相匹配。

对于每一个新的 c # 版本,我们都在努力提高常见编码场景的清晰度和简单性,c # 9.0也不例外。这次的一个特别重点是支持数据形状的简洁和不可变的表示。

对象初始化器非常棒。它们为类型的客户端创建对象提供了一种非常灵活和可读的格式,特别适用于嵌套对象创建,在嵌套对象创建过程中,可以一次性创建整个对象树。下面是一个简单的例子:

var person = new Person { FirstName = "Mads", LastName = "Torgersen" };

对象初始值设定项还可以使类型作者免于编写大量构造样板文件——他们所要做的就是编写一些属性!

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 属性的赋值都是错误的:

var person = new Person { FirstName = "Mads", LastName = "Nielsen" }; // OK
person.LastName = "Torgersen"; // ERROR!

因此,只初始化属性保护对象的状态在初始化完成后不会发生变异。

因为只能在初始化期间调用 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)));
    }
}

经典面向对象程序设计的核心思想是,对象具有强大的身份,并封装了随时间演变的可变状态。C # 在这方面一直很有效,但是有时候你想要的恰恰相反,这里 c # 的默认设置往往会妨碍工作,让事情变得非常艰难。

如果你发现自己希望整个对象是不可变的,并且表现得像一个值,那么你应该考虑将它声明为一个记录:

public record Person
{
    public string? FirstName { get; init; }
    public string? LastName { get; init; }
}

记录仍然是一个类,但是记录关键字为它灌输了一些附加的类值行为。一般来说,记录是由它们的内容而不是它们的身份来定义的。在这方面,记录更接近于结构,但记录仍然是引用类型。

虽然记录是可变的,但是它们主要是为了更好地支持不可变数据模型而构建的。

在处理不可变数据时,一个常见的模式是从现有数据创建新的值来表示新的状态。例如,如果我们的人要改变他们的姓氏,我们会将其表示为一个新对象,这个对象是旧对象的副本,只是姓氏不同。这种技术通常被称为非破坏性突变。记录代表的不是随着时间的推移而代表的人,而是代表人在给定时间的状态。为了帮助这种编程风格,记录允许一种新的表达式: with-expression:

var person = new Person { FirstName = "Mads", LastName = "Nielsen" };
var otherPerson = person with { LastName = "Torgersen" };

With-expressions 使用对象初始值设定项语法来说明新对象与旧对象的不同之处。可以指定多个属性。

With-expression 的工作方式是将旧对象的完整状态复制到新对象中,然后根据对象初始值设定项对其进行变异。这意味着属性必须有一个 init 或 set 访问器才能在 with-表达式中更改。

所有对象都从对象类继承一个虚 Equals (对象)方法。这被用作 Object 的基础。当两个参数都非空时,Equals (object,object)静态方法。结构会重写这个函数,使其具有“基于值的相等性”,并通过递归地调用 Equals 对结构的每个字段进行比较。唱片也是如此。这意味着,根据它们的“值性”,两个记录对象可以相等而不是同一个对象。例如,如果我们再次修改被修改人的姓氏:

我们现在有 ReferenceEquals (person,originalPerson) = false (它们不是同一个对象) ,但 Equals (person,originalPerson) = true (它们具有相同的值)。除了基于价值的 Equals 之外,还有一个基于价值的 GetHashCode ()覆盖。此外,记录实现了 IEquatable < t > 并使 = = 和!= 操作符,因此基于价值的行为在所有这些不同的平等机制中一致地显示出来。

价值等同性和易变性并不总能很好地结合在一起。一个问题是,更改值可能会导致 GetHashCode 的结果随着时间的推移而更改,如果对象存储在哈希表中,这将是不幸的!我们不禁止可变记录,但是我们不鼓励它们,除非您已经考虑到了后果!

记录可以从其他记录继承:

public record Student : Person
{
    public int ID;
}

使用-表达式和值相等可以很好地处理记录继承,因为它们考虑了整个运行时对象,而不仅仅是静态地知道它的类型。假设我创建了一个 Student,但是把它存储在一个 Person 变量中:

Person student = new Student { FirstName = "Mads", LastName = "Nielsen", ID = 129 };

一个 with-expression 仍然会复制整个对象并保持运行时类型:

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, since ID's are different

有时,对记录使用更加位置化的方法是有用的,其中记录的内容通过构造函数参数给出,并且可以通过位置解构提取。完全可以在一个记录中指定自己的构造函数和解构函数:

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);

这声明了公共 init-only auto-properties、构造函数和解构函数,以便您可以编写:

var person = new Person("Mads", "Torgersen"); // positional construction
var (f, l) = person;                        // positional deconstruction

如果不喜欢生成的 auto-property,可以改为定义自己的同名属性,生成的构造函数和解构函数将只使用该属性。在这种情况下,您可以使用该参数进行初始化。比如说,你希望 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);

用 c # 编写一个简单的程序需要大量的样板代码:

using System;
class Program
{
    static void Main()
    {
        Console.WriteLine("Hello World!");
    }
}

这不仅对语言初学者来说是压倒性的,而且会使代码变得杂乱无章,增加缩进的级别。在 c # 9.0中,你只需要在顶层编写你的主程序:

using System;

Console.WriteLine("Hello World!");

任何声明都是允许的。程序必须在使用之后以及文件中的任何类型或名称空间声明之前执行,而且只能在一个文件中执行此操作,就像现在只能有一个 Main 方法一样。如果您想返回状态代码,您可以这样做。如果你想等待,你可以这样做。如果您想访问命令行参数,可以使用 args 作为“ magic”参数。

using static System.Console;
using System.Threading.Tasks;

WriteLine(args[0]);
await Task.Delay(1000);
return 0;

局部函数是一种语句形式,在顶级程序中也是允许的。从顶级语句部分以外的任何地方调用它们都是错误的。

在 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 部分写成一个嵌套的开关表达式:

DeliveryTruck t when t.GrossWeightClass switch
{
    > 5000 => 10.00m + 5.00m,
    < 3000 => 10.00m - 2.00m,
    _ => 10.00m,
},

这里 > 5000和 < 3000是关系模式。

最后,您可以将模式与逻辑运算符组合起来,并且(或者)作为单词拼写,以避免与表达式中使用的运算符混淆。例如,上面的嵌套开关可以按如下升序排列:

DeliveryTruck t when t.GrossWeightClass switch
{
    < 3000 => 10.00m - 2.00m,
    >= 3000 and <= 5000 => 10.00m,
    > 5000 => 10.00m + 5.00m,
},

这里的中间格使用和结合两个关系模式,并形成一个表示区间的模式。Not 模式的一个常见用法是将其应用于 null 常量模式,如 not null。例如,我们可以根据未知情况是否为空来分割处理:

not null => throw new ArgumentException($"Not a known vehicle type: {vehicle}", nameof(vehicle)),
null => throw new ArgumentNullException(nameof(vehicle))

在 if-conditions 中包含 is-expressions,而不是笨拙的双括号,这样也不方便:

if (!(e is Customer)) { ... }

你可以直接说

if (e is not Customer) { ... }

事实上,在 is not 这样的表达式中,我们允许您为 Customer 命名以供后续使用:

if (e is not Customer c) { throw ... } // if this branch throws or returns...
var n = c.FirstName; // ... c is definitely assigned here

“ Target typing”是一个术语,用于表达式从使用它的上下文中获取其类型。例如,null 和 lambda 表达式总是目标类型的。

C # 中的新表达式总是要求指定一个类型(隐式类型数组表达式除外)。在 c # 9.0中,如果表达式被赋值为一个明确的类型,则可以省略该类型。

Point p = new (3, 5);

当你有很多重复的时候,比如在数组或者对象初始值设定项中,这个特别好:

Point[] ps = { new (1, 2), new (5, 2), new (5, -3), new (1, -3) };

有时表示派生类中的重写方法具有比基类中的声明更具体的返回类型是有用的。9.0允许:

abstract class Animal
{
    public abstract Food GetFood();
    ...
}
class Tiger : Animal
{
    public override Meat GetFood() => ...;
}

还有更多…

查看完整的 c # 9.0特性的最佳位置是“ c # 9.0的新功能”文档页面。