
从C#9开始,可以使用record关键字来定义一个具有不可变属性的引用类型,编译器会为该类型生成大量的模板代码。C#10以后,record struct用来定义值类型,record class或record用来定义引用类型。
record(C# reference)这篇官方的文档中,介绍了record类型的诸多特性,文档中对record class和record struct分别做了详细的描述。本文以record class为例,通过反编译得到了编译器为record class类型生成的代码,希望能够从这些代码入手,分析record class类型的这些特性的背后逻辑。后面如果没有特别提出,则record即指record class。
获取反编译代码
这次我选择使用Reshaper提供在VS2022中提供的IL Viewer插件作为反编译工具,使用起来非常方便,生成项目后,打开IL Viewer的窗口就直接可以看到反编译的代码。窗口左上角还可以选择反编译的类型
- IL code
- Lower Level C#: 反编译成较低层级的C#代码,包含编译器生成的代码
- High Level C#: 反编译成较高层级的C#代码,基本上和我们开发时写的代码一样
因为我的目的是看看编译器为record类型生成了什么代码,所以我选择查看Lower Level C#
record类型的特性
属性和字段定义的位置语法
public record Person(string Name);中的Name参数,在record类型中就叫做位置参数,编译器会根据位置参数生成构造函数、解构函数和自动属性,生成的属性也叫做位置属性。编译器生成的构造函数和自动属性大致如下
[CompilerGenerated] public Person(string Name) { this.Name = Name; base(); } [CompilerGenerated] public void Deconstruct(out string Name) { Name = this.Name; } [CompilerGenerated] public string Name{ get; init; }
生成的自动属性默认是公共的init-only属性(只可以在构造对象时赋值),但也可以自行实现位置属性。除了位置属性以外,也可以定义其他属性。例如,Person类可以这样定义:
public record Person(string Name) { private string _name = Name; internal string Name { get => _name; set { _name = string.IsNullOrEmpty(value) ? "invalid" : value; } } public int Age { get; set; } }
在这个例子中,已经定义了Name属性,因此编译器不会再生成Name属性,但Name属性仍然是一个位置属性,编译器仍然会根据它来生成构造函数和解构函数,以及后面讲到的值相等性、浅克隆等特性,也仍然会作用于Name属性。而相比之下,Age属性是额外定义的普通属性,record类型的诸多特性都不会对它起作用,编译器也不会对它做任何额外的操作。
另外需要注意的是,定义Name属性的后台字段时,使用了Name进行初始化,这里的Name是指的位置参数(如果没有初始化这一步,位置参数就不会被使用)。编译器生成的构造函数中,对_name字段进行了赋值:
[CompilerGenerated] public Person(string Name) { this._name = Name; base(); }
不可变性
record类型的不可变性是指其位置属性的不可变性。从前面的反编译结果已经可以看到,位置属性默认情况下是init-only属性,这意味着,只有在对象初始化时才能对其赋值。
同样,在前面的例子中也可以看到,可以手写位置属性的代码来替代位置属性的默认代码,从而使得位置属性不再是init-only属性。所以,record类型的不可变性不是强制的,而是一种默认情形。
值相等性
C#的相等性可以分为值相等性和引用相等性,引用相等性是指两个变量引用的是同一个对象,值相等性是指两个变量引用的对象所包含的基元的值全都相等。
编译器为record类型生成了1个属性和5个方法用来实现record的值相等性,分别是EqualityContract属性、==和!=运算符、重写的GetHashCode、Equals方法、以及以当前类型为参数的Equals方法,反编译的代码如下:
[CompilerGenerated] protected virtual Type EqualityContract { [CompilerGenerated] get { return typeof (Person); } } [NullableContext(2)] [CompilerGenerated] [SpecialName] public static bool op_Inequality(Person left, Person right) { return !Person.op_Equality(left, right); } [NullableContext(2)] [CompilerGenerated] [SpecialName] public static bool op_Equality(Person left, Person right) { if ((object) left == (object) right) return true; return (object) left != null && left.Equals(right); } [CompilerGenerated] public override int GetHashCode() { return (EqualityComparer<Type>.Default.GetHashCode(this.EqualityContract) * -1521134295 + EqualityComparer<string>.Default.GetHashCode(this._name)) * -1521134295 + EqualityComparer<int>.Default.GetHashCode(this.<Age>k__BackingField); } [NullableContext(2)] [CompilerGenerated] public override bool Equals(object obj) { return this.Equals(obj as Person); } [NullableContext(2)] [CompilerGenerated] public virtual bool Equals(Person other) { if ((object) this == (object) other) return true; return (object) other != null && Type.op_Equality(this.EqualityContract, other.EqualityContract) && EqualityComparer<string>.Default.Equals(this._name, other._name) && EqualityComparer<int>.Default.Equals(this.<Age>k__BackingField, other.<Age>k__BackingField); }
方法上的[NullableContext(2)]表明参数是可空的,例如[NullableContext(2)]public override bool Equals(object obj)等价于public override bool Equals(object? obj)。
其中,EqualityContract属性、GetHashCode、Equals(Person? other)可以显式申明。跟位置属性一样,显式申明后,编译器不会再生成。其他方法则不可以显式申明,否则会IDE或者编译器会报错。
另外,如果显式申明了Equals(Person? other),则应该同时申明GetHashCode(),否则会有一个编译警告。产生警告的原因很容易理解,因为生成的Equals(object? obj)方法里面,实际上直接返回了Equals(Person? other)的结果,这就像我们申明了一个普通的类型,重写了Equals(object? obj)而没有重写GetHashCode()方法,同样也会得到一个警告。
record类型的值相等性主要是由Equals(Person? other)方法实现的,在编译器生成的这个方法中,先比较了是否引用同一个对象,然后比较了EqualityContract,再依次比较了每一个字段的值,这里的字段包括类型定义的字段和自动属性的后台字段。
EqualityContract返回的是typeof(Person),也就是说,在进行值相等性比较时首先会比较两个对象是不是同一个类型,只有是同一个类型才能相等。但是,可以显式定义EqualityContract来改变这个行为。例如,另写一个Student记录类派生自Person,但不定义任何属性和字段,默认情况下,Student的EqualityContract会返回typeof(Student),那么Student类的实例永远不会等于Person类的实例。而如果显式定义了Student的EqualityContract属性,并让它返回typeof(Person),那么当字段的值都相等时,两个实例就是相等的。例如下面的定义:
var p = new Person("yangtb"); var s = new Student("yangtb"); Console.WriteLine(p == s); //输出为False public record Person(string Name); public record Student(string Name) : Person(Name); -------------------------------------------------------------- var p = new Person("yangtb"); var s = new Student("yangtb"); Console.WriteLine(p == s); //输出为True public record Person(string Name); public record Student(string Name) : Person(Name) { protected override Type EqualityContract => typeof(Person); };
非破坏性变化
record的非破坏性变化简单来说其实就是指定一个record类型的实例,创建一个它的副本,然后修改某些属性的值。这个功能实际上并不特殊,对于普通类型,如果我们定义了创建副本对象的方法,也可以这样做。特殊的点在于,record类型的位置属性默认情况下是init-only属性,也就是只能在初始化时赋值。那么如果我们要修改副本对象的属性值的话,只能通过构造函数来创建副本对象,然后利用初始化器来赋值。例如下面的普通类型的例子。
var p = new Person("yangtb"); var p1 = p1.Clone(); p2.Name = "yangtb3"; //无法赋值,因为Name是init-only属性 var p2 = new Person(p) { Name = "yangtb2" }; //可以赋值,因为是在初始化器里面对Name赋值的 public class Person(string Name) { public string Name { get; init; } = Name; public Person(Person p) : this(p.Name) { } public Person Clone() { return new Person(this.Name); } };
record类型实际上也生成了一个protect级别的构造函数,用来实现拷贝,在这个构造函数中,会依次复制每一个字段的值。另外生成了一个公共的<Clone>$()函数,返回用这种方式拷贝的对象。反编译的代码如下所示
[CompilerGenerated] public virtual Person <Clone>$() { return new Person(this); } [CompilerGenerated] protected Person(Person original) { base..ctor(); this.<Name>k__BackingField = original.<Name>k__BackingField; }
然后C#为record类型引入了with表达式,with表达式通过调用<Clone>$()函数和初始化构造器来实现类似var p2 = new Person(p) { Name = “yangtb2” }的效果。with表达式的使用方法如下
var p = new Person("yangtb"); var p1 = p with { Name = "yangtb2" }; //输出为p.Name=yangtb; p1.Name=yangtb2 Console.WriteLine($"p.Name={p.Name}; p1.Name={p1.Name}");
上述代码反编译的结果为
Person p = new Person("yangtb"); Person person = p.<Clone>$(); person.Name = "yangtb2"; Person p1 = person; Console.WriteLine(string.Concat("p.Name=", p.Name, "; p1.Name=", p1.Name));
反编译的代码里面对Name属性进行了赋值。如果我们在C#代码里这样写,一定会报错,我们这样写时,调用的是set访问器,它的IL代码是
callvirt instance void NamedPoint::set_Name(string)
而with表达式这里调用的实际上是init访问器,它的IL代码是
callvirt instance void modreq([System.Runtime]System.Runtime.CompilerServices.IsExternalInit) NamedPoint::set_Name(string)
对于init-only属性来说,顾名思义,它只存在init访问器,而不存在set访问器。
总结来说的话,with表达式给record类型提供了一个在拷贝实例的时候使用初始化器的语法糖。
用于显示的内置格式设置
这个就没啥特殊的了,直接上反编译的代码吧。这里PrintMembers方法默认是打印的所有公共的属性,包括record的位置属性和普通的属性。,例如,下面的代码中,ToString方法会打印Name和Id属性。
原始代码:
public record Person(string Name) { private readonly string _id = Guid.NewGuid().ToString(); public string Id => _id; protected int InternalId { get; set; } };
反编译代码:
[CompilerGenerated] public override string ToString() { StringBuilder builder = new StringBuilder(); builder.Append("Person"); builder.Append(" { "); if (this.PrintMembers(builder)) builder.Append(' '); builder.Append('}'); return builder.ToString(); } [CompilerGenerated] protected virtual bool PrintMembers(StringBuilder builder) { RuntimeHelpers.EnsureSufficientExecutionStack(); builder.Append("Name = "); builder.Append((object) this.Name); builder.Append(", Id = "); builder.Append((object) this.Id); return true; }
这里的ToString和PrintMembers方法都可以通过显式定义来替换默认生成的代码。
record类型的继承
record类型可以继承record类型,但record不能继承普通类型,普通类型也不能继承record。
本节中的派生类和基类均指record类型。
派生类中的位置参数
派生类的位置参数中,与基类同名的位置参数,都不会生成覆盖基类的位置属性。不管派生类有没有将同名的位置参数传递给基类。例如,下面两段代码的Student类中,都只会生成Grade这个位置属性。
//将位置参数Name传递给基类,不会生成Name属性 public record Student(string Name, int Grade) : Person(Name); //没有将位置参数Name传递给基类,但也不会生成Name属性,IDE会提示Name参数未被使用 public record Student(string Name, int Grade) : Person("Name");
继承层次结构中的相等性
这一节在官方文档的描述是“要使两个记录变量相等,运行时类型必须相等”,然后用了下面一段代码来说明
public abstract record Person(string FirstName, string LastName); public record Teacher(string FirstName, string LastName, int Grade) : Person(FirstName, LastName); public record Student(string FirstName, string LastName, int Grade) : Person(FirstName, LastName); public static void Main() { Person teacher = new Teacher("Nancy", "Davolio", 3); Person student = new Student("Nancy", "Davolio", 3); Console.WriteLine(teacher == student); // output: False Student student2 = new Student("Nancy", "Davolio", 3); Console.WriteLine(student2 == student); // output: True }
其实在值相等性一节中反编译的代码中就出现了EqualityContract这个属性。EqualityContract返回一个类型,两个record类型的对象相等的前提是EqualityContract返回的类型是同一个类型,其次才是每个字段的值都相等。
所以上面那个例子中,跟Teacher和Student是否都继承自Person其实没啥关系,只要它们的EqualityContract返回的不是相同的类型,它们的实例就不可能相等,除非在显式定义它们的EqualityContract,使其返回相同的类型。
不过官方文档中这一节的目的可能是想强调两个派生类继承自同一个基类的情况下,派生类看起来可能会相等,但其实不可能相等吧。
派生类中的with表达式
在非破坏性变化一节中介绍了with表达式,它为record类型提供了一个在拷贝对象时调用初始化器的机会。
在派生类和基类之间使用with表达式时存在一个限制:拷贝时会复制运行时类型的所有属性,但初始化器中只能设置编译时类型中的属性。
官方文档给了下面这个例子:
Point p1 = new NamedPoint("A", 1, 2) { Zbase = 3, Zderived = 4 }; Point p2 = p1 with { X = 5, Y = 6, Zbase = 7 }; // Can't set Name or Zderived Console.WriteLine(p2 is NamedPoint); // output: True Console.WriteLine(p2); // output: NamedPoint { X = 5, Y = 6, Zbase = 7, Name = A, Zderived = 4 } Point p3 = (NamedPoint)p1 with { Name = "B", X = 5, Y = 6, Zbase = 7, Zderived = 8 }; Console.WriteLine(p3); // output: NamedPoint { X = 5, Y = 6, Zbase = 7, Name = B, Zderived = 8 }
从这个例子中可以看到,p1的运行时类型是NamedPoint,第一次使用with表达式时,p1的编译时类型是Point,所以在初始化器中只能对Point类型的属性赋值;第二次使用with表达式时,在p1上使用了强制类型转换,将编译时类型转为了NamedPoint,这时在初始化器中可以对NamedPoint类型的属性进行赋值。
下面是反编译的代码:
NamedPoint namedPoint1 = new NamedPoint("A", 1, 2); namedPoint1.Zbase = 3; namedPoint1.Zderived = 4; Point p1 = (Point) namedPoint1; Point point = p1.<Clone>$(); point.X = 5; point.Y = 6; point.Zbase = 7; Point p2 = point; Console.WriteLine(p2 is NamedPoint); Console.WriteLine((object) p2); NamedPoint namedPoint2 = ((NamedPoint) p1).<Clone>$(); namedPoint2.Name = "B"; namedPoint2.X = 5; namedPoint2.Y = 6; namedPoint2.Zbase = 7; namedPoint2.Zderived = 8; Console.WriteLine((object) namedPoint2);
从反编译的代码中很容易看出来,第一次调用的是Point类型的<Clone>$方法,得到的是一个Point的类型的实例,所以只能调用Point类型的X、Y这两个init访问器;第二次调用的是NamedPoint类型的<Clone>$方法,可以调用NamedPoint的X、Y、Name这三个init访问器。
这里需要注意的是p1.<Clone>$方法虽然调用了Point的接口,但实际上调用的是NamedPoint的<Clone>$方法,原因是该方法上有PreserveBaseOverridesAttribute进行修饰。
派生记录中的PrintMembers格式设置
这个没啥好说的,就是在派生类的PrintMembers函数中先调用了基类的PrintMembers函数。
[CompilerGenerated] protected override bool PrintMembers(StringBuilder builder) { if (base.PrintMembers(builder)) builder.Append(", "); builder.Append("Name = "); builder.Append((object) this.Name); builder.Append(", Zderived = "); builder.Append(this.Zderived.ToString()); return true; }
派生记录中的解构函数行为
解构函数实际上没有任何继承行为,不管是记录的基类还是派生记录,都只解构自己的位置属性。
//Point [CompilerGenerated] public void Deconstruct(out int X, out int Y) { X = this.X; Y = this.Y; } //NamedPoint [CompilerGenerated] public void Deconstruct(out string Name, out int X, out int Y) { Name = this.Name; X = this.X; Y = this.Y; }
泛型约束
record类型在泛型参数的约束中表现有2点:
- record class符合class的泛型约束;record struct符合struct的泛型约束
- 目前没有泛型约束能限制泛型是record类型
从反编译的代码来看,record class也就是一个实现了IEquatable接口的普通类
[NullableContext(1)] [Nullable(0)] public class Point : /*[Nullable(0)]*/ IEquatable<Point> { }
总结
上面就是record类型的所有特性了,原本的内容大家可以看官方的文档,这里只是从反编译代码的角度做一个解读,可能有不准确的地方,请大家指正。