C#中的記錄(record)
從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); }Point1
public readonly struct Point2 : IEquatable<Point2> { public Point2() { } public Point2(double X, double Y); public double X { get; init; } public double Y { get; init; } public void Deconstruct(out double X, out double Y); public bool Equals(Point2 other); public override bool Equals(object obj); public override int GetHashCode(); private bool PrintMembers(StringBuilder builder); public override string ToString(); public static bool operator !=(Point2 left, Point2 right); public static bool operator ==(Point2 left, Point2 right); }Point2
public struct Point3 : IEquatable<Point3> { public Point3() { } public Point3(double X, double Y); public double X { get; set; } public double Y { get; set; } public readonly void Deconstruct(out double X, out double Y); public readonly bool Equals(Point3 other); public override readonly bool Equals(object obj); public override readonly int GetHashCode(); private bool PrintMembers(StringBuilder builder); public override string ToString(); public static bool operator !=(Point3 left, Point3 right); public static bool operator ==(Point3 left, Point3 right); }Point3
可以看到,這三種類型的記錄主要有共同點有:
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的實體跟蹤機制造成影響
參考文件:https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record
一個專注於.NetCore的技術小白