探索 C# 9 的記錄類型
今天我們來講一個 C# 里的新數(shù)據(jù)類別:記錄(Record)。
POCO 的概念
要想知道記錄是什么,我們就需要先了解一個基本概念:POCO。
POCO 的全稱是 Plain Old C# Object,這個 C 除了翻譯成 C# 也可以翻譯成 CLR,直接翻譯出來是平凡陳舊 C#/CLR 對象。典型的說了當沒說系列。它實際上指的是一個數(shù)據(jù)類型,里面除了含有數(shù)據(jù)成員以外,別的什么都沒有。
舉個例子。這個 Student
類型就是一個簡單的 POCO。
因為這個數(shù)據(jù)類型里,別的啥都沒有,只包含基本的 Name
、Age
和 Gender
三個數(shù)據(jù)成員,所以滿足 POCO 的基本定義。
數(shù)據(jù)成員(Data Member)指的是用于存儲數(shù)據(jù)的基本信息的成員類型?,F(xiàn)如今 C# 的數(shù)據(jù)成員可以是數(shù)據(jù)類型里的字段,以及同時帶有
get
和set
的自動屬性。早期 C# 只有字段可以用于存儲數(shù)據(jù),但封裝機制的復雜性導致 C# 不得不簡化代碼,所以屬性后來也可以在不聲明配套字段的情況下獨立存在。它們往往都是直接get
和set
都帶有的自動屬性,因此這樣的自動屬性也稱為數(shù)據(jù)成員。
另外,這個數(shù)據(jù)類型還可以包含基本的構造器(為這三個成員賦值)、ToString
的重寫方法用來顯示輸出屬性的結果之類的,包含它們也不影響數(shù)據(jù)自身,所以即使有了這些成員,數(shù)據(jù)類型仍是 POCO。
不過,下面這個數(shù)據(jù)類型則不是 POCO:
你可能會問,它不也只是包含數(shù)據(jù)成員?雖然是,但 Name
上方標記了一個特性,此特性使得這個 Name
屬性可能在別的地方有別的用法。所以這里的 PersonComponent
類型不是 POCO。
POCO 專門用于記錄一個數(shù)據(jù)信息,它自身沒有別的用途,只存儲一些基本信息,為的就是以后能夠通過屬性或字段的成員訪問來獲取它們,僅此而已。這樣的數(shù)據(jù)類型是 POCO。
記錄的基本概念
記錄類型是一種特殊的類型,它為了我們能夠更加容易地實現(xiàn) POCO 而出現(xiàn)這樣的類型。C# 9 里出現(xiàn)了這個特性。它的語法是采用和類型聲明基本一致的方式,將 class
關鍵字改成 record
上下文關鍵字。
是的,僅需一句話即可完美表示一個和早期寫這么一大堆代碼的類型。這個 Student
此時用 record
這個上下文關鍵字修飾后,它就被稱為記錄類型。
下面我們來說一下記錄類型底層的實現(xiàn)細節(jié),以及記錄類型順帶帶出來的一些新語法特性。
記錄類型的底層
主構造器
記錄類型在后臺其實也跟前面早期聲明的 Student
類類型是差不多的,不過只是編譯器看到了 record
關鍵字之后,會自動生成這些代碼,不需要你自己手寫而已。因此,記錄類型在底層也是一個平凡的類型。
既然是一個類型,那么它就得包含一些基本的成員。C# 9 產(chǎn)生的記錄類型規(guī)定,一個記錄類型像是上面這樣的書寫方式的話,在底層除了會產(chǎn)生這些基本的數(shù)據(jù)成員以外,還有一些別的成員:
一個帶有這些屬性對應賦值的構造器;
Equals
的重寫方法(簽名大概是bool Equals(object)
);Equals
方法,參數(shù)不是object
而是這個數(shù)據(jù)類型本身(簽名大概是bool Equals(T)
);GetHashCode
重寫方法(簽名大概是int GetHashCode()
);ToString
重寫方法(簽名大概是string ToString()
);Clone
克隆方法(簽名大概是T Clone()
);Deconstruct
解構方法(簽名大概是void Deconstruct(...)
,參數(shù)都是out
類型的,把每一個寫在小括號的數(shù)據(jù)成員全部挨個寫入到這里當參數(shù));運算符
==
和!=
,參數(shù)是這個類型自己(簽名分別是operator ==(T, T)
和operator !=(T, T)
);一個
private
或者protected
修飾的構造器,參數(shù)是這個類型自己(簽名大概是T(T)
);一個
private
或者protected
修飾的PrintMembers
方法(簽名大概是bool PrintMembers(StringBuilder)
);一個
private
或者protected
修飾的EqualityContract
屬性(簽名大概是Type EqualityContract
)。
看起來有點多。我們會對這些成員挨個說明一下。我們先來說一下構造器。
根據(jù)這個內容,我們可以知道的是,記錄類型會固定在底層產(chǎn)生兩個構造器,大概是這樣的:
先來說第一個。第一個構造器是 public
修飾的。這個 public
是系統(tǒng)生成的,你是修改不了的。然后,參數(shù)表列里就是我們在最開始寫在 record Student(...)
語句里小括號的這些信息。相當于就是順序抄寫到這里來了。然后,底層會自動產(chǎn)生 Name
、Age
和 Gender
屬性,然后在這里可以提供賦值。所以,有多少個屬性就有多少個屬性的賦值過程。
再來說第二個。第二個構造器的修飾符 protected
也可能是 private
,這取決于你這個數(shù)據(jù)類型 Student
的 record
前面有沒有別的修飾符。如果包含 sealed
修飾符的話,那么此時因為類型是 sealed
的,所以不可能派生出來別的類型,因此 protected
修飾符就不可能出現(xiàn)在 sealed
修飾符修飾的類型里,因此這種情況下,這個自動生成的構造器是 private
修飾的;然后在構造器里,這段代碼也是固定的,挨個屬性賦值。
我們把第一個構造器(挨個屬性抄進來當參數(shù))稱為這個記錄類型的主構造器(Primary Constructor)。
init
屬性
因為最開始我們說過,這些 record
后寫的東西自動被底層翻譯成屬性,所以它們其實就是自動屬性。不過,和前面手動書寫的 Student
類不一樣的地方是,這里自動生成的屬性,聲明格式是這樣的:
可以看到,屬性全部的 set
均被替換為了一個新的關鍵字 init
。init
的概念和 set
基本一致,也是跟 set
實現(xiàn)機制一致的賦值過程,但換了一個關鍵字后,意思不一樣了:set
是隨時隨地都可以賦值,但 init
只能在實例化的時候,寫入到初始化器里。
舉個例子,我用原來的 set
屬性的話:
set
只約束你賦值的具體過程,但什么時候賦值都行;而 set
改成 init
這樣就不行了??蛇@種限制有什么意義呢?實際上,初始化器只能跟在 new
實例化表達式之后,而上面這個離散的 stu.Name
和 stu.Age
這些寫法實際上我不一定非得跟在 new
之后。實際上因為它已經(jīng)脫離了 new
表達式的語法,所以它可以在中間插入很多東西然后再來賦值。而 init
關鍵字的意義就在這里:init
約束屬性賦值僅可以在初始化器里使用和賦值,任何其它別的地方都不行。
可這限制為什么得這樣搞呢?C# 9 的記錄類型認為,這樣的 POCO 是在默認情況下是不可變的。不可變的意思就是,這個數(shù)據(jù)類型一旦在 new
聲明和實例化出來后,就不得再對里面的數(shù)據(jù)進行修改;而如果假設這些屬性沒有 init
的話,所有的屬性全部只有 get
,沒有了 init
也沒有 set
的話,這些屬性就全部只能通過構造器賦值,這樣就不靈活;而 init
一旦有了的話,語法的約定和使用規(guī)則允許你可以在初始化器里賦值,這樣限定更加合理和嚴謹;而另外一方面來說,init
你也可以不使用,即使你知道它可能可以使用初始化器來賦值,我也可以不使用。比如我上面這種賦值下,Gender
屬性我就沒有賦值。而如果使用構造器的話,你三個屬性全都必須賦值,就沒有必要。
你可能會問我,既然底層是一個普通類型的話,我們知道,因為上面自動產(chǎn)生的內容里沒有無參構造器,所以 new Student { Name = ..., Age = ... }
的語法是不成立的,因為你沒有無參構造器,就無法這么寫。是的,這個時候我們需要加一個東西進去:
C# 9 的記錄類型也確實允許我們這么做。這樣的話,上面的 new Student { ... }
的寫法就可以被允許了。不過此時這個無參構造器是我們手寫的,并不是系統(tǒng)生成的,這一點需要你注意。因為底層是一個類,我們早就知道一點,類里但凡包含一個非無參的構造器,那么無參構造器就必然不會自動生成。所以,這個構造器必須得自己寫。
主構造器在記錄類型里有一個基本約定:你要自己定義別的構造器,必須調用這個構造器。因此,我們這里必須書寫一個
: this(default, default, default)
來故意調用它,但傳參都使用default
就好。
Equals
比較方法以及 ==
和 !=
運算符
為了方便使用,光只有屬性和構造器的存在肯定是不夠的,所以,C# 9 記錄類型規(guī)定,和 Equals
方法相關的成員會生成如下四個:
可以看到,系統(tǒng)生成的這些內容都非常好理解。這里稍微要說一下的是這個 ReferenceEquals
方法。它其實就是比較兩個對象的引用(底層就是比較指針)是不是一樣的(是不是指向同一塊內存)。
如果引用不一致,那么就得比較內容。于是后面的 left?.Equals(right) ?? false
是一個整體。?.
和 ??
運算符是我們 C# 6 的語法,?.
是有限判斷 ?.
左邊的對象是不是 null
。如果不是的話就執(zhí)行后面的內容;否則的話直接截斷,并得到 null
的結果。假設這個表達式里 left
為 null
,那么 Equals
方法就不會執(zhí)行,并且 left?.Equals(right)
就會得到 null
的結果,相當于把 null
替換掉這個表達式;與此同時,??
運算符表示“里面不是 null
的部分”——如果 ??
左邊的部分不為 null
,那么就是它自己作為這個表達式的結果;如果是 null
的話,那么 ??
后面的部分就會作為默認結果,作為整個表達式的結果。那么往前分析,?.
這部分如果得到的 null
的話,那么這個 A?.B ?? C
整個表達式就是 C
部分作為結果。
回到這個寫法上。假設 left?.Equals(right)
表達式得到的結果是 null
,那么整個 ?.
和 ??
湊在一起后,整個表達式就是 false
這個值,意味著對象不相等,這恰好和我們期望的比較操作是一樣的,所以這個寫法比較巧妙,可以作為一個定式記一下。
后面這個第 18 行代碼,可能你會認為這個 !(left == right)
有點奇怪。實際上我們要得把這個 ==
看成一個調用方法。因為這個類型已經(jīng)重載了 ==
運算符了,因此 left == right
不再是 object
類型里的引用比較,而是前面重載的這個行為。因為是 !=
運算符,所以只需要得到 ==
運算符的結果,然后取反即可。
GetHashCode
哈希碼方法
這個方法我們就不多說了,它要用一些哈希碼自己的知識點。你只需要知道,哈希碼是用一個整數(shù)來表達對象的 ID 從而通過這個 ID 確定對象是不是一致。如果哈希碼一樣,那么對象就一致。這個哈希碼方法就是專門計算這個數(shù)值的。
當然,既然你有如上的這些屬性,所以這些屬性的實例,都會挨個計算出來哈希碼,然后通過一個復雜的運算整合在一起表示這個對象的哈希碼。
ToString
表征字符串方法和 PrintMembers
方法
ToString
方法用于顯示這個對象的具體信息。所以,這個 ToString
也會被系統(tǒng)自動生成。大概的代碼是這樣的:
能不能看懂這段代碼?ToString
方法最終會得到一個寫法大概是 Student { Name = ..., Age = ..., Gender = ... }
的字符串結果。然后對象的數(shù)值就會自動填到里面去。
稍微注意的是,PrintMembers
,返回值是 bool
??赡苣銜X得奇怪,這么執(zhí)行代碼返回值不一定是 true
嗎?那么這個返回值不就沒有意義?實際上,這些方法是自動生成的,也可以自己寫。這個時候,可能返回值就不再必須是 true
了(如果失敗了就會自動返回 false
之類)。
Clone
克隆方法以及 with
表達式
還記得嗎?這個數(shù)據(jù)類型在底層是一個類,因此只使用 =
賦值只能賦值引用,而 Student
復制構造器也只是 private
或者 protected
修飾符修飾的成員,因此我們無法使用。所以,為了避免機制沖突,我們創(chuàng)建了一個 Clone
方法。
Clone
方法和 Student
里的復制構造器的底層實現(xiàn)代碼差不多,甚至你可以這么認為:
即直接調用復制構造器,返回復制了每個數(shù)據(jù)成員后的這個對象即可。
而這樣的 Clone
方法有什么用呢?還記得 C# 規(guī)定記錄是不可變的嗎?那么我想要改掉其中某一個或若干數(shù)據(jù)成員,又不想大量變更數(shù)據(jù)成員的話,C# 提供了一個語法,叫 with
表達式,而這里的 with
是一個新的上下文關鍵字。
通過 with
表達式,然后后面跟著一個初始化器的形式,可以產(chǎn)生一個新的 stu2
對象,并且和 stu
對象里只差 Name
屬性的數(shù)值不同。而 stu
和 stu2
變量此時是不同的引用,這就是兩個完全獨立的個體了。而在底層,這個 with
方法基本上等于 Clone
方法產(chǎn)生了副本后,然后改掉了 Name
屬性的數(shù)值。這就是這個新語法配合 Clone
方法的使用方式。
而請注意的是,Clone
方法僅可通過 with
表達式來隱式調用,你無法自己調用,編譯器不讓你調用。
Deconstruct
解構方法
C# 7 里有解構函數(shù)的機制,所以允許你在左側寫值元組,右側寫對象自身,然后賦值照樣成功的語法。C# 9 的記錄也自帶這樣的方法。Student
有這三個屬性,系統(tǒng)會自動根據(jù)這三個屬性生成這樣的解構函數(shù):
正是因為它隱式存在(你看不到它的存在,但實際上它在底層生成了),所以你可以直接調用它:
比如這樣的語法,就可以了。不想用其中的某個或某些數(shù)值的話,可以使用棄元符號 _
代替。
EqualityContract
屬性
最后說一下 EqualityContract
屬性。這個屬性是一個你無法在外面使用的屬性,因為它使用的是 private
或者 protected
修飾的,它的返回值是 Type
類型,這個屬性用來干嘛呢?用來表征這個記錄類型是啥類型。
它和 GetType
方法執(zhí)行效果是一樣的,不過 EqualityContract
可提供給編譯器生成別的代碼,所以有了這么一個東西。
成員的合成和非合成
合成成員
我們說完了一個普通的記錄類型的底層生成的代碼,下面我們來說一下一個記錄類型帶來的新概念:合成(Synthesize)。這些成員底層會自動產(chǎn)生,但這不代表我們不能自己手寫。因為 C# 允許我們在基本的定義后再加入一些你自己定義的東西進去,因此你也可以自己手寫一些東西。當然,這也包含上面的這些內容。如果你手寫了上面出現(xiàn)的這些內容的某個或某些的話,那么這個或這些成員此時就稱為合成成員(Synthesized Member)。

