欢迎光临散文网 会员登陆 & 注册

探索 C# 10 的记录结构类型

2021-09-25 05:08 作者:SunnieShine  | 我要投稿

C# 9 带来的记录类型确实给我们带来了很多方便的地方,让我们实现一个 POCO 变得相当容易。不过问题在于,一个记录类型还是太笨重了。且不说生成的东西和成员,记录类型是一个类,所以实例化一个类的话,必须走堆内存一遭,所以耗时必然会很多。

为了解决这个问题,C# 10 带来了结构记录类型。

还是先来说一下基本语法

为了体现结构记录类型的本质是一个结构,我们需要在 record 上下文关键字的后面补充一个 struct 关键字。假设我现在拥有一个这样的数据类型,那么简化后是这样的:

我们之前是把 class 改成 record,而现在我们把 struct 改成 record struct。我们就把这个用 record struct 组合关键字修饰的类型称为结构记录类型,或者记录结构类型(Record Struct);因为 C# 10 诞生了结构版本的记录类型,所以为了方便比较和对比,原本 C# 9 里的记录类型我们这里也可以改名叫做类记录类型,或者记录类类型(Record Class)。稍微注意一下名字上的规范,在中文里,“结构”和“类”可以放在“记录”这个词语的前面或者后面都行,但在英语环境下,我们是固定放在 record 这个单词的后面的,即 record class 和 record struct,而不是 struct record 或 class record,请一定要注意。

结构记录类型的底层

和记录类一样,记录结构也是生成了完全一致的这些成员:

  • 一个带有这些属性对应赋值的构造器;

  • Equals 的重写方法(签名大概是 bool Equals(object));

  • Equals 方法,参数不是 object 而是这个数据类型本身(签名大概是 bool Equals(T));

  • GetHashCode 重写方法(签名大概是 int GetHashCode());

  • ToString 重写方法(签名大概是 string ToString());

  • Deconstruct 解构方法(签名大概是 void Deconstruct(...),参数都是 out 类型的,把每一个写在小括号的数据成员全部挨个写入到这里当参数);

  • 运算符 ==!=,参数是这个类型自己(签名分别是 operator ==(T, T)operator !=(T, T));

  • 一个 private 或者 protected 修饰的 PrintMembers 方法(签名大概是 bool PrintMembers(StringBuilder))。

不过少了一个 Clone 方法和一个复制构造器。这个原因也很简单:因为结构的等号就自带拷贝副本的光环,你在使用的时候,因为引用类型的等号赋值过程都是赋值地址数据(引用),所以为了支持数据拷贝,所以有 Clone 才可以搞定;但在结构里,等号就有一样的效果了,因此就没有了这两个专门用来拷贝用的成员了。

然后,少了一个 EqualityContract,因为没有必要有了。

而且,这些剩余的成员,生成的底层代码也都和记录类里生成的内容是完全一样的,所以没有必要刻意说明细节;不过还是有一个要说的地方,就是这个 readonly 修饰符的问题。

readonly record structrecord struct 底层代码的区别

区别一:部分自动生成的成员是否标记 readonly 修饰符

C# 7 带来了 readonly struct 的概念,而 C# 8 则带来了 readonly 结构成员的概念,因此 readonly 关键字就得在这里好好说说了。

先回顾一下 readonly 修饰符的基本用法:

  • 修饰到字段上,表示字段的数据在构造器和自身赋值语句初始化后不再更改;

  • 修饰到结构上,表示结构里的所有实例成员(构造器除外)都是只读的;

  • 修饰到结构里的实例成员(构造器和字段除外)上,表示结构里的所有这些成员在执行期间都不会发生该类型数据成员的数据在底层的变动。

这里我们只用得上后面这两种情况。比如说我现在有一个结构 Student,里面有一个属性 AverageScore,它只有 get 方法,目的是获取这个学生实例的学习平均分。按照规范执行,我们必须得这么实现代码:

或者你直接改写用 Lambda 写法简记:

可以看出,我们完完全全只是在读取里面的数据,而怎么也不会通过这个 get 方法去改变比如 MathEnglishChinese 的数值。因此,我们称这样的属性是只读的。按照 C# 8 提供的语法,我们需要使用 readonly 修饰符修饰到属性上:

这样可以更加严谨一些,通过语法层面来限定,以便以后优化代码。

