1. 程式人生 > >C# 9.0新特性詳解系列之五:記錄(record)和with表示式

C# 9.0新特性詳解系列之五:記錄(record)和with表示式

## [1][1] 背景與動機 傳統面向物件程式設計的核心思想是一個物件有著唯一標識,表現為物件引用,封裝著隨時可變的屬性狀態,如果你改變了一個屬性的狀態,這個物件還是原來那個物件,就是物件引用沒有因為狀態的改變而改變,也就是說該物件可以有很多種狀態。C#從最初開始也是一直這樣設計和工作的。但是一些時候,你可能非常需要一種恰好相反的方式,例如我需要一個物件只有一個狀態,那麼原來那種預設方式往往會成為阻力,使得事情變得費時費力。 當一個型別的物件在建立時被指定狀態後,就不會再變化的物件,我們稱之為不可變型別。這種型別是執行緒安全的,不需要進行執行緒同步,非常適合平行計算的資料共享。它減少了更新物件會引起各種bug的風險,更為安全。System.DateTime和string就是不可變型別非常經典的代表。 原來,我們要用類來建立一個不可變型別,你首先要定義只讀欄位和屬性,並且還要重寫涉及相等判斷的方法等。在C#9.0中,引入了record,專門用來以最簡的方式建立不可變型別的新方式。如果你需要一個行為像值型別的引用型別,你可以使用record;如果你需要整個物件都是不可變的,且行為像一個值,那麼你也可考慮將其宣告為一個record型別。 那麼什麼是record型別? ## [2][1] 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型別如下。這種只有只讀屬性的型別,因為其在建立好之後,屬性就不能再被修改,我們通常把這種型別叫做不可變型別。 ```C# bymark public record Person { public string LastName { get; } public string FirstName { get; } public Person(string first, string last) => (FirstName, LastName) = (first, last); } ``` 上面這種宣告,在使用時,只能用帶參的建構函式進行初始化。要建立一個record物件跟類沒有什麼區別: ```C# bymark Person person = new("Andy", "Kang"); ``` 如果要支援用物件初始化器進行初始化,則在屬性中使用init關鍵字。這種形式,如果不需要用帶參的建構函式進行初始化,可以不定義帶參的建構函式,上面的Person可以改為下面形式。 ```C# bymark public record Person { public string? FirstName { get; init; } public string? LastName { get; init; } } ``` 現在,建立Person物件時,用初始化器進行初始化如下: ```C# bymark Person person = new() { FirstName = "Andy", LastName = "Kang"}; ``` 如果需要是可變型別的record,我們定義如下。這種因為有set訪問器,所有它支援用物件初始化器進行初始化,如果你想用建構函式進行初始化,你可以新增自己的建構函式。 ```C# bymark public record Person { public string? FirstName { get; set; } public string? LastName { get; set; } } ``` #### 2.1.2 位置記錄 / Positional records 為了支援將record物件能解構成元組,我們給record新增解構函式Deconstruct。這種record就稱為位置記錄。下面程式碼定義的Person,記錄的內容是通過建構函式的引數傳入,並且通過位置解構函式提取出來。你完全可以在記錄中定義你自己的構造和解構函式(注意不是解構函式)。如下所示:。 ```C# bymark 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預設是不可變的說法由來。 ```C# bymark public record Person(string FirstName, string LastName); ``` 該方式聲明瞭公開的、僅可初始化的自動屬性、建構函式和解構函式。現在建立物件,你就可以寫如下程式碼: ```C# bymark var person = new Person("Mads", "Torgersen"); // 位置建構函式 var (firstName, lastName) = person; // 位置解構函式 ``` 當然,如果你不喜歡產生的自動屬性、建構函式和解構函式,你可以自定義同名成員代替,產生的建構函式和解構函式將會只使用你自定義的那個。在這種情況下,被自定義引數處於你用於初始化的作用域內,例如,你想讓FirstName是個保護屬性: ```C# bymark public record Person(string FirstName, string LastName) { protected string FirstName { get; init; } = FirstName; } ``` 如上例子所示,對位置記錄進行擴充套件,你可以在大括號裡新增你想要的任何成員。 一個位置記錄可以像下面這樣呼叫父類建構函式。 ```C# bymark 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表示式,用於拷貝原有物件,並對特定屬性進行修改: ```C# bymark var person = new Person { FirstName = "Mads", LastName = "Nielsen" }; var otherPerson = person with { LastName = "Torgersen" }; ``` 如果只是進行拷貝,不需要修改屬性,那麼無須指定任何屬性修改,如下所示: ```C# bymark Person clone = person with { }; ``` with表示式使用初始化語法來說明新物件在哪裡與原有物件不同。with表示式實際上是拷貝原來物件的整個狀態值到新物件,然後根據物件初始化器來改變指定值。這意味著屬性必須有init或者set訪問器,才能用with表示式進行更改。 需要注意的是: * with表示式左邊運算元必須為record型別。 * record的引用型別的成員在拷貝的時候,只是將所指例項的引用進行了拷貝。 ## 2.3 record的面向物件的特性——繼承、多型等 記錄(record)和類一樣,在面向物件方面,支援繼承,多型等所有特性。除過前面提到的record專有的特性,其他語法寫法跟類也是一樣。同其他型別一樣,record的基類依然是object。 要注意的是: * 記錄只能從記錄繼承,不能從類繼承,也不能被任何類繼承。 * record不能定義為static的,但是可以有static成員。 下面一個學生record,它繼承自Person: ```C# bymark public record Person { public string? FirstName { get; init; } public string? LastName { get; init; } } public sealed record Student : Person { public int ID { get; init; } } ``` 對於位置記錄,只要保持record特有的寫法即可: ```C# bymark 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變數裡。 ```C# bymark Person student = new Student { FirstName = "Mads", LastName = "Nielsen", ID = 129 }; ``` with表示式仍然拷貝整個物件並保持著執行時的型別: ```C# bymark var otherStudent = student with { LastName = "Torgersen" }; WriteLine(otherStudent is Student); // true ``` 同樣地,值相等性確保兩個物件有著同樣的執行時型別,然後比較他們的所有狀態: ```C# bymark 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的記錄型別: ```C# bymark public record Person(string FirstName, string LastName); ``` 編譯器生成的程式碼和下面的程式碼定義是等價的。但是要注意的是,跟編譯器實際生成的程式碼相比,名字的命名是有所不同的。 ```C# bymark public class Person : IEquatable { 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.Default.GetHashCode(EqualityContract) * -1521134295 + EqualityComparer.Default.GetHashCode(_FirstName)) * -1521134295 + EqualityComparer.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.Default.Equals(_FirstName, other._FirstName) && EqualityComparer.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又修改回去了: ```C# bymark 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: ```C# bymark public record Student(string FirstName, string LastName, int Level) : Person(FirstName, LastName); ``` 這個時候,我們分別建立一個Person和Student例項,都用來描述同樣的人: ```C# bymark Person p = new Person("Jerry", "Kang"); Person s = new Student("Jerry", "Kang", 1); WriteLine(p == s); // False ``` 這兩者比較的結果是False,這與我們實際需求不相符。那麼我們可以重寫EqualityContract來實現兩種相等: ```C# bymark 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物件的欄位值拷貝到新物件對應欄位中: ```C# bymark protected Person(Person original) { /* 拷貝所有欄位 */ } // 編譯器生成 ``` with表示式就會引起拷貝建構函式被呼叫,然後應用物件初始化器來有限更改屬性相應值。如果你不喜歡預設的產生的拷貝建構函式,你可以自定義該建構函式,編譯器一旦發現有自定義的建構函式,就不會在自動生成,with表示式也會進行呼叫。 ```C# bymark 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格式: ```C# bymark 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][1] 應用場景 ### 3.1 Web Api 用於web api返回的資料,通常作為一種一次性的傳輸型資料,不需要是可變的,因此適合使用record。 ### 3.2 併發和多執行緒計算 作為不可變資料型別record對於平行計算和多執行緒之間的資料共享非常適合,安全可靠。 ### 3.3 資料日誌 record本身的不可變性和ToString的資料內容的輸出,不需要很多人工編寫很多程式碼,就適合進行日誌處理。 ### 3.4 其他 其他涉及到有大量基於值型別比較和複製的場景,也是record的常用的使用場景。 ## [4][1] 結束語 在生產應用中,有著眾多的使用場景,以便我們用record來替換寫一個類。未知的還在等我們進一步探索。 [1]: https://mp.weixin.qq.com/s?__biz=MzAwNjcyNTU2Ng==&mid=2247483808&idx=1&sn=42ba4a436ecc4ecd02f00be4389c85ce&chksm=9b084b71ac7fc267011bddfb3246b8698034a74721aaf834731d96ac0a73c594a683b345a070&cur_album_id=1612459507345899521&scene=189#rd ####如對您有價值,請推薦,您的鼓勵是我繼續的動力,在此萬分感謝。關注本人公眾號“碼客風雲”,享第一時間閱讀最新文章。