记录是一种特殊的类或结构体,其设计意在处理不可变(只读)数据。其中最有用的特性就是非破坏性更改(nondestructive mutation)。除此之外,记录也常用于创建合并或保存数据的类型。在简单的场景中,它不但可以消除样板代码,还保持了不可变类型最需要的相等语义。
记录仅仅是C#编译期中的结构。在运行时,CLR中的记录和类是一样的(只不过前者中有不少由编译器“合成”的成员)。
使用不可变类型(即类的字段在初始化后不能更改)是简化程序和消除缺陷的常见方式,也是函数式编程的重要组成部分。函数式编程需要避免使用可变类型,并将函数作为数据对待。LINQ就是基于这种原则进行设计的。
如需“更改”不可变对象,则需要创建一个新对象,将数据复制到新对象中,并在该过程中进行修改(称为非破坏性更改)。这种方式在性能上往往没有预想的那么糟糕,因为浅表复制就已经足够了(深层复制会复制子对象和集合,当数据不可变时是不必要的)。但从编码的角度说,实现非破坏性更改是非常麻烦的,在属性众多时尤其如此。记录通过语言层面支持的模式解决了这个问题。
有时,程序员(尤其是函数式程序员)面临的另一个问题是如何将不可变类和其他数据组合(但并不额外增加行为)。定义这种类型需要的工作量往往超出预期,它需要在构造器中将每一个参数赋值给相应的属性(此处解构器应该可以发挥作用)。若使用记录,则编译器就可以帮助我们完成上述工作。
最后,如果对象不可变,那么它的标识自然也无法改变。因此,这种类型更适合进行结构化的相等比较而非引用相等比较。结构化相等即两个实例只有在其数据相等时才相同(和元组一致)。(无论这个记录是类还是结构体)记录默认使用结构化相等比较,无须编写样板代码。
定义记录和定义类或结构体相似,它们都可以包含相同种类的成员,包括字段、属性、方法等。记录可以实现接口,基于类的记录还可以继承其他(基于类的)记录。
默认情况下,记录的底层类型是“类”:
从C# 10开始,记录的底层类型还可以基于结构体:
( record class 也是合法的,它的含义和只写 record 是一样的。)
一个简单的记录可能仅仅包含一系列只用于初始化的属性和一个构造器:
以上构造器使用了前一节中描述的简化写法:
上述写法在本例中等价于:
C#在编译时会将记录的定义转换为类(或结构体),并执行以下附加步骤:
· 生成一个protected的复制构造器(与一个隐藏的克隆方法)以实现非破坏性更改。
· 重写或重载相等比较相关的函数来实现结构化相等的比较。
· 重写 ToString() 方法(展开并输出记录中的公有属性,这个行为和匿名类型类似)。
因此,上述记录的声明将扩展为类似如下代码:
虽然我们仍然可以在记录的构造器中添加可选参数,但是我们并不推荐这样做(至少在公有的程序库中)。我们建议仅仅将那些只用于初始化的属性放在构造器中:
这种模式的优点是,若消费者编译时使用了旧版本的程序集,那么我们可以安全地在记录中添加只用于初始化的属性而不破坏二进制兼容性。
记录的定义中也可以包含参数列表:
参数支持 in 和 params 修饰符,但是不支持 out 或 ref 修饰符。如果在定义中指定了参数列表,则编译器将执行以下步骤:
· 为每一个参数生成一个只用于初始化的属性。
· 生成一个主构造器来输入属性的值。
· 生成一个解构器。
除非使用 readonly 关键字进行修饰,否则当使用参数列表定义record struct时,编译器会生成可写的属性而不是仅进行初始化的属性:
这样做的原因是在典型的应用场景下,struct的不可更改特性并非由于struct本身不可更改,而是使用struct的上层结构是不可更改的。在以下范例中,即使X字段本身是可写的,我们也无法更改X字段:
但是以下语句却是可行的:
我们唯一能做到的只是更改一个局部变量( test.Prop 的副本)。更改一个局部变量的值可以作为一种有效的优化,同时又不会影响非可变类型系统带来的好处。
但反之,如果 Field 是一个可写入的字段,且 Prop 也是一个可写的属性,则不管如何声明 Mutable 结构体,我们都可以轻松更改其内容。
因此,如果我们按如下方式简单地声明 Point 记录:
编译器将生成和上一节中几乎一样的展开代码,其中一个小的区别是构造器中参数的名称是 X 和 Y 而非 x 和 y :
主构造器的参数 X 和 Y 可以“神奇地”被记录中的任何字段与只用于初始化的属性利用。我们将在4.12.6节中说明这个细节。
定义参数列表的另一个不同之处是编译器会生成相应的解构器:
拥有参数列表的记录还可以使用如下继承语法:
相应地,编译器将生成如下主构造器:
当我们需要定义聚合了多个值的类(在函数式编程中称作积类型)时,就可以使用参数列表对代码进行有效的简化。它同样适用于构造原型。但是当需要在 init 访问器中添加逻辑(例如用于验证参数)时,记录就不太适用了,我们稍后会对此进行介绍。
编译器在所有记录类型上执行的最重要的一步就是生成了复制构造器(和隐藏的克隆方法)。这样就可以使用 with 关键字对记录对象进行非破坏性更改了:
在上述示例中, p2 是 p1 的副本,但是 p2 的 Y 属性的值为 4 。属性数目越多,这种方式的优势就越明显:
上述代码的输出为:
非破坏性更改分为两个阶段:
1.首先,复制构造器复制记录对象。它默认将复制记录对象中的每一个字段,一板一眼地创建对象副本并忽略 init 访问器中的任何逻辑(这也避免了初始化的开销)。这个过程会涉及所有的字段(无论是public还是private字段,抑或是由自动属性生成的隐藏字段)。
2.接下来,更新成员初始化列表中的每一个属性的值(使用 init 访问器)。
因此,编译器会将下列代码:
转换为与以下代码功能等价的形式:
(显式编写以上代码是无法通过编译的,因为 A 和 C 都是只用于初始化的属性。此外,复制构造器是protected的。C#通过调用记录中生成的public隐藏方法 <Clone>$ 来解决构造器的调用问题。)
如有必要可以自行定义复制构造器。C#将使用自定义的构造器而不再额外生成复制构造器:
如果记录中包含需要复制的可更改的子对象或集合,或者包含需要清理的计算字段,则可以编写自定义的复制构造器。但遗憾的是,我们只能够替换而无法增强默认的实现。
在继承其他的记录时,复制构造器只应复制自身的字段。复制基记录字段的工作则应当委托给基记录进行:
记录中的显式声明的属性可以在 init 访问器中添加校验逻辑。以下示例将确保 X 永远不为 NaN (非数字):
以上设计确保了在构造与非破坏性更改操作时均对属性进行校验:
但刚刚我们提到过,自动生成的复制构造器将复制所有字段与自动属性的值。因此,上述记录的复制构造器将类似于以下代码:
需要注意的是复制 _x 字段会绕过 X 属性访问器的限制。但是这并不会造成任何后果。因为这个完全复制对象的值已经安全经过了 X 中 init 访问器的验证。
在函数式编程中,延迟评估是不可变对象的一种常见模式。延迟评估即只有在需要的时候才计算相应值,并将计算结果缓存起来留备后用。例如,若需要在 Point 记录中添加一个属性,返回当前点与原点 (0,0) 的距离:
我们可以对上述代码进行重构,避免每次请求 DistanceFromOrigin 时都重复计算。首先,删除参数列表,并将 X 、 Y 和 DistanceFromOrigin 均定义为只读属性。这样我们就可以在构造器中计算 DistanceFromOrigin 的值:
上述代码能够正常工作,但是无法进行非破坏性更改(将 X 和 Y 更改为只用于初始化的属性将破坏代码功能,因为 DistanceFromOrigin 的值在 init 访问器执行之后可能不再成立)。此外,上述代码无论是否访问了 DistanceFromOrigin 都会执行距离计算,因此优化并不彻底。优化的方案应当在第一次使用属性时延迟计算并缓存距离值:
从技术角度看,我们在代码中“更改”了 _distance 的值。但是我们仍然可以说 Point 是不可变类型,因为延迟计算并更改字段的值并未违反不可变性的规则,也未破坏其优点,甚至还可以和第21章中的 Lazy<T> 类型合并使用。
使用C#的null合并赋值运算符可以将整个属性的声明精简为一行代码:
(以上代码当 _distance 非null时返回 _distance ,否则将 Math.Sqrt(X*X + Y*Y) 赋值给 _distance 并返回 _distance 的值。)
为了使上述代码与只用于初始化的属性协同工作,还需要进一步修改,即在 X 和 Y 通过 init 访问器赋值时清空缓存的 _distance 字段。完整代码如下:
这样, Point 就可以支持非破坏性更改功能了:
一个意外的惊喜是自动生成的复制构造器也会复制缓存的 _distance 字段。因此,如果记录中的其他属性没有涉及相应的计算,则对这些属性的非破坏性更改不会损失缓存的值。如果你对此并不在意,除了在 init 访问器中清理缓存值之外,还可以编写自定义的复制构造器忽略缓存的字段。这样做更加精炼,因为这个方案可以保留参数列表。自定义复制构造器也可以利用解构器实现:
需要注意,无论采用哪种方案,添加延迟计算字段都会破坏默认的结构化比较机制(因为这些字段有可能被赋值也有可能没有)。但是这个问题修正起来并不困难,我们稍后会进行介绍。
当我们使用参数列表定义记录时,编译器会自动生成属性的声明,同时会生成主构造器(和解构器)。如你所见,这种机制在简单情形下工作良好,而在更加复杂的情况下,则可以忽略参数列表,自行编写属性声明与构造器。
如果你愿意处理主构造器的奇特语义,则C#还可以提供另外一个略显折中的方案,即定义参数列表,同时编写部分或所有属性的声明:
在上述示例中,我们接管了 ID 属性的定义。我们将其定义为一个只读属性(而不是只用于初始化的属性),防止其参与非破坏性更改过程。如果我们不希望在非破坏性更改过程中更改特定属性,就可以将它声明为只读属性。这样它就只能在记录中存储运算结果,而无须进行更新。
注意,这些只读属性需要进行初始化(见粗体文字):
当我们接管属性声明时,属性初始化就是我们的责任了。主构造器将不再进行自动操作。此外,上述代码中粗体的 ID 引用的是主构造器中的参数,而不是 ID 属性。
而在record struct中,重新将属性定义为字段是合法的:
主构造器的特别之处是其参数(本例中为 ID 、 LastName 和 GivenName )对于所有的字段和属性初始化器都是可见的。我们可以将上述示例进行扩展来演示该特性:
同样,粗体的 ID 引用的是主构造器的参数而不是属性。(这种写法并不构成二义性的原因是在初始化器中访问属性是非法的。)
在本例中, _enrollmentYear 的值是从 ID 的最初四位数字中计算出来的。虽然将其保存在只读字段中是安全的(因为 ID 属性是只读属性,因此它不可能被非破坏性更改),但是在真实工程中却并不适用。这是因为若没有显式的构造器,就没有一个集中的地点对 ID 进行校验,并在 ID 非法时抛出有意义的异常(而这是一个普遍的需求)。
需要对属性进行验证也是编写 init 访问器的理由(请参见4.12.4一节)。但是主构造器和这种要求配合不佳。例如,请考虑如下记录,其 init 访问器将执行是否为null的校验:
由于 Name 不是自动属性,因此无法(在属性上)进行初始化。最好的方式就是对字段进行初始化(见粗体部分)。但遗憾的是通过以下代码即可跳过null校验:
可见,不自行编写构造器是无法将主构造器参数赋值给属性的,这就是目前面临的难题。虽然有一些替代措施(例如,将 init 中验证逻辑重构到一个独立的静态方法中调用两次),但最简单的方式就是放弃使用参数列表,自行编写普通的构造器(如果需要的话还需要编写解构器):
和结构体、匿名类型与元组一样,记录默认提供了结构化相等比较的能力,这意味着两个记录只有在所有字段(与自动属性)都相等时才相等:
记录(和元组一样)也支持相等运算符:
记录默认的相等比较的实现不可避免地存在一些问题。例如,若记录中包含延迟加载的值、数组或集合类型(它们需要为相等比较做特殊的处理),则会破坏相等比较的逻辑。所幸,修正这些相等比较问题是比较容易的,并且无须为类或结构体完整地实现相等比较逻辑。
和类与结构体不同,我们无法为记录重写 object.Equals 方法,而是需要定义以下形式的公有 Equals 方法:
该 Equals 方法必须是 virtual (而不是 override ),它的参数表必须是强类型的,例如,实际的记录类型(本例中为 Point 而不是 object )。若方法签名正确,则编译器将自动使用该方法。
在本例中,我们更改了相等比较的逻辑,比较了 X 和 Y 的值而忽略了 _someOtherField 字段。
如果当前记录继承了其他记录,则可以调用 base.Equals 方法:
和其他类型一样,自定义相等比较还需重写 GetHashCode() 方法。对于记录来说,我们无须重写 != 或 == ,也无须实现 IEquatable<T> ,这些工作都将自动完成。我们将在6.13节中完整地介绍相等比较。