我來舉個例子。
此時 EqualityContract
屬性是一個合成屬性。不過,因為規(guī)則要求,我們必須加上 virtual
修飾符,并使用 private
或 protected
修飾符。具體使用 private
還是 protected
修飾符取決于類型本身自己是不是 sealed
修飾過的。顯然它沒有,所以必須使用 protected virtual
組合修飾。所以 public
必須換為 protected virtual
才可以。
自定義成員
下面來說一下自定義的成員。自定義成員就是那些我們隨便定義的,但不會和前面這些包含的成員沖突的成員。所以這些自定義成員我們也可以叫非合成成員(Non-Synthesized Member)。
非合成成員是可以你隨便書寫的,所以不受語法約束。所以,沒有什么特殊的語法限制。但這里要說一下補充數(shù)據(jù)成員的問題。
由于語法的限制,記錄類型的主構造器帶有的這些底層生成的屬性全部都是自動屬性,并且包含的是 get
和 init
,無法改變。不過,C# 允許我們自定義成員,所以我們可以添加數(shù)據(jù)成員到這個類型里,使得這個記錄類型改為可變類型。
比如我在 Student
記錄類型里加入可變的屬性成員 Class
:
此時語法上是沒有問題的,編譯器也不會認為你這么寫代碼會有問題。不過,這變更了屬性的個數(shù),也改變了比較規(guī)則。假設我要生成 Equals
方法的話,因為多了一個 Class
,那么它會不會參與比較相等性呢?
答案是,會。編譯器的靈活性使得你即使自己定義了這樣的數(shù)據(jù)成員,編譯器照樣可以識別到,因此它也會參與相等性比較。不過,一些別的方法可能就不會參與了。下面列舉一下會用到這些數(shù)據(jù)成員操作的成員,并說一下,如果自定義新的數(shù)據(jù)成員后,是否會參與進去。