回到这里。说这个是干什么呢?readonly record structrecord struct 的区别在于,底层生成的代码都带不带 readonly 修饰符的这个问题。

如果是 readonly record struct,因为大家都知道这个类型都标记了 readonly 了,那么自然里面所有成员都得遵章守纪按照不修改数据的方式来获取数值,因此所有实例成员都不必再标记 readonly 了,因为本来就有了 readonly 了还标记重复的 readonly 就没有意义。当然,这只针对于非字段的实例成员。字段最开始就必须要求 readonly 修饰符,它的 readonly 不可省略。

但是,如果这个记录结构本身没有 readonly 修饰符的话,这意味着里面的某个或某些成员可能会在实例化后仍然可变(数值发生变化)。这种情况下,我们只能对一部分不更改变动底层数值的实例成员标记 readonly 了。此时,我们回顾一下刚才我们说到的自动生成的成员,可以发现,里面部分的成员(比如 ToString 方法、Equals 方法)都只是取值来得到输出结果的目的,它们不会变更数值。因此,在底层代码里,这些成员会在自动生成的代码上带上 readonly 修饰符。

区别二:initset 属性赋值器

是的。C# 10 的结构记录在这点上是有区分的。因为 readonly 修饰符的特性告知了用户这个类型是否可变,因此我们强制用户必须优先考虑加上 readonly 修饰符到 record struct 的声明上去;如果确实可变,那么我们则可以考虑用户不加 readonly 修饰符。

那么可变和不可变体现的地方就在这个属性生成的代码上。

我们对比两个声明,主构造器的参数表列全部是一样的,只是声明的时候一个有 readonly 一个没有。在底层,这三个属性在底层里是生成了带有 getinit 的自动属性,而在没有 readonly 修饰的记录结构里,这三个属性在底层里则是生成了带有 getset 的自动属性。即大概是这样的:

这是它们的第二个区别。

你可能会问我。既然有赋值器,那么说明这个属性是可变的啊,那么为什么 readonly record struct 又可以修饰 readonly 呢?这不是矛盾了吗?实际上并不是。这个 init 修饰符保证了赋值过程只发生在初始化器里,也就是说,它只能用在 new 表达式的后面接着大括号,里面包含的这个初始化器的内容一起构造成为整体。而 init 保证了赋值过程只出现在这里,所以属性只是在初始化的时候发生了变更,在使用的时候完全没有,这不还是跟构造器实例化对象是一个效果吗?所以说,在结构里,一个 readonly struct 是允许属性包含 init 赋值器的,而 set 赋值器却不行。

区别三:主构造器参数和非合成属性重复报错

在 C# 9 的记录类里,如果出现了主构造器参数和属性重名的情况,会产生编译器警告;但是在 C# 10 里的这个记录结构里,就不是编译器警告了,而是编译器错误。

比如这样的代码,属性 X 和主构造器参数 X 重名了。此时因为 Pos 是记录结构,因此将产生编译器错误,而不是编译器警告。那么为什么不统一呢?因为早期这个错误信息只是一个较弱的约束,而产生了记录结构后,相当于是升级版的记录类型,但又为了保持语法的兼容,原本的级别没有得到调整,但现在大家都知道了,X 重名后就必然导致 X 有一个完全无法用到,所以编译器错误才应该是更合适的报错级别。因此报错的级别并不一致,是这个原因。

记录结构的 with 表达式

在标题上,我直接把“记录结构”的“记录”给划掉了。可能你很诧异,我这行为是在干嘛呢?还记得 with 表达式在 C# 9 的记录类里是怎么用的吗?

即初始化了实例后,仍可以通过 with 关键字“小幅度调整”对象里的数据成员信息,然后把 a with { ... } 整个表达式用这个得到的修改后的对象给直接替换掉。而在这个期间,底层是会调用 Clone 方法来复制副本的,这样才能保证这里的 ab 完全是两个不同引用的对象。

但是,这一点在 C# 10 的记录结构里就显得没有必要了。结构是不需要 Clone 的,也不需要复制构造器的,所以 C# 10 干脆开放了语法,让所有结构(不管是不是 record struct)全部都可以直接用 with 表达式了。

当然了,如果是 record struct 肯定是可以用 with 表达式的:

用法则和之前的 with 没啥区别。

结构初始化行为相关语法的调整

为了让记录结构更加灵活,C# 语言团队不得不调整对结构的一些初始化行为的逻辑。

