record:记录(带名元组)
概览
init-only
属性为C#提供了某种不变性,下面使用记录来帮助你进一步理解这一概念。
record
类型的基本特性:
- 不可变性:
record
是不可变的。一旦创建了record
对象,就无法直接修改其属性的值。 - 属性自动实现:
record
具有属性自动实现的特性,你只需声明属性,编译器将自动生成get
和init
(用于初始化的set
) 方法。 - 结构化相等性比较:即两个对象的所有属性值都相同时被认为相等。编译器会自动生成适当的
Equals
和GetHashCode
方法。 - 模式匹配:
record
类型天生支持模式匹配,使得在模式匹配语法中更加简洁和直观,因为自动生成解构函数 deconstruct。 - 拷贝和修改: 使用
with
表达式,可以轻松创建record
对象的副本并修改其中的一些属性,因为记录不能变更,所以只能赋值。
记录的主要构造函数参数称为位置参数。编译器创建镜像主构造函数或位置参数的位置属性。编译器不会合成没有修饰符的类型的主构造函数参数的属性
C# 10 允许 record class
定义引用类型数据, record struct
定义值类型数据。
不可变的记录
以下两个示例演示不可变(init)引用型的记录
//方式一 public record Person(string FirstName, string LastName); //方式二 public record Person { public required string FirstName { get; init; } // required 修饰符表示它所应用的字段或属性必须由 对象初始值设定项 进行初始化 // public required string LastName { get; init; } // required 修饰符表示它所应用的字段或属性必须由 对象初始值设定项 进行初始化 // }; Person per = new("Nancy", "Davolio"); per.LastName = "tonny";//错误,因为 以上两者定义的记录是不可变的(init),只能初始值设定项和构造参数中输入值。
以下两个示例演示不可变(init)值类型的记录
//方式一 public readonly record struct Point(double X, double Y, double Z); //方式二 public record struct Point { public double X { get; init; } public double Y { get; init; } public double Z { get; init; } }
可变的记录
以下示例演示可变(set)引用类型的记录
public record Person { public required string FirstName { get; set; } public required string LastName { get; set; } };
以下示例演示可变(set)值类型的记录
//方式一 public record struct DataMeasurement(DateTime TakenAt, double Measurement); //方式二 public record struct Point { public double X { get; set; } public double Y { get; set; } public double Z { get; set; } }
2.1.2 位置记录 / Positional records
为了支持将record对象能解构成元组,我们给record添加解构函数Deconstruct。这种record就称为位置记录。下面代码定义的Person,记录的内容是通过构造函数的参数传入,并且通过位置解构函数提取出来。你完全可以在记录中定义你自己的构造和解构函数(注意不是析构函数)。如下所示:
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); }
针对上面如此复杂的代码,C#9.0提供了更精简的语法表达上面同样的内容。需要注意的是,这种记录类型是不可变的。这也就是为什么有record默认是不可变的说法由来。
public record Person(string FirstName, string LastName);
该方式声明了公开的、仅可初始化的自动属性、构造函数和解构函数。现在创建对象,你就可以写如下代码:
var person = new Person("Mads", "Torgersen"); // 位置构造函数 var (firstName, lastName) = person; // 位置解构函数
当然,如果你不喜欢产生的自动属性、构造函数和解构函数,你可以自定义同名成员代替。在调用构造函数和解构函数时,将会只使用你自定义的那个。在这种情况下,被自定义参数处于你用于初始化的作用域内,例如,你想让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);
2.2 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" };
如果只是进行拷贝,不需要修改属性,那么无须指定任何属性修改,如下所示:
Person clone = person with { };
with表达式使用初始化语法来说明新对象在哪里与原有对象不同。with表达式实际上是拷贝原来对象的整个状态值到新对象,然后根据对象初始化器来改变指定值。这意味着属性必须有init或者set访问器,才能用with表达式进行更改。
需要注意的是:
- with表达式左边操作数必须为record类型。
- record的引用类型的成员在拷贝的时候,只是将所指实例的引用进行了拷贝。
2.3 record的面向对象的特性——继承、多态等
记录(record)和类一样,在面向对象方面,支持继承,多态等所有特性。除过前面提到的record专有的特性,其他语法写法跟类也是一样。同其他类型一样,record的基类依然是object。
要注意的是:
-
记录只能从记录继承,不能从类继承,也不能被任何类继承。
-
record不能定义为static的,但是可以有static成员。
下面一个学生record,它继承自Person:
public record Person { public string? FirstName { get; init; } public string? LastName { get; init; } } public sealed record Student : Person { public int ID { get; init; } }
对于位置记录,只要保持record特有的写法即可:
public record Person(string FirstName, string LastName); public sealed record Student(string FirstName, string LastName, int Level) : Person(FirstName, LastName); public sealed record Teacher(string FirstName, string LastName, string Title) : Person(FirstName, LastName) { public override string ToString() { StringBuilder s = new(); base.PrintMembers(s); return $"{s.ToString()} is a Teacher"; } }
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 record实现原理
从本质上来讲,record仍然是一个类,但是关键字record赋予这个类额外的几个像值的行为。也就是,当你定义了record时候,编译器会自动生成以下方法,来实现基于值相等的特性(即只要两个record的所有属性都相等,且类型相同,那么这两个record就相等)、对象的拷贝和成员及其值的输出。
-
基于值相等性的比较方法,如Equals,==,!=,EqualityContract等。
-
重写GetHashCode()
-
拷贝和克隆成员
-
PrintMembers和ToString()方法
例如我先定义一个Person的记录类型:
public record Person(string FirstName, string LastName);
编译器生成的代码和下面的代码定义是等价的。但是要注意的是,跟编译器实际生成的代码相比,名字的命名是有所不同的。
public class Person : IEquatable<Person> { private readonly string _FirstName; private readonly string _LastName; protected virtual Type EqualityContract { get { return typeof(Person); } } public string FirstName { get { return _FirstName; } init { _FirstName = value; } } public string LastName { get { return _LastName; } init { _LastName = value; } } public Person(string FirstName, string LastName) { _FirstName = FirstName; _LastName = LastName; } public override string ToString() { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.Append("Person"); stringBuilder.Append(" { "); if (PrintMembers(stringBuilder)) { stringBuilder.Append(" "); } stringBuilder.Append("}"); return stringBuilder.ToString(); } protected virtual bool PrintMembers(StringBuilder builder) { builder.Append("FirstName"); builder.Append(" = "); builder.Append((object)FirstName); builder.Append(", "); builder.Append("LastName"); builder.Append(" = "); builder.Append((object)LastName); return true; } public static bool operator !=(Person r1, Person r2) { return !(r1 == r2); } public static bool operator ==(Person r1, Person r2) { return (object)r1 == r2 || ((object)r1 != null && r1.Equals(r2)); } public override int GetHashCode() { return (EqualityComparer<Type>.Default.GetHashCode(EqualityContract) * -1521134295 + EqualityComparer<string>.Default.GetHashCode(_FirstName)) * -1521134295 + EqualityComparer<string>.Default.GetHashCode(_LastName); } public override bool Equals(object obj) { return Equals(obj as Person); } public virtual bool Equals(Person other) { return (object)other != null && EqualityContract == other.EqualityContract && EqualityComparer<string>.Default.Equals(_FirstName, other._FirstName) && EqualityComparer<string>.Default.Equals(_LastName, other._LastName); } public virtual Person Clone() { return new Person(this); } protected Person(Person original) { _FirstName = original._FirstName; _LastName = original._LastName; } public void Deconstruct(out string FirstName, out string LastName) { FirstName = this.FirstName; LastName = this.LastName; } }
这些由编译器生成的一些成员,是允许编程人员自定义的,一旦编译器发现有自定义的某个成员,它就不会再生成这个成员。
由此可见,record实际上就是编译器特性,并且records由他们的内容来界定,不是他们的引用标识符。从这一点上讲,records更接近于结构,但是他们依然是引用类型。
2.4.1 基于值的相等
所有对象都从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并重载了==和 !=这两个操作符,这些都是为了基于值的行为在所有的不同的相等机制方面保持一致。
基于值的相等和可变性契合的不总是那么好。一个问题是改变值可能引起GetHashCode的结果随时变化,如果这个对象被存放在哈希表中,就会出问题。我们没有不允许使用可变的record,但是我们不鼓励那样做,除非你已经想到了后果。
如果你不喜欢默认Equals重写的字段与字段比较行为,你可以进行重写。你只需要认真理解基于值的相等时如何在records中工作原理,特别是涉及到继承的时候。
除了熟悉的Equals,==和!=操作符之外,record还多了一个新的EqualityContract只读属性,该属性返回类型是Type类型,返回值默认为该record的类型。该属性用来在判断两个具有继承关系不同类型的record相等时,该record所依据的类型。下面我们看一个有关EqualityContract的例子,定义一个学生record,他继承自Person:
public record Student(string FirstName, string LastName, int Level) : Person(FirstName, LastName);
这个时候,我们分别创建一个Person和Student实例,都用来描述同样的人:
Person p = new Person("Jerry", "Kang"); Person s = new Student("Jerry", "Kang", 1); WriteLine(p == s); // False
这两者比较的结果是False,这与我们实际需求不相符。那么我们可以重写EqualityContract来实现两种相等:
public record Student(string FirstName, string LastName, int Level) : Person(FirstName, LastName) { protected override Type EqualityContract { get => typeof(Person); } }
经过此改造之后,上面例子中的两个实例就会相等。EqualityContract的修饰符是依据下面情况确定的:
- 如果基类是object, 属性是virtual;
- 如果基类是另一个record类型,则该属性是override;
- 如果基类类型是sealed,则该属性也是sealed的。
2.4.2 拷贝克隆与with表达式
一个record在编译的时候,会自动生成一个带有保护访问级别的“拷贝构造函数”,用来将现有record对象的字段值拷贝到新对象对应字段中:
protected Person(Person original) { /* 拷贝所有字段 */ } // 编译器生成
with表达式就会引起拷贝构造函数被调用,然后应用对象初始化器来有限更改属性相应值。如果你不喜欢默认的产生的拷贝构造函数,你可以自定义该构造函数,编译器一旦发现有自定义的构造函数,就不会在自动生成,with表达式也会进行调用。
public record Person(string FirstName, string LastName) { protected Person(Person original) { this.FirstName = original.FirstName; this.LastName = original.LastName; } }
编译器默认地还会生成with表达式会使用的一个Clone方法用于创建新的record对象,这个方法是不能在record类型里面自定义的。
2.4.3 PrintMembers和ToString()方法
如果你用Console.WriteLine来输出record的实例,就会发现其输出与用class定义的类型的默认的ToString完全不同。其输出为各成员及其值组成的字符串:
Person {FirstName = Andy, LastName = Kang}
这是因为,基于值相等的类型,我们更加关注于具体的值的情况,因此在编译record类型时会自动生成重写了ToString的行为的代码。针对record类型,编译器也会自动生成一个保护级别的PrintMembers方法,该方法用于生成各成员及其值的字符串,即上面结果中的大括号内部的内容。ToString中,就调用了PrintMembers来生成其成员字符串部分,其他部分在ToString中补充。
我们也可以定义PrintMembers和重写ToString方法来实现自己想要的功能,如下面实现ToString输出为Json格式:
public record Person(string FirstName, string LastName) { protected virtual bool PrintMembers(StringBuilder builder) { builder.Append("\"FirstName\""); builder.Append(" : "); builder.Append($"\"{ FirstName}\""); builder.Append(", "); builder.Append("\"LastName\""); builder.Append(" : "); builder.Append($"\"{ LastName}\""); return true; } public override string ToString() { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.Append("{"); if (PrintMembers(stringBuilder)) { stringBuilder.Append(" "); } stringBuilder.Append("}"); return stringBuilder.ToString(); } }
record因为都是继承自Object,因此ToString都是采用override修饰符。而PrintMembers方法修饰符是依据下面情况决定的:
-
如果记录不是sealed而是从object继承的, 该方法是protected virtual;
-
如果记录基类是另一个record类型,则该方法是protected override;
-
如果记录类型是sealed,则该方法也是private的。
模式匹配:record
类型天生支持模式匹配,使得在模式匹配语法中更加简洁和直观。
应用场景
DTOs(数据传输对象): 记录类型是定义简单数据传输对象的理想选择。通常包含一组属性,用于在应用程序的不同层之间传递数据,而不需要在这些对象中添加复杂的业务逻辑。
配置对象:用于表示应用程序或系统的配置信息。由于配置通常是不可变的,并且具有一组静态的属性,因此记录类型是一种合适的选择。
消息传递:在消息传递和事件处理中,特别是在异步编程环境中。可以作为消息的不可变表示,方便传递和处理。
领域模型:在表示领域模型中的值对象。这些值对象通常是不可变的,仅用于包含数据而不包含行为。
3.1 Web Api
用于web api返回的数据,通常作为一种一次性的传输型数据,不需要是可变的,因此适合使用record。
3.2 并发和多线程计算
作为不可变数据类型record对于并行计算和多线程之间的数据共享非常适合,安全可靠。
3.3 数据日志
record本身的不可变性和ToString的数据内容的输出,不需要人工编写很多代码,就适合进行日志处理。
3.4 其他
其他涉及到有大量基于值类型比较和复制的场景,也是record的常用的使用场景。