你記住了嗎?
記錄類型的繼承和派生機制
基本類都有繼承和派生,那么記錄也得有這樣的機制。下面我們就來說一下。
記錄類型的派生語法
記錄類型被翻譯成了類,所以必然也存在繼承和派生關系。但是請注意,記錄類型只能派生和繼承自一個記錄類型,這也就是說,你無法寫一個普通的類型,然后拿給記錄類型當派生類,也不能把記錄類型作為基類型,派生出了一個沒有 record
修飾的普通類型。舉個例子。
這么做是可以的。
: Person(Name, Age, Gender)
這一部分。這個是記錄的派生的固定語法。你要派生,就必須為基類型的主構造器傳入對應的參數(shù)信息。
記錄類型派生后的底層代碼
那么既然派生出來的新的類型,我們就得對派生的記錄類型的底層說明一下底層的代碼生成的樣子都有什么區(qū)別。
實際上,也沒有什么特別大的區(qū)別,只是你想想看,因為它是從基類型派生下來的,所以 virtual
派生下來的是不是得改成 override
修飾符了?所以,這個就是記錄類型在派生后和原本基類型唯一不同的生成的代碼的不同點:virtual
關鍵字被換成了 override
。
另外,從這個角度來說,你看看這個記錄類型是可以提供派生的,所以如果我們沒有編譯器前文的那些限制,你自己合成方法的時候就可以不寫 virtual
,那怎么保證我派生類型是走這個方法派生的呢?這不就是出現(xiàn)了語法的問題和沖突了么?所以,virtual
在合成方法里是不可少的。
而除了這一點,我們還有 ToString
這些跟自身類型有關的代碼生成。
Student
記錄類型,讓它看起來不那么像基類型 Person
猜猜看,我要是調用 p.ToString
會輸出什么東西來?請選擇:
A.
Student { Name = Sunnie, Age = 25, Gender = Boy, Class = 3 }
B.
Person { Name = Sunnie, Age = 25, Gender = Boy }
答案是 A。按照道理來說,Person
和 Student
類型里均包含 ToString
方法,不過因為繼承關系,Student
是重寫的 Person
類型里的方法。因此,現(xiàn)在這個結果是包含四個數(shù)據(jù)數(shù)值的;雖然此時 p
自己是 Person
類型,但 ToString
已經(jīng)被重寫掉,所以顯示內容仍然不應該是選項 B 的結果。你答對了嗎?
其它的成員在底層代碼的生成里也都基本類似,就不再贅述了。不過這里說一下 Clone
這個方法,稍微有點特殊。
Clone
方法在抽象記錄類型里是抽象的。這句話有點繞。換言之,編譯器會對一個 abstract record
生成一個 abstract
修飾的 Clone
方法,此時這個類型就不再可以 Clone
了,也因此,這個類型也無法使用 with
表達式;而別的生成的成員都不受影響,生成的內容也都會按照前文給的那些個內容生成完全一樣規(guī)則的內容。
淺談 record
、sealed record
和 abstract record
的異同點
我們先來說一下 record
、sealed record
和 abstract record
的異同點。
sealed record
和 record
最為相似,不過區(qū)別在于 sealed
修飾的記錄類型不可提供給別的類型派生。因此,sealed
修飾過的記錄類型,里面的所有原本是 protected virtual
或 protected
的成員,全部在生成的代碼里,被改成 private
修飾,這是它們唯一的區(qū)別。
而 abstract record
稍微麻煩一點。因為它是抽象的,所以不能實例化,因此 Clone
就是一個典型范例,它就和 record
和 sealed record
的記錄類型生成下來的結果不一致:abstract record
是抽象的 Clone
方法。
而稍微注意一下。這里的 Deconstruct
方法最為特殊,因為這個成員沒有 virtual
、sealed
、override
或者 abstract
之類的修飾符修飾,所以它是一個獨立的個體;在派生后,哪怕參數(shù)個數(shù)一致、類型一致,也不會有別的多余的東西產(chǎn)生,取而代之的是標記了一個 new
方法修飾符到 Deconstruct
方法上以覆蓋原始的解構函數(shù)。比如說這里 abstract record Person
派生了 sealed record Student
類型,它們全部都只包含 string Name
、int Age
和 Gender Gender
三個屬性作為主構造器的成員,那么 Student
派生記錄類型里會這樣生成代碼:
即多一個 new
而已。
其它無關痛癢的記錄類型語法
partial
修飾符修飾記錄類型
如果我們要用 partial
修飾符來修飾記錄類型,是怎么樣用的呢?因為記錄類型會自帶參數(shù)表列構成主構造器,所以我們寫 partial
的話,不需要每個文件都有這樣的主構造器。只需要只有一個主構造器就可以了,別的全部不用寫出來:
比如這樣。
主構造器允許的參數(shù)修飾符
我們把 record
聲明后的這一坨參數(shù)表列叫主構造器,而主構造器怎么說都得是一個構造器(雖然長得更像是方法的參數(shù))。但不管怎么說,構造器包含參數(shù),那么參數(shù)就一定可以包含修飾符。
不論是不是主構造器也好、方法也好、索引器也好、運算符重載也好,它們都會或多或少包含參數(shù)。C# 早期就規(guī)定了,參數(shù)一共可以有 out
、ref
、in
和 params
這四種修飾符修飾參數(shù)本身,而在記錄類型里,修飾符 out
和 ref
是不行的,剩下那倆是可以的。比如假設我把代碼改成這樣:
這樣是可以的。當然,params
也可以,不過這里沒有需要數(shù)組傳入的屬性信息,所以就不舉例說明了。
主構造器上使用特性
C# 甚至允許我們在參數(shù)表列上使用特性,而且可以使用特性目標來固定應用到某個成員上。
主構造器的語法的特殊性,和它綁定在一起的有參數(shù)、底層字段和后臺屬性三個不同的內容。
Name
是不可空的,但是初始值允許為空,只是我們不讓傳參的時候傳入一個 null
我們使用 [param: DisallowNull]
(這個 param:
特性目標可省略)和 [property: DisallowNull]
(這個 property:
不可省略)分別告訴編譯器在生成代碼的時候,不允許參數(shù)和屬性 Name
傳入 null
,但允許它自身初始化的時候保持 null
數(shù)值。
所以,主構造器的參數(shù)綁定的概念有三個,因此有效的特性目標可以是 param
、field
和 property
這三個。
這里稍微注意下,C# 最開始的特性目標語法格式是必須分開的,不是說特性可以中括號里逗號分隔就可以在這里也這么寫。因為這里帶有特性目標,所以特性不可使用逗號寫在一起,即
[param: DisallowNull, property: DisallowNull]
一樣的語法是不正確的,必須分開寫成兩對中括號。
暫時沒有結構類型的 record
record
被翻譯成了類,所以 record
是不支持結構的。在 C# 10 里,record
會被推廣到 record struct
上,但 C# 9 還不行,而且 C# 10 的 record struct
和這里的 record