请注意,下面的内容会直接颠覆和改变你对原本 C# 里结构的用法逻辑和理解方式。请一定要注意 C# 10 改变了结构的初始化逻辑和规则。

要知道,C# 的结构是非常轻量级的数据类型,它的出现引出了很多初始化的基本概念。比如说,它的语法是合取了所有内置的那些数据类型的初始化方式,才得到了这些结论:

  • 这些数据类型初始化之前必须得有数值传入;

  • 这些数据因为基本数据类型,所以必须预先准备好固定的分配内存规则,毕竟值类型一般会被放进栈内存。

出于这些基本限制,C# 早期做出了这些限制:

  • 必须带有一个用户无法更改的无参构造器,用于默认初始化内存用;

  • 所有数据成员均无法手动初始化(即在成员的默认补充 = 数值; 的赋值部分)。

下面我们针对于这样两个内容来描述一下,变更的规则是如何的。

无参构造器

早期来说,C# 的无参构造器是客观存在的,不论你是否声明了别的构造器,无参构造器都是客观存在的;而且,正是因为这个原因,C# 甚至不让你自定义无参构造器。虽然无参构造器长这样:

即使写法上很简单,但 C# 仍然不让你自己写。虽然它一般都和内存分配绑定起来,可问题就在于,这么做一个限定让我们初始化一些数据的时候极为不便,因为有些时候我就希望一些数据在初始化的时候就有不同的数值,而如果我们尝试调用有参构造器的话,又会产生冗余参数,而我只是想自定义一个默认的初始化行为而已,这样 C# 早期的语法就无法做到。

为了避免这样的不便,C# 10 作出了妥协:允许用户可定义一个必须是 public 的无参构造器,这样的话,用户就可以自定义初始化行为了。不过要注意的是,必须是 public 修饰,别的访问修饰符都不行。因为你既然都愿意更改初始化的行为了,但构造器一直都必须调用,以得到正常的初始化效果,那么无参构造器设置为不公开的情况的话,那么该有的限制还是没有。因此,无参构造器仍然必须是 public 的。

当然,还有一个只能定义为 public 的原因,也是最为主要的原因:因为方便编译器分析。如果一旦出现非 public 的构造器的话,一个结构的初始化行为的复杂度就会高出不止一个级别。比如我只有一个 private 的无参构造器是自定义的,那么就会影响到我后期比如使用反射创建实例化对象,以及 new() 泛型约束检测它是不是包含无参构造器之类的。所以,C# 10 干脆就直接限制你不让你创建非 public 的自定义无参构造器。

不过,由于无参构造器可以用户自行定义后,就会影响一系列的语法规则。

第一,default 表达式。其实也很好理解,因为无参构造器有了之后,初始化默认行为就改变了,以至于结构里,default(T)new T() 的语义不再一样。你始终记住,default 表达式永远都是那个早期的那种、所有数据成员都是这个数据类型自身的默认数值,所构建出来的实例结果;而现如今的 new T() 则是两种情况:

  • 如果有自定义无参构造器,那么 new T() 按现在定义的那样计算初始化结果;

  • 如果没有自定义无参构造器,那么 new T() 就和 default(T) 是一样的。

第二,new() 泛型约束。C# 2 的泛型引入了 where T : new() 的这种约束模式,它约束这个对象必然有无参构造器。如果一个泛型类型的约束是这样的的话:

那么它会不会受到影响呢?不会。因为无参构造器不管你自己定义还是系统自己生成,这个都不会影响,因为两种情况下,无参构造器都是有的。所以,大大方方用吧,这一点来说是没有差别的。

第三,迭代结构类型。虽然 new() 泛型约束不受影响,但是对迭代类型来说的话,就不太一样了。所谓的迭代类型,比如下面的例子就是一个良好的描述:

这个 S1 结构类型里有一个 S0 结构类型的字段,而 S 结构类型是一个泛型类型,它包含一个泛型参数 T 类型的字段,而这个 T 则是一个结构。

这里我们要说一下有点奇怪的规则。为了达到 C# 10 的无参构造器定义和不影响初始化行为的规则,此时这两个 F 字段(分别位于  S1 类型和 S<T> 类型里)是如何初始化的呢?答案是,忽略掉。是的,直接忽略掉。无参构造器只提供了一种你自定义初始化行为的手段,但它仍然不影响任何时候其它地方系统的初始化过程。

