1 背景與動機
傳統面向物件編程的核心思想是一個物件有著唯一標識,表現為物件參考,封裝著隨時可變的屬性狀態,如果你改變了一個屬性的狀態,這個物件還是原來那個物件,就是物件參考沒有因為狀態的改變而改變,也就是說該物件可以有很多種狀態,C#從最初開始也是一直這樣設計和作業的,但是一些時候,你可能非常需要一種恰好相反的方式,例如我需要一個物件只有一個狀態,那么原來那種默認方式往往會成為阻力,使得事情變得費時費力,
當一個型別的物件在創建時被指定狀態后,就不會再變化的物件,我們稱之為不可變型別,這種型別是執行緒安全的,不需要進行執行緒同步,非常適合并行計算的資料共享,它減少了更新物件會引起各種bug的風險,更為安全,System.DateTime和string就是不可變型別非常經典的代表,
原來,我們要用類來創建一個不可變型別,你首先要定義只讀欄位和屬性,并且還要重寫涉及相等判斷的方法等,在C#9.0中,引入了record,專門用來以最簡的方式創建不可變型別的新方式,如果你需要一個行為像值型別的參考型別,你可以使用record;如果你需要整個物件都是不可變的,且行為像一個值,那么你也可考慮將其宣告為一個record型別, 那么什么是record型別?
2 Record介紹
record型別是一種用record關鍵字宣告的新的參考型別,與類不同的是,它是基于值相等而不是唯一的識別符號——物件參考,他有著參考型別的支持大物件、繼承、多型等特性,也有著結構的基于值相等的特性,可以說有著class和struct兩者的優勢,在一些情況下可以用以替代class和struct,
提到不可變的型別,我們會想到readonly struct,那么為什么要選擇添加一個新的型別,而不是用readonly struct呢?這是因為記錄有著如下優點:
-
在構造不可變的資料結構時,它的語法簡單易用,
-
record為參考型別,不用像值型別在傳遞時需要記憶體分配,并進行整體拷貝,
-
建構式和結構函式為一體的、簡化的位置記錄
-
有力的相等性支持,重寫了Equals(object), IEquatable
, 和GetHashCode()這些基本方法,
2.1 record型別的定義與使用
2.1.1 常規方式
record型別可以定義為可變的,也可以是不可變的,現在,我們用record定義一個只有只讀屬性的Person型別如下,這種只有只讀屬性的型別,因為其在創建好之后,屬性就不能再被修改,我們通常把這種型別叫做不可變型別,
public record Person
{
public string LastName { get; }
public string FirstName { get; }
public Person(string first, string last) => (FirstName, LastName) = (first, last);
}
上面這種宣告,在使用時,只能用帶參的建構式進行初始化,要創建一個record物件跟類沒有什么區別:
Person person = new("Andy", "Kang");
如果要支持用物件初始化器進行初始化,則在屬性中使用init關鍵字,這種形式,如果不需要用帶參的建構式進行初始化,可以不定義帶參的建構式,上面的Person可以改為下面形式,
public record Person
{
public string? FirstName { get; init; }
public string? LastName { get; init; }
}
現在,創建Person物件時,用初始化器進行初始化如下:
Person person = new() { FirstName = "Andy", LastName = "Kang"};
如果需要是可變型別的record,我們定義如下,這種因為有set訪問器,所有它支持用物件初始化器進行初始化,如果你想用建構式進行初始化,你可以添加自己的建構式,
public record Person
{
public string? FirstName { get; set; }
public string? LastName { 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.1.3 定義的總結
record默認情況下是被設計用來進行描述不可變型別的,因此位置記錄這種短小簡明的宣告方式是推薦方式,
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的,
3 應用場景
3.1 Web Api
用于web api回傳的資料,通常作為一種一次性的傳輸型資料,不需要是可變的,因此適合使用record,
3.2 并發和多執行緒計算
作為不可變資料型別record對于并行計算和多執行緒之間的資料共享非常適合,安全可靠,
3.3 資料日志
record本身的不可變性和ToString的資料內容的輸出,不需要很多人工撰寫很多代碼,就適合進行日志處理,
3.4 其他
其他涉及到有大量基于值型別比較和復制的場景,也是record的常用的使用場景,
4 結束語
在生產應用中,有著眾多的使用場景,以便我們用record來替換寫一個類,未知的還在等我們進一步探索,
如對您有價值,請推薦,您的鼓勵是我繼續的動力,在此萬分感謝,關注本人公眾號“碼客風云”,享第一時間閱讀最新文章,
<iframe style="background: rgba(255, 255, 255, 1)" src="https://mp.weixin.qq.com/mp/appmsgalbum?action=getalbum&album_id=1612459507345899521&__biz=MzAwNjcyNTU2Ng==#wechat_redirect" frameborder="0" width="100%" height="342"></iframe>
轉載請註明出處,本文鏈接:https://www.uj5u.com/net/232432.html
標籤:.NET技术
