从C#9.0开始,我们有了一个有趣的语法糖:记录(record)
为什么提供记录?
开发过程中,我们往往会创建一些简单的实体,它们仅仅拥有一些简单的属性,可能还有几个简单的方法,比如DTO等等,但是这样的简单实体往往又很有用,我们可能会遇到一些情况:
比如想要克隆一个新的实体而不是简单的引用传递
比如想要简单的比较属性值是否都一致,
比如在输出,我们希望得到内部数据结构而不是简单的甩给我们一个类型名称
其实,这说的有些类似结构体的一些特性,那为什么不直接采用结构体来实现呢?这是因为解构体有它的一些不足:
1、结构体不支持继承 2、结构体是值传递过程,因此,这意味着大量的结构体拥有者相同的数据,但是占用这不同内存 3、结构体内部相等判断使用ValueType.Equals方法,它是使用反射来实现,因此性能不快
而引用类型记录,正好弥补了这些缺陷。
在C#9.0中,我们使用record关键字声明一个记录类型,它只能是引用类型:
public record Animal;
从C#10开始,我们不仅有引用类型记录,还有结构体记录:
//使用record class声明为引用类型记录,class关键字是可选的,当缺省时等价于C#9.0中的record用法 public record Animal; //等价于 public record class Animal; //使用record struct声明为结构体类型记录 public record struct Animal; //也可使用readonly record struct声明为只读结构体类型记录 public readonly record struct Animal;
至于它们是什么,区别上和普通class、struct有什么不一样,我们慢慢道来
引用类型记录
引用类型记录不是一种新的类型,它是class用法的一个新用法,新的语法糖,也就是说record class是引用类型(这个在C#9.0中没有record class的写法,直接使用record)。
先看看引用类型记录是什么样子的,首先是无构造参数的记录:
//无构造参数,无其它方法属性等 public record Animal; //实例化 var animal = new Animal();
在编译时,会生成对应的class,大致等价于下面的例子:
public class Animal : IEquatable<Animal> { public Animal() { } protected Animal(Animal original) { } protected virtual Type EqualityContract => typeof(Animal); public virtual Animal <Clone>$() => new Animal(this); public virtual bool Equals(Animal? other) => (other != null) && (this.EqualityContract == other.EqualityContract); public override bool Equals(object obj) => this.Equals(obj as Animal); public override int GetHashCode() => EqualityComparer<Type>.Default.GetHashCode(this.EqualityContract); protected virtual bool PrintMembers(StringBuilder builder) => false; public override string ToString() { StringBuilder builder = new StringBuilder(); builder.Append("Animal"); builder.Append(" { "); if (this.PrintMembers(builder)) { builder.Append(" "); } builder.Append("}"); return builder.ToString(); } public static bool operator ==(Animal r1, Animal r2) => (r1 == r2) || ((r1 != null) && r1.Equals(r2)); public static bool operator !=(Animal r1, Animal r2) => !(r1 == r2); }
可以看到,处理几个相比较的方法,那么这个记录的作用几乎等价于object了!这里有一个<Clone>$(),方法,这是编译器生成的,作用后面再解释。
再看看有构造参数的记录:
//有构造参数,无其它方法属性等 public record Person(string Name, int Age); //实例化 var person = new Person("zhangsan", 1);
注:上面的定义可能会报错:
据说这是VS2019的一个小BUG,因为记录会生成 init setter,解决办法是添加一个命名空间是System.Runtime.CompilerServices,名称是IsExternalInit类就行了:
namespace System.Runtime.CompilerServices { class IsExternalInit { } }
有构造参数的记录在编译时,会生成对应的class,大致等价于下面的例子:
public class Person : IEquatable<Person> { public Person(string Name, int Age) { this.Name = Name; this.Age = Age; } protected Person(Person original) { this.Name = original.Name; this.Age = original.Age; } protected virtual Type EqualityContract => typeof(Person); public string Name { get; init; } public int Age { get; init; } public virtual Person <Clone>$() => new Person(this); public void Deconstruct(out string Name, out int Age) => (Name, Age) = (this.Name, this.Age); public virtual bool Equals(Person? other) => (other != null) && (this.EqualityContract == other.EqualityContract) && EqualityComparer<string>.Default.Equals(this.Name, other.Name) && EqualityComparer<int>.Default.Equals(this.Age, other.Age); public override bool Equals(object obj) => this.Equals(obj as Person); public override int GetHashCode() => (((EqualityComparer<Type>.Default.GetHashCode(this.EqualityContract) * -1521134295) + EqualityComparer<string>.Default.GetHashCode(this.Name)) * -1521134295) + EqualityComparer<int>.Default.GetHashCode(this.Age); protected virtual bool PrintMembers(StringBuilder builder) { builder.Append("Name"); builder.Append(" = "); builder.Append(this.Name); builder.Append(", "); builder.Append("Age"); builder.Append(" = "); builder.Append(this.Age.ToString()); return true; } public override string ToString() { StringBuilder builder = new StringBuilder(); builder.Append("Person"); builder.Append(" { "); if (this.PrintMembers(builder)) { builder.Append(" "); } builder.Append("}"); return builder.ToString(); } public static bool operator ==(Person r1, Person r2) => (r1 == r2) || ((r1 != null) && r1.Equals(r2)); public static bool operator !=(Person r1, Person r2) => !(r1 == r2); }
可以看到,相比无构造参数的记录,有构造参数的记录将构造参数生成了属性(setter是init),而且Equals、GetHashCode、ToString等方法重载都有这几个属性参与。
除此之外,还生成了一个Deconstruct方法,因此,有构造参数的记录就具有解构能力。另外,这里也同样生成了一个<Clone>$方法。
接下来看看记录的这些属性和方法:
1、构造函数和属性
记录会根据给定的参数生成一个构造函数,同时为每一个构造参数生成一个属性(为了规范,参数应采用匈牙利命名法,首字符大写),比如上面的Animal记录,等价于:
public class Animal : IEquatable<Animal> { public Animal(string Name, int Age) { this.Name = Name; this.Age = Age; } public string Name { get; init; } public int Age { get; init; } //其他方法属性 }
这里的属性的setter是init,也就是说记录具有不可变性,记录一旦初始化完成,那么它的属性值将不可修改(可以通过反射修改)。
另外,记录运行我们自定义构造方法和属性,但是需要遵循:
1、记录在编译时会根据构造参数生成一个默认的构造函数,默认构造函数不能被覆盖,如果有自定义的构造函数,那么需要使用this关键字初始化这个默认的构造函数 2、记录中可以自定义属性,自定义属性名可以构造参数名,也就是说自定义属性可以覆盖构造参数生成的属性,此时对应构造参数将不起任何作用,但是我们可以通过属性指向这个构造参数来自定义这样一个属性
比如:
public record Person(string Name, int Age) { //自定义构造函数需要使用this初始化默认构造函数 public Person(string Name) : this(Name, 18) { } //覆盖构造参数中的Age,属性不用是init,可以自定义,public也可以改成internal等等 internal int Age { get; set; } = Age;//这个赋值很重要,如果没有,构造函数中的参数值将不会给到属性,也就是说构造函数中的Age不起任何作用 //额外的自定义属性 public DateTime Birth { get; set; } } //等价于 public class Person : IEquatable<Person> { public Person(string Name) : this(Name, 18) { } public Person(string Name, int Age) { this.Name = Name; this.Age = Age; } public string Name { get; init; } internal int Age { get; set; }//Age改变 //额外的自定义属性 public DateTime Birth { get; set; } //其他方法及属性 }
从上面可以看到,虽然记录具有不可变性,但是我们可以通过自定义属性来覆盖原来的行为,让其属性变为可修改的,Age属性有原来的public和init变为internal和set。
此外,在创建一个记录时,可以给构造参数指定一些特性标识,在编译时会用这些特性给到生成的对应属性,如:
public record Person([property: JsonPropertyName("name")] string Name, [property: JsonPropertyName("name")] int Age); //等价于 public class Person : IEquatable<Person> { [JsonPropertyName("name")] public string Name { get; set; } [JsonPropertyName("name")] public int Age { get; set; } //其他方法及属性 }
其中property表示特性加在属性上,field表示特性加在字段上,param表示特性加在构造函数的参数上
2、记录可以解构
上面的例子可以看到,每个记录,在编译时会针对构造参数生成一个Deconstruct 方法,因此记录天生就支持解构:
Person person = new Person("zhangsan", 21); var (name, age) = person; Console.WriteLine($"name={name},age={age}");//name=zhangsan,age=21
注:解构只针对默认构造函数的构造参数,不计算自定义的属性和构造函数,如果需要,我们还可以重载自己的解构Deconstruct方法
3、记录可以继承
记录可继承,但是需要遵循:
1、一条记录可以从另一条记录继承,但不能从一个类中继承,一个类也不能从一个记录继承 2、继承的子记录必须声明父记录中各参数
例如:
public record Person(string Name, int Age); public record Teacher(string Phone, int Age, string Name) : Person(Name, Age); public record Student(string Grade, int Age, string Name) : Person(Name, Age);
4、值相等性
值相等性一般是值类型的一个概念,而记录是引用类型,要实现值相等性,主要通过三个方面来实现:
- 重写Object的Equals和GetHashCode方法
- 重写运算符
==和!= - 实现了IEquatable<T>接口
重写Object的Equals方法和重写运算符 == 、!=很好理解,因为引用类型在使用Equals方法或者运算符 == 、!=作判断时,是根据对象是否是同一个对象的引用而返回true或者false,例如:
public record Person(string Name, int Age); static void Main(string[] args) { //一般引用类型 var exception1 = new Exception(); var exception2 = exception1; Console.WriteLine(exception1.Equals(exception2));//true Console.WriteLine(exception1 == exception2);//true Console.WriteLine(exception1.Equals(new Exception()));//false Console.WriteLine(exception1 == new Exception());//false //记录 var person1 = new Person("zhangsan", 18); var person2 = person1; Console.WriteLine(person1.Equals(person2));//true Console.WriteLine(person1 == person2);//true Console.WriteLine(person1.Equals(new Person("zhangsan", 18)));//true Console.WriteLine(person1 == new Person("zhangsan", 18));//true }
对于实现了IEquatable<T>接口,是为了让记录在泛型集合中,如Dictionary<TKey,TValue>, List<T>等,在使用Contains, IndexOf, LastIndexOf, Remove等方法时可以像string,int,bool等类型一样对待,例如:
public record Person(string Name, int Age); static void Main(string[] args) { //一般引用类型 List<Exception> exceptions = new List<Exception>() { new Exception() }; Console.WriteLine(exceptions.IndexOf(new Exception()));//-1 Console.WriteLine(exceptions.Contains(new Exception()));//false Console.WriteLine(exceptions.Remove(new Exception()));//false //记录 List<Person> persons = new List<Person>() { new Person("zhangsan", 18) }; Console.WriteLine(persons.IndexOf(new Person("zhangsan", 18)));//0 Console.WriteLine(persons.Contains(new Person("zhangsan", 18)));//true Console.WriteLine(persons.Remove(new Person("zhangsan", 18)));//true }
换句话说,虽然记录是引用类型,但是我们应该将记录按值类型一样去使用。
注意:
1、实现的IEquatable<T>接口的Equals方法和重写的GetHashCode方法中使用的属性不仅仅是构造参数对应的属性,还包含自定义的属性、继承的属性(包括public,internal,protected,private,但是需要有get获取器) 2、无论是重写Object的Equals方法,还是重写运算符 == 和 !=,最终都是调用实现的IEquatable<T>接口的Equals方法
虽然记录的值相等性很好用,但是这有个问题,因为记录可继承,那么如果父子记录的属性值一样,如果判定他们相同显然不合理,因此编译时额外生成了一个EqualityContract属性:
1、EqualityContract属性指向当前的记录类型(Type),使用protected修饰 2、如果记录没有从其它记录继承,那么EqualityContract属性会带有virtual修饰,否将会使用override重写 3、如果记录指定为sealed,即不可派生,那么EqualityContract属性会带有sealed修饰
为了保证父子记录的差异性,在实现的IEquatable<T>接口的Equals方法中,处理判断属性值相同外,还会判断记录类型是否一致,即EqualityContract属性。
那如果说,我们需要只考虑属性值,而不考虑类型时,需要判断他们相等,这时只需要重写EqualityContract属性,将它指向同一个Type即可。
此外,可以自定义Equals方法,这样编译时就不会生成Equals方法。
5、非破坏性变化:with
因为记录是引用类型,而属性的setter是init,因此当我们需要克隆一个记录时就出现困难了,我们可以通过自定义属性来修改setter来实现,但这不是记录的初衷。
记录可以使用with关键字来实现非破坏性的变化:
public record Person(string Name, DateTime Birth, int Age, string Phone, string Address);
static void Main(string[] args) { //初始化了一个对象 Person person = new("zhangsan", new DateTime(1999, 1, 1), 22, "13987654321", "中国"); //如果想改下地址,因为记录的不可变性,不能直接使用属性修改 //person.Address = "中国深圳";//报错 //方法一:可以重新初始化,但是不方便 person = new(person.Name, person.Birth, person.Age, person.Phone, "中国深圳"); //方法二:可以使用with关键字 person = person with { Address = "中国深圳" }; //可以使用with关键字克隆一个对象 var clone = person with { }; Console.WriteLine(clone == person);//true Console.WriteLine(ReferenceEquals(clone, person));//false }
使用with关键字时会先调用<Clone>$()方法来创建一个对象,然后对这个对象进行指定属性的初始化,这就是最开始的例子中<Clone>$()方法的作用:
person = person with { Address = "中国深圳" };
//在编译后等价于
var temp=person.<Clone>$();
temp.Address = "中国深圳";
person = temp;
在写代码时,我们当然不能显式的调用<Clone>$()方法,因为名称不合法(它是编译器生成的),<Clone>$()方法其实就是调用一个构造函数来实现初始化的,这表示我们可以通过自定义或者重写这个构造函数来实现我们自己的逻辑:
public class Person : IEquatable<Person> { protected Person(Person original) { this.Name = original.Name; this.Age = original.Age; } public virtual Person <Clone>$() => new Person(this); //其他方法属性 }
注意,传入构造函数的参数是原始对象,然后使用原始对象中的属性值来进行初始化,如果属性值是一个引用类型,那么它将进行浅复制过程。
注:这里with用法针对引用类型记录,值类型记录的with参考后文
6、内置格式化
记录还重写了ToString,可以方便查看,输出格式默认是:
记录类型 { 属性名1 = 属性值1, 属性名2 = 属性值2, ...}
例如:
public record Person(string Name, int Age); static void Main(string[] args) { //初始化了一个对象 Person person = new("zhangsan", 22); Console.WriteLine(person); //输出:Person { Name = zhangsan, Age = 22 } }
编译器还合成了一个PrintMembers方法,如果我们有自己提供PrintMembers方法,编译器就不会合成了,所以如果我们想要实现自己的格式化,只需要实现自己的PrintMembers方法,而不用重写ToString方法。
public record Person(string Name, int Age) { protected virtual bool PrintMembers(StringBuilder builder) { builder.Append("Name"); builder.Append(" : "); builder.Append(Name); builder.Append(", "); builder.Append("Age"); builder.Append(" : "); builder.Append(Age.ToString()); return true; } } static void Main(string[] args) { //初始化了一个对象 Person person = new("zhangsan", 22); Console.WriteLine(person); //输出:Person { Name : zhangsan, Age : 22 } //属性名称与值之间使用了:而不是= }
值类型记录
注:值类型记录只针对C#10及以后的版本有效
值类型记录也就是结构体记录,大体上,值类型记录与引用类型记录的区别,就跟值类型与引用类型的区别差不多,所以具体不介绍,可以参考上面引用类型的介绍,这里只具体介绍它们的区别。
值类型记录又分为两种:record struct和readonly record struct,这里结合record class来看看它们的区别:
比如有三个record:
public record class Point1(double X, double Y); public readonly record struct Point2(double X, double Y); public record struct Point3(double X, double Y);
这里Point1是record class,Point2是readonly record struct,Point3是record struct,经过编译,它们等价于下面的三个类和结构体(方法体去掉了,具体可参考上面引用类型记录):
public class Point1 : IEquatable<Point1> { protected Point1(Point1 original); public Point1(double X, double Y); protected virtual Type EqualityContract { get; } public double X { get; set; } public double Y { get; set; } public virtual Point1 <Clone>$(); public void Deconstruct(out double X, out double Y); public virtual bool Equals(Point1 other); public override bool Equals(object obj); public override int GetHashCode(); protected virtual bool PrintMembers(StringBuilder builder); public override string ToString(); public static bool operator ==(Point1 left, Point1 right); public static bool operator !=(Point1 left, Point1 right); }