比如 S1 类型的 F 字段,按照初始化的行为规则,F 因为是字段,所以必须在初始化实例之前给结构的所有成员赋值。而如果 S1 没有任何自定义的构造器的话,那么这个字段将保持默认数值。默认数值是多少,我刚才说过了吧。可问题就在于,此时 S0 类型有一个我们定义了的无参构造器,所以这个 F 初始化的时候,我们仍然是不看这个构造器的;取而代之的是,default(S0)作为 F 字段的默认值。是的,更严谨的语言是,所有字段默认初始化为 default(T) 结果,而并不是 new T() 结果。这个需要你记清楚。

而第二个例子里,S 类型是一个泛型类型,包含一个结构类型的泛型参数。那么如果把这个泛型类型的参数作为类型,创建一个字段放在这个类型里的话,这个 F 参照的是什么初始化表达式呢?答对了,还是 default(T),仍然不是 new T()。因此,一定要注意这里。

第四,base() 基类型构造器的调用。虽然结构没有什么所谓的基类型的概念,因为它自己是无法自定义继承和派生关系的。但是,别忘了它隐式从 ValueType 这个类派生下来的,而 ValueType 是一个抽象类,不允许你直接实例化。问题来了,我如果有一个结构 S,那么我既然能够定义无参构造器了,那么是不是意味着我能够调用 base() 来获取 ValueType 里的无参构造器的初始化过程呢?

答案是,不可以。C# 10 仍禁止你调用 base()。倒不是因为 ValueType 是抽象类所以没有无参构造器,而是因为不让你用。

第五,结构类型元素的数组的初始化。还是这个熟悉的配方。我们仍然使用 S 来表示一个结构类型,并且假设定义好了一个无参构造器。那么如果我这么定义:

那么是不是 s 变量的每一个元素都调用了 new T() 这个自定义的无参构造器呢?答案是,否定。它们的初始化仍然用的是 default(T) 的结果而不是 new T() 的结果。

第六,可选参数和变长参数导致的假无参构造

如果我做了上述的代码书写的话,那么请问两次实例化有哪个(哪些)是会输出 42 的呢?

答案是,一个都没有。无参构造器是最匹配的项,而有可选参数和变长参数的构造器只是在书写的时候可以有假的无参的写法表现,但并不意味着真的无参。所以按照匹配的优先级来说,它们是比无参构造器低一级的,因此,两个都是调用的无参构造器,而上面的代码没有写,因此啥都不输出,因此你看不到输出 42 的情况。

最后一点,就是反射里调用无参构造器输出的情况。这个和泛型约束 new() 的结论差不多。因为结构此时必须是 public 的,所以一定有可访问的无参构造器,因此不论你自定义与否,它都是客观存在的,因此并不会影响。假设你要使用比如 Activator.CreateInstance<T>() 的语法创建一个结构类型实例的话,这么做也是永远都成功的。

成员初始化器

无参构造器我们说完了,下面我们来说一下成员初始化器(Member Initializer)。成员初始化器这个名字跟初始化器差不多,但用法上不同。一般我们说初始化器都指的是对象初始化器(Object Initializer),书写格式是大括号,里面跟上属性等于数值的键值对的赋值过程;而这里说的成员初始化器,则指的是直接在成员的声明语句的末尾追加 = 数值; 的初始化部分。那么与其说是成员初始化器,还不如严谨一点叫它数据成员初始化器,因为这种初始化过程只发生在字段和自动属性上。

从 C# 10 开始,结构和类的初始化过程就越来越类似了。当然,为了保留和兼容之前 C# 的定义规则,初始化器仍然也不是那么像类的初始化过程。

首先我们来回顾一下类的初始化过程:类类型的实例会调用 new 后给的构造器,传入参数,并给数据赋值。如果没有赋值到的对象,则剩余的数据成员将会挨个按照代码顺序从上往下挨个赋值,赋的值是 default(T),即它自己这个类型的默认数值。

而结构的话,初始化也差不多了。只不过,因为结构在初始化器里必须对所有对象要完成初始化,因此这个限制在 C# 10 里只推广了一丢丢:结构里的数据成员必须经过成员初始化器或构造器完成初始化。如果构造器里不初始化的话,那么这个数据成员必须包含成员初始化器;否则编译器将产生错误。这和类类型不同:类类型是不赋值也可以,它会自动得到默认数值;而结构类型则必须添加成员初始化器以避免编译器报错,哪怕你知道这里赋值只是一个简单的 default(T) 你也得写上;除非,它可以在对象初始化器里赋值,即这个属性是 public 的非 readonly 实例字段,或者是带有 init 的属性。这样的话就不用要求你必须在构造器里赋值了,因为它可以别的地方赋值。

