從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("age")] int Age);//等價于public class Person : IEquatable<Person>{[JsonPropertyName("name")]public string Name { get; set; }[JsonPropertyName("age")]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));//trueConsole.WriteLine(exception1 == exception2);//trueConsole.WriteLine(exception1.Equals(new Exception()));//falseConsole.WriteLine(exception1 == new Exception());//false//記錄var person1 = new Person("zhangsan", 18);var person2 = person1;Console.WriteLine(person1.Equals(person2));//trueConsole.WriteLine(person1 == person2);//trueConsole.WriteLine(person1.Equals(new Person("zhangsan", 18)));//trueConsole.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()));//-1Console.WriteLine(exceptions.Contains(new Exception()));//falseConsole.WriteLine(exceptions.Remove(new Exception()));//false//記錄List<Person> persons = new List<Person>() { new Person("zhangsan", 18) };Console.WriteLine(persons.IndexOf(new Person("zhangsan", 18)));//0Console.WriteLine(persons.Contains(new Person("zhangsan", 18)));//trueConsole.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);//trueConsole.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方法。
值類型記錄
注:值類型記錄只針對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,經過編譯,它們等價于下面的三個類和結構體(方法體去掉了,具體可參考上面引用類型記錄):
可以看到,這三種類型的記錄主要有共同點有:
1、對記錄的參數,分別生成了屬性2、生成了一個包含記錄所有屬性的構造函數3、重寫了 Object.Equals(Object)方法和Object.GetHashCode()方法4、實現了System.IEquatable<T>接口5、實現了==和!=運算操作6、實現了Deconstruct方法而實現解構操作7、重寫了Object.ToString()方法,以及創建了一個PrintMembers用于序列化(但是PrintMembers有些許區別)
共同點沒什么好說的,參考上面引用類型介紹就可以了,接下來說說不同點:
1、record class和readonly record struct生成的屬性是get和init標識,也就是說它們的對象是只讀的,而record struct生成的屬性是get和set標識,也就是說它的對象是可讀可寫的
例如:
var point1 = new Point1(1, 2);point1.X = 2;//報錯var point2 = new Point2(1, 2);point2.X = 2;//報錯var point3 = new Point3(1, 2);point3.X = 2;//編譯通過
2、在構造函數上,record class會生成兩個構造函數:一個是protected修飾,用于<Clone>$()方法克隆一個對象,一個public修飾,包含所有的構造參數,而readonly record struct和record struct只包含一個public修飾,包含所有的構造參數的構造函數,但是因為它們的本質還是結構體,因此默認會有一個空構造函數,因此在創建時有區別:
//創建時需要指定所有的參數,protected修飾的構造函數不能在記錄及子記錄外使用var point1 = new Point1(1, 2);//除了可以指定所有參數的構造函數,還可以使用空構造函數初始化var point2 = new Point2(1, 2);point2 = new Point2();var point3 = new Point3(1, 2);point3 = new Point3();
3、record class類型記錄會生成一個<Clone>$()方法,它通過調用一個protected的構造函數來克隆出一個新的引用對象,而我們可以通過自定義或者重寫這個protected的構造函數的構造函數來實現我們自己業務邏輯。
其實這個<Clone>$()方法是在with關鍵字中使用的:
var point1 = new Point1(1, 2);var point = point1 with { X = 2 };//等價于var point1 = new Point1(1, 2);var point = point1.<Clone>$();point.X = 2;//注:編譯時給point.X賦值會報錯(因為init),這里只是說明
而對于readonly record struct和record struct類型記錄,因為它們的本質是struct,天生是值復制的,因此就不需要這么一個<Clone>$()方法了,與此對應的是,結構體默認會有空構造函數(C#10)。
var point2 = new Point2(1, 2);var point = point2 with { X = 2 };//等價于var point = point2;//struct是值復制過程point.X = 2;
//注:編譯時給readonly record struct聲明的屬性賦值會報錯,而給record struct聲明的屬性賦值不會報錯,這里只是說明
4、record struct,readonly record struct,record class都可以擁有自定義屬性,但是有些許區別
1、record class按類中的屬性規則去定義2、record struct按結構體中的屬性規則去定義,此外,定義的屬性必須進行初始化3、readonly record struct按結構體中的屬性規則去定義,此外,定義的屬性必須進行初始化,而且定義的屬性只能是只讀的
例如:
public record class Point1(double X, double Y){public double Z { get; set; }}public readonly record struct Point2(double X, double Y){public double Z { get; } = default;//必須初始化,此外readonly修飾所以只能只讀}public record struct Point3(double X, double Y){public double Z { get; set; } = default;//必須初始化}
5、record struct,readonly record struct,record class都實現了System.IEquatable<T>接口,而且重寫了Object.Equals(Object)方法(本質上是通過System.IEquatable<T>接口來實現),但是record class中實現的Equals方法除了比較屬性以外,還會比較記錄的類型是否一致(即比較EqualityContract屬性,這一點可以參考上面介紹的引用類型記錄的值相等性部分),而對于record struct,readonly record struct,在編譯時,并沒有生成一個EqualityContract屬性,在實現的Equals方法也只是比較了屬性值,沒有比較類似是否一致。
其實想想,結構體只能實現接口,而不能從另一個結構體派生,因此在在實現的Equals方法自然就沒有進行類型判斷的必要了。
6、record struct,readonly record struct,record class都重寫了Object.ToString()方法,而且都是通過創建了一個PrintMembers方法來實現的,但是在PrintMembers方法上表現的行為不一致(這是一點細節,了解即可)。
1、如果記錄是結構體記錄(即record struct和readonly record struct),或者使用sealed關鍵字修飾,那么生成的PrintMembers方法是:private bool PrintMembers(StringBuilder builder);2、如果記錄沒有使用sealed關鍵字修飾,且記錄直接派生自Object(即沒有派生自一個父記錄),那么生成的PrintMembers方法是:protected virtual bool PrintMembers(StringBuilder builder);3、如果記錄派生自一個父記錄,那么生成的PrintMembers方法是:protected override bool PrintMembers(StringBuilder builder);
總結
記錄是一個語法糖,本質上還是class或者struct,它只編譯時生效,運行時并沒有記錄這個東西,此外,根據官網介紹,記錄不適合在EntityFrameworkCore中使用,畢竟它重寫了Equals方法和相等運算(==和!=),這樣可能會對EntityFrameworkCore的實體跟蹤機制造成影響。