1背景与动机
传统面向对象编程的核心思想是一个对象有着唯一标识,表现为对象引用,封装着随时可变的属性状态,如果你改变了一个属性的状态,这个对象还是原来那个对象,就是对象引用没有因为状态的改变而改变,也就是说该对象可以有很多种状态。C#从最初开始也是一直这样设计和工作的。但是一些时候,你可能非常需要一种恰好相反的方式,例如我需要一个对象只有一个状态,那么原来那种默认方式往往会成为阻力,使得事情变得费时费力。当一个类型的对象在创建时被指定状态后,就不会再变化的对象,我们称之为不可变类型。这种类型是线程安全的,不需要进行线程同步,非常适合并行计算的数据共享。它减少了更新对象会引起各种bug的风险,更为安全。System.DateTime和string就是不可变类型非常经典的代表。原来,我们要用类来创建一个不可变类型,你首先要定义只读字段和属性,并且还要重写涉及相等判断的方法等。在C#9.0中,引入了record,专门用来以最简的方式创建不可变类型的新方式。如果你需要一个行为像值类型的引用类型,你可以使用record;如果你需要整个对象都是不可变的,且行为像一个值,那么你也可考虑将其声明为一个record类型。那么什么是record类型?2Record介绍record类型是一种用record关键字声明的新的引用类型,与类不同的是,它是基于值相等而不是唯一的标识符——对象引用。他有着引用类型的支持大对象、继承、多态等特性,也有着结构的基于值相等的特性。可以说有着class和struct两者的优势,在一些情况下可以用以替代class和struct。提到不可变的类型,我们会想到readonlystruct,那么为什么要选择添加一个新的类型,而不是用readonlystruct呢?这是因为记录有着如下优点:在构造不可变的数据结构时,它的语法简单易用。
record为引用类型,不用像值类型在传递时需要内存分配,并进行整体拷贝。
构造函数和结构函数为一体的、简化的位置记录
有力的相等性支持,重写了Equals(object),IEquatableT,和GetHashCode()这些基本方法。
2.1record类型的定义与使用2.1.1常规方式record类型可以定义为可变的,也可以是不可变的。现在,我们用record定义一个只有只读属性的Person类型如下。这种只有只读属性的类型,因为其在创建好之后,属性就不能再被修改,我们通常把这种类型叫做不可变类型。publicrecordPerson{publicstringLastName{get;}publicstringFirstName{get;}publicPerson(stringfirst,stringlast)=(FirstName,LastName)=(first,last);}上面这种声明,在使用时,只能用带参的构造函数进行初始化。要创建一个record对象跟类没有什么区别:
Personperson=new("Andy","Kang");如果要支持用对象初始化器进行初始化,则在属性中使用init关键字。这种形式,如果不需要用带参的构造函数进行初始化,可以不定义带参的构造函数,上面的Person可以改为下面形式。
publicrecordPerson{publicstring?FirstName{get;init;}publicstring?LastName{get;init;}}
现在,创建Person对象时,用初始化器进行初始化如下:
Personperson=new(){FirstName="Andy",LastName="Kang"};
如果需要是可变类型的record,我们定义如下。这种因为有set访问器,所有它支持用对象初始化器进行初始化,如果你想用构造函数进行初始化,你可以添加自己的构造函数。
publicrecordPerson{publicstring?FirstName{get;set;}publicstring?LastName{get;set;}}
2.1.2位置记录/Positionalrecords
为了支持将record对象能解构成元组,我们给record添加解构函数Deconstruct。这种record就称为位置记录。下面代码定义的Person,记录的内容是通过构造函数的参数传入,并且通过位置解构函数提取出来。你完全可以在记录中定义你自己的构造和解构函数(注意不是析构函数)。如下所示:。
publicrecordPerson{publicstringFirstName{get;init;}publicstringLastName{get;init;}publicPerson(stringfirstName,stringlastName)=(FirstName,LastName)=(firstName,lastName);publicvoidDeconstruct(outstringfirstName,outstringlastName)=(firstName,lastName)=(FirstName,LastName);}
针对上面如此复杂的代码,C#9.0提供了更精简的语法表达上面同样的内容。需要注意的是,这种记录类型是不可变的。这也就是为什么有record默认是不可变的说法由来。
publicrecordPerson(stringFirstName,stringLastName);该方式声明了公开的、仅可初始化的自动属性、构造函数和解构函数。现在创建对象,你就可以写如下代码:
varperson=newPerson("Mads","Torgersen");//位置构造函数var(firstName,lastName)=person;//位置解构函数当然,如果你不喜欢产生的自动属性、构造函数和解构函数,你可以自定义同名成员代替,产生的构造函数和解构函数将会只使用你自定义的那个。在这种情况下,被自定义参数处于你用于初始化的作用域内,例如,你想让FirstName是个保护属性:
publicrecordPerson(stringFirstName,stringLastName){protectedstringFirstName{get;init;}=FirstName;}
如上例子所示,对位置记录进行扩展,你可以在大括号里添加你想要的任何成员。
一个位置记录可以像下面这样调用父类构造函数。
publicrecordStudent(stringFirstName,stringLastName,intID):Person(FirstName,LastName);
2.1.3定义的总结
record默认情况下是被设计用来进行描述不可变类型的,因此位置记录这种短小简明的声明方式是推荐方式。
2.2with表达式
当使用不可变的数据时,一个常见的模式是从现存的值创建新值来呈现一个新状态。例如,如果Person打算改变他的姓氏(lastname),我们就需要通过拷贝原来数据,并赋予一个不同的lastname值来呈现一个新Person。这种技术被称为非破坏性改变。作为描绘随时间变化的person,record呈现了一个特定时间的person的状态。为了帮助进行这种类型的编程,针对records就提出了with表达式,用于拷贝原有对象,并对特定属性进行修改:varperson=newPerson{FirstName="Mads",LastName="Nielsen"};varotherPerson=personwith{LastName="Torgersen"};
如果只是进行拷贝,不需要修改属性,那么无须指定任何属性修改,如下所示:
Personclone=personwith{};
with表达式使用初始化语法来说明新对象在哪里与原有对象不同。with表达式实际上是拷贝原来对象的整个状态值到新对象,然后根据对象初始化器来改变指定值。这意味着属性必须有init或者set访问器,才能用with表达式进行更改。
需要注意的是:
with表达式左边操作数必须为record类型。record的引用类型的成员在拷贝的时候,只是将所指实例的引用进行了拷贝。2.3record的面向对象的特性——继承、多态等
记录(record)和类一样,在面向对象方面,支持继承,多态等所有特性。除过前面提到的record专有的特性,其他语法写法跟类也是一样。同其他类型一样,record的基类依然是object。要注意的是:记录只能从记录继承,不能从类继承,也不能被任何类继承。
record不能定义为static的,但是可以有static成员。
下面一个学生record,它继承自Person:publicrecordPerson{publicstring?FirstName{get;init;}publicstring?LastName{get;init;}}publicsealedrecordStudent:Person{publicintID{get;init;}}
对于位置记录,只要保持record特有的写法即可:
publicrecordPerson(stringFirstName,stringLastName);publicsealedrecordStudent(stringFirstName,stringLastName,intLevel):Person(FirstName,LastName);publicsealedrecordTeacher(stringFirstName,stringLastName,stringTitle):Person(FirstName,LastName){publicoverridestringToString(){StringBuilders=new();base.PrintMembers(s);return$"{s.ToString()}isaTeacher";}}
with表达式和值相等性与记录的继承结合的很好,因为他们不仅是静态的已知类型,而且考虑到了整个运行时对象。比如,我创建一个Student对象,将其存在Person变量里。
Personstudent=newStudent{FirstName="Mads",LastName="Nielsen",ID=};with表达式仍然拷贝整个对象并保持着运行时的类型:
varotherStudent=studentwith{LastName="Torgersen"};WriteLine(otherStudentisStudent);//true
同样地,值相等性确保两个对象有着同样的运行时类型,然后比较他们的所有状态:
PersonsimilarStudent=newStudent{FirstName="Mads",LastName="Nielsen",ID=};WriteLine(student!=similarStudent);//true,由于ID值不同2.4record实现原理从本质上来讲,record仍然是一个类,但是关键字record赋予这个类额外的几个像值的行为。也就是,当你定义了record时候,编译器会自动生成以下方法,来实现基于值相等的特性(即只要两个record的所有属性都相等,且类型相同,那么这两个record就相等)、对象的拷贝和成员及其值的输出。
基于值相等性的比较方法,如Equals,==,!=,EqualityContract等。
重写GetHashCode()
拷贝和克隆成员
PrintMembers和ToString()方法
例如我先定义一个Person的记录类型:publicrecordPerson(stringFirstName,stringLastName);编译器生成的代码和下面的代码定义是等价的。但是要注意的是,跟编译器实际生成的代码相比,名字的命名是有所不同的。
publicclassPerson:IEquatablePerson{privatereadonlystring_FirstName;privatereadonlystring_LastName;protectedvirtualTypeEqualityContract{get{returntypeof(Person);}}publicstringFirstName{get{return_FirstName;}init{_FirstName=value;}}publicstringLastName{get{return_LastName;}init{_LastName=value;}}publicPerson(stringFirstName,stringLastName){_FirstName=FirstName;_LastName=LastName;}publicoverridestringToString(){StringBuilderstringBuilder=newStringBuilder();stringBuilder.Append("Person");stringBuilder.Append("{");if(PrintMembers(stringBuilder)){stringBuilder.Append("");}stringBuilder.Append("}");returnstringBuilder.ToString();}protectedvirtualboolPrintMembers(StringBuilderbuilder){builder.Append("FirstName");builder.Append("=");builder.Append((object)FirstName);builder.Append(",");builder.Append("LastName");builder.Append("=");builder.Append((object)LastName);returntrue;}publicstaticbooloperator!=(Personr1,Personr2){return!(r1==r2);}publicstaticbooloperator==(Personr1,Personr2){return(object)r1==r2
((object)r1!=nullr1.Equals(r2));}publicoverrideintGetHashCode(){return(EqualityComparerType.Default.GetHashCode(EqualityContract)*-+EqualityComparerstring.Default.GetHashCode(_FirstName))*-+EqualityComparerstring.Default.GetHashCode(_LastName);}publicoverrideboolEquals(objectobj){returnEquals(objasPerson);}publicvirtualboolEquals(Personother){return(object)other!=nullEqualityContract==other.EqualityContractEqualityComparerstring.Default.Equals(_FirstName,other._FirstName)EqualityComparerstring.Default.Equals(_LastName,other._LastName);}publicvirtualPersonClone(){returnnewPerson(this);}protectedPerson(Personoriginal){_FirstName=original._FirstName;_LastName=original._LastName;}publicvoidDeconstruct(outstringFirstName,outstringLastName){FirstName=this.FirstName;LastName=this.LastName;}}这些由编译器生成的一些成员,是允许编程人员自定义的,一旦编译器发现有自定义的某个成员,它就不会再生成这个成员。由此可见,record实际上就是编译器特性,并且records由他们的内容来界定,不是他们的引用标识符。从这一点上讲,records更接近于结构,但是他们依然是引用类型。2.4.1基于值的相等
所有对象都从object类型继承了Equals(object),这是静态方法Object.Equals(object,object)用来比较两个非空参数的基础。结构重写了这个方法,通过递归调用每个结构字段的Equals方法,从而有了“基于值的相等”。Recrods也是这样。这意味着只要他们的值保持一致,两个record对象可以不是同一个对象实例就会相等。例如我们将修改的Lastname又修改回去了:
varoriginalPerson=otherPersonwith{LastName="Nielsen"};
现在我们会得到ReferenceEquals(person,originalPerson)=false(他们不是同一对象),但是Equals(person,originalPerson)=true(他们有同样的值).。与基于值的Equals一起的,还伴有基于值的GetHashCode()的重写。另外,records实现了IEquatableT并重载了==和!=这两个操作符,这些都是为了基于值的行为在所有的不同的相等机制方面保持一致。
基于值的相等和可变性契合的不总是那么好。一个问题是改变值可能引起GetHashCode的结果随时变化,如果这个对象被存放在哈希表中,就会出问题。我们没有不允许使用可变的record,但是我们不鼓励那样做,除非你已经想到了后果。
如果你不喜欢默认Equals重写的字段与字段比较行为,你可以进行重写。你只需要认真理解基于值的相等时如何在records中工作原理,特别是涉及到继承的时候。
除了熟悉的Equals,==和!=操作符之外,record还多了一个新的EqualityContract只读属性,该属性返回类型是Type类型,返回值默认为该record的类型。该属性用来在判断两个具有继承关系不同类型的record相等时,该record所依据的类型。下面我们看一个有关EqualityContract的例子,定义一个学生record,他继承自Person:
publicrecordStudent(stringFirstName,stringLastName,intLevel):Person(FirstName,LastName);
这个时候,我们分别创建一个Person和Student实例,都用来描述同样的人:
Personp=newPerson("Jerry","Kang");Persons=newStudent("Jerry","Kang",1);WriteLine(p==s);//False
这两者比较的结果是False,这与我们实际需求不相符。那么我们可以重写EqualityContract来实现两种相等:
publicrecordStudent(stringFirstName,stringLastName,intLevel):Person(FirstName,LastName){protectedoverrideTypeEqualityContract{get=typeof(Person);}}
经过此改造之后,上面例子中的两个实例就会相等。EqualityContract的修饰符是依据下面情况确定的:
如果基类是object,属性是virtual;如果基类是另一个record类型,则该属性是override;如果基类类型是sealed,则该属性也是sealed的。2.4.2拷贝克隆与with表达式一个record在编译的时候,会自动生成一个带有保护访问级别的“拷贝构造函数”,用来将现有record对象的字段值拷贝到新对象对应字段中:
protectedPerson(Personoriginal){/*拷贝所有字段*/}//编译器生成with表达式就会引起拷贝构造函数被调用,然后应用对象初始化器来有限更改属性相应值。如果你不喜欢默认的产生的拷贝构造函数,你可以自定义该构造函数,编译器一旦发现有自定义的构造函数,就不会在自动生成,with表达式也会进行调用。
publicrecordPerson(stringFirstName,stringLastName){protectedPerson(Personoriginal){this.FirstName=original.FirstName;this.LastName=original.LastName;}}编译器默认地还会生成with表达式会使用的一个Clone方法用于创建新的record对象,这个方法是不能在record类型里面自定义的。2.4.3PrintMembers和ToString()方法如果你用Console.WriteLine来输出record的实例,就会发现其输出与用class定义的类型的默认的ToString完全不同。其输出为各成员及其值组成的字符串:Person{FirstName=Andy,LastName=Kang}这是因为,基于值相等的类型,我们更加