这个 Prop3 是我胡诌上去的写法,就是为了阐述和表达出它是带有 init 赋值器的属性。这个属性因为可以在对象初始化器里赋值,因此可以不在构造器里赋值的同时,补上成员初始化器。

成员的合成和非合成

合成成员

和 C# 9 的记录类是差不多的,概念我就不提了,概念是一样的。

自定义成员

这一点和 C# 9 的记录类也是差不多的。比如我在 Student 记录类型里加入可变的属性成员 Class

此时语法上是没有问题的,编译器也不会认为你这么写代码会有问题。不过,因为 Person 记录结构上没有 readonly 修饰符,因此这个 Classgetset 的写法,是和 NameAgeGender 底层生成的代码是一样的,都是 getset 的自动属性。而且,它也要参与数据成员的校验和使用:

这个表格和 C# 9 的记录类是一样的。

记录结构类型的继承和派生机制

因为结构必须从 ValueType 派生,所以由于结构无法自定义派生规则的关系,我们无法对记录结构定义派生和继承关系,不过你可以要求它实现一些接口,这个和 C# 9 的记录类是一样的。

接口的话,和 C# 9 的记录类是一样的。主构造器的参数直接视为一个个的属性即可。只是要注意,readonly record structgetinit 的自动属性,但 record structgetset 的属性。如果关键字用得不一样,接口就会和这里给的属性本身不匹配,导致编译器错误,提示你没有实现完成员。

和 C# 9 的记录类一样的地方,除了刚才说的地方,还有一个点,就是实现接口。记录结构类型在写上主构造器后,因为它会自动生成 Equals 方法,所以这个记录结构类型也会自动帮你实现 IEquatable<T> 接口。所以你可以直接把这个类型拿去参与相等性比较,比如使用 Equals 方法,或者是 ==!= 运算符。

其它无关痛痒的记录类型语法

partial 修饰符修饰记录类型

如果我们要用 partial 修饰符来修饰记录类型,是怎么样用的呢?和记录类是一样的写法,只是原本的 partial record 的配方要改成 partial record struct 了。

比如这样。不过一定请注意,partial 必须放在 record struct 的前面。也就是说,record struct 这个时候是一个标识整体,我们无法把 partial 关键字插入到 recordstruct 关键字的中间,它们是不能拆开的。

record class 的语义

因为配合 C# 10 的 record struct 的定义规则,C# 10 推广了 record 的零碎语法。record 从语义上和 record class 这个定义组合是等价的,所以在 C# 10 里,编译器允许我们在 record 关键字后再加上 class 关键字表示一个记录类类型。这个写法和原本的 record 没有区别,它的出现用于强调和区分现有的 record struct 的写法。

record struct 的基本用法一样,record class 也是不可拆分的单位,因此你也只能使用比如 partial record class 这样的语法。

主构造器允许的参数修饰符

好吧,这一点和 C# 9 记录类是一样的,仍然只允许我们使用 inparams 修饰符修饰参数。

无参主构造器

记录结构的无参主构造器可否存在呢?可以。比如长这样:

是的,这一对小括号里没有写东西,所以它也可以不写出来:

这样要好看一点。

主构造器上使用特性

这一点也和 C# 9 记录类的是一样的。

没有 record interface

虽然我们知道,structclass 都可以使用 record 来简化语义模型构造 POCO 了,但 interface 是纯抽象的对象类别,所以没有 record interface 一说,毕竟……接口自身肯定不能实例化嘛。

没有 record ref struct

是的,虽然 ref struct 很好用,有时候我们也可以把一个很简单的 ref struct 给调整成一个 POCO,但它毕竟不是一个合规的结构,因为它只能放在栈内存里,很多特殊的规则就不适用了,比如值类型的装箱,比如泛型参数之类。ref struct 的条件甚至有点过于严苛了,因此 ref struct 这种组合是没有记录对应写法的,也就是说,你无法写成比如 record ref struct 这样的东西,因为 C# 10 的记录结构并不支持针对于 ref struct 的情况。


探索 C# 10 的记录结构类型的评论 (共 条)

分享到微博请遵守国家法律