從 C# 9 開始,需要自訂型別的時候,除了類別(class)、結構(struct),現在多了一個選擇:記錄(record)。其主要用途是封裝資料,特別是不可改變的資料(immutable data)。
👉在 GitHub 上閱讀
先來看一個簡單範例:
public record Student
{
public int Id { get; set; }
public string Name { get; set; }
}
建立記錄類型的執行個體時,看起來也和類別的寫法相同:
var stu = new Student { Id=1, Name="Mike" };
stu.Id = 10;
如你所見,原本我們熟悉的類別相關語法,一樣都可以用於 record
。不過,此範例僅只作為暖場熱身,實務上通常不建議這樣寫,因為 record
的主要用途是為了封裝不可變的資料,而此範例卻提供了可隨時修改的屬性。
接著就來看看專門為 record
提供的簡潔語法,以及其他附帶功能。
在比較單純的場合,記錄的宣告可以簡短到一行程式碼就解決:
public record Student (int Id, string Name);
寫在一對括弧裡面的參數列,其中的參數會由編譯器自動建立對應的屬性,而且還會加入一個帶有相同參數列的建構式(稍後會展示)。因此,若使用這種「位置參數」的寫法來宣告記錄類型,在建立執行個體的時候就必須傳入對應之參數:
Student stu1 = new (1, "Mike");
建立執行個體的時候也可以加上初始設定式,但是傳入建構式的參數依然不可少:
Student stu1 = new (1, "Mike") { Id=2 }; // OK!
Student stu2 = new (); // 編譯失敗!
Student stu3 = new () { Id=3 }; // 編譯失敗!
再看一個自訂 record
類型的範例。這次要同時使用位置參數和典型的顯式屬性(explicit property):
public record Student (int Id, string Name)
{
protected int Id { get; init; }
public string Name { get; } = Name;
public int Grade { get; init; }
}
說明:
- 第 3 行改寫了參數列的
Id
屬性,而且把那個屬性的可見範圍限縮為protected
。 - 第 4 行改寫了參數列的
Name
屬性,而且把它改成唯讀屬性。請注意這裡的初始值會由參數列(即建構式)的Name
參數傳入。儘管同一個區塊中出現三個Name
,乍看之下可能產生誤解,但編譯器並不會搞錯。 - 第 5 行只是單純加入一個屬性。
第 3 行和第 4 行等於告訴編譯器:「我要自行定義這兩個屬性的行為。」
記錄可以說是一種特殊的類別。它跟類別一樣可以宣告成抽象記錄(abstract record
),也可以繼承另一個記錄,例如:
record ExStudent(int Id, string Name, int Grade)
: Student(Id, Name);
預設情況下,程式碼經過編譯之後,記錄都是以類別的形式存在。換言之,這些記錄都是參考型別(reference type),而非實質型別(value type)。
從 C# 10 開始,則可以明確宣告一個底層為結構(struct)的記錄。例如:
public record struct Student(...);
以此方式定義的記錄類型便是實質型別,也就不允許繼承了。
編譯器產生的程式碼
了解自訂 record
類型的基本寫法之後,接著來看看編譯器究竟在背後替我們做了哪些事情。藉由了解這些背後的細節,我們會更清楚 C# 的 record
除了新增一些語法之外,還附帶贈送了哪些功能。
再貼一次剛才的範例:
public record Student (int Id, string Name);
僅此一行,編譯器會替我們產生許多程式碼,其中至少增加了 12 個成員(屬性和方法)。底下是經過刪減的版本:
以下幾個觀察重點,可以看出編譯器幫我們做了哪些事情:
- 宣告型別的地方(第 1 行),
record
變成了class
,而且實作了IEquatable<T>
介面,以便比對兩個物件是否相等(稍後會進一步說明)。 - 加入一個基礎建構式:第 6~10 行。
- 加入一個拷貝建構式(copy constructor):第 12~16 行。
- 加入一個特殊命名的
Clone
方法:第 18~19 行。 - 加入一個分解式(deconstructor):第 21 行。
- 加入其他改寫方法,包括:
ToString
、Equals
、GetHashCode
等等。
接著進一步說明記錄的複製以及一些重要方法,包括 ToString
、Equals
、和分解式 Deconstruct
。
複製物件
如前面展示過的,編譯器為 record
類型自動加入的 Clone
方法,其名稱比較特別:<Clone>$
,其內部實作只是單純透過拷貝建構式(copy constructor)來建立一個內容完全相同的副本。
若有需要,我們也可以自行撰寫拷貝建構式。編譯器會優先使用我們提供的拷貝建構式。
<Clone>$
可以在 .NET 中介語言(IL)層次正常運作,但我們寫程式的時候是沒辦法呼叫它的(即便它是個公開方法)。那麼,編譯器什麼時候會替我們呼叫這個方法呢?
請看底下幾種寫法:
var stu1 = new Student(1, "Mike");
var stu2 = stu1; // 不會呼叫 <Clone>$()
var stu3 = stu1 with { }; // 會呼叫 <Clone>$()
var stu4 = stu1 with { Id=2 }; // 會呼叫 <Clone>$()
當我們使用 with
語法來複製記錄的時候,編譯器就會在背後替我們呼叫 <Clone>$
方法。此時會發生兩件事:
- 首先,
<Clone>$
方法透過拷貝建構式來複製出一個新的副本。此複製過程是單純的一對一複製,不會去執行屬性的init
存取子;亦即把來源記錄中的所有屬性和欄位(包括私有欄位)全部複製到新的記錄(副本)。 - 接著會執行
with
關鍵字後面的成員初始設定式(member initializer),以便修改副本的屬性。
如果用程式碼來表現上述步驟,會像這樣:
var stu2 = new Student(stu1); // 使用拷貝建構式來完全複製
stu2.Id = 2; // 更新屬性值
上面兩行程式碼僅只是為了協助理解,實際上是無法通過編譯的,因為 Id
是 init-only 屬性,而且拷貝建構式並非公開方法,外界根本無法呼叫。
這種既能複製一份新記錄、同時又可以修改其屬性值的寫法,有個正式的名稱,叫做 non-destructive mutation(非破壞式變異)。為什麼是「mutation」呢?可以這樣來理解:init-only 屬性只能在初始化物件的過程中賦值,所以這些屬性又稱為「不可變的屬性」,即 immutable properties。現在 C# 提供了 with
加上成員初始設定式的寫法,不僅讓原本的記錄得以維持其不可變性,同時提供了一個窗口讓外界還是有機會能夠修改記錄副本的屬性值。
ToString
方法
這裡用一個小實驗來觀察編譯器替 record
型別改寫的 ToString
方法。首先,分別宣告一個 record
和 class
自訂型別:
public class MyClass
{
public int Id { get; set; }
public int Name { get; set; }
}
public record Student (int Id, string Name);
這裡刻意讓類別 MyClass
和記錄 Student
有完全相同的屬性:Id
和 Name
,以便稍後進行一些小實驗,觀察它們的差異。
接著撰寫程式碼來建立記錄和類別的實體,並分別呼叫它們的 ToString
方法:
MyClass obj = new() { Id=1, Name="Mike" };
Console.WriteLine(obj.ToString());
Student stu = new (1, "Mike");
Console.WriteLine(stu.ToString());
執行結果:
MyClass
Student { Id = 1, Name = Mike }
這樣一比較,就很清楚了:類別的 ToString
預設實作會傳回該類別的名稱;而我們在定義記錄類型 Student
的時候雖然沒有改寫任何方法,但編譯器會在背後代勞,讓改寫的 ToString
方法將整個物件的內容——包括型別名稱、公開屬性的名稱與值——全兜成一個容易閱讀的字串,方便我們隨時觀察或除錯物件的屬性值。這是使用 record
的其中一個好處。
有趣的小細節:編譯器提供的
ToString
方法只會處理公開屬性,對於宣告為internal
、protected
、和private
的屬性都會忽略。
Equals
方法
延續前面的範例,這次要觀察的是:編譯器為 record
類型改寫的 Equals
方法在比較兩個物件是否相等的時候,與一般類別的行為有何差異。
MyClass obj1 = new() { Id=1, Name="Mike" };
MyClass obj2 = new() { Id=1, Name="Mike" };
Console.WriteLine($"兩物件是否相等: {obj1.Equals(obj2)}");
Student stu1 = new (1, "Mike");
Student stu2 = new (1, "Mike");
Console.WriteLine($"兩物件是否相等: {stu1.Equals(stu2)}");
執行結果:
兩物件是否相等: False
兩物件是否相等: True
我們知道,.NET 參考型別所提供的預設 Equals
方法僅只是單純比較兩個變數是否指向同一個物件(即記憶體為址是否相同),所以對第一個執行結果為 False 應該不會感到意外——因為 obj1
和 obj2
各指向不同的實體。
第二個執行結果來自兩個 Student
記錄的比較。雖然這裡的 Student
也是參考型別,而且 stu1
和 stu2
分別指向不同的執行個體,但是 Equals
方法卻傳回 True。這是因為編譯器自動替記錄類型改寫了 Equals
方法,而且只要兩個比較對象的內容完全一樣(所有屬性值皆相等)即視為相等。
討論到這裡,record
類型的第二個好處應該很明顯了:我們不用特別去改寫 Equals
方法,就直接擁有比對兩個物件的內容是否完全相同的能力。這個部分跟實質型別(例如 struct
)的行為是一樣的。
此外,編譯器還有提供 ==
和 !=
運算子的實作。其中的 ==
運算子也是使用剛才介紹的 Equals
方法來比較兩個物件是否相等。所以使用 ==
或 Equals
方法所得到的結果是一樣的。
如果想要進一步了解編譯器改寫的 Equals
方法究竟做了哪些比對工作,我們可以用反組譯工具來觀察編譯後的程式碼。方便起見,這裡一併列出相關程式碼:
class Student : IEquatable<Student>
{
public override bool Equals(object? obj)
{ return Equals(obj as Student); }
// 實作 IEquatable<T> 介面的 Equals 方法
public virtual bool Equals(Student? other)
{ // 比對各屬性是否相等的程式碼(省略) }
protected virtual Type EqualityContract
{ get => typeof(Student); }
}
重點說明:
- 宣告型別的地方(第 1 行),編譯器把
record
變成了class
,而且實作了IEquatable<T>
介面,以便比對兩個物件是否相等。 - 第 3 行是改寫
Object
的Equals
方法,其內部只是去呼叫IEquatable<T>
介面的Equals
方法(第 7 行)。
為了避免佔據太長的版面,這裡省略了 IEquatable<T>.Equals
方法的實作細節。你只要知道它會進行以下幾個比對操作就夠了:
- 兩物件的參考是否相等。若相等則傳回 True。
- 兩物件的
EqualityContract
是否相等。從剛才程式碼列表的倒數第 3 行可得知,這裡比較的是兩物件是否為同一個型別。若型別相等,才繼續往下比較。 - 逐一比較兩物件的所有屬性和欄位(包括私有欄位)。若全部相等便傳回 True,否則傳回 False。
了解這些細節之後,對於底下這個小實驗的執行結果應該就不會感到意外了:
// 兩個物件的內容完全相同,但型別不同。
MyClass obj = new() { Id=1, Name="Mike" };
Student stu = new (1, "Mike");
Console.WriteLine(stu.Equals(obj)); // False!
最後一個與 Equals
方法有關的細節是 GetHashCode
方法。這裡就不展示編譯器改寫的方法內容了,我想只要知道這點便已足夠:當兩個物件的內容完全相同(即 Equals
方法傳回 True
),那麼它們的 GetHashCode
方法所回傳的結果必定也是相同的數值。
Deconstruct
方法
延續前面的範例,這次要觀察的是編譯器為 record
類型改寫的 Deconstruct
方法,即分解式(deconstructor)。
class Student : IEquatable<Student>
{
public void Deconstruct(out int Id, out string Name)
{
Id = this.Id;
Name = this.Name;
}
// 其餘省略
}
編譯器自動加入了這個方法,我們就可以輕易將物件的屬性拆解到其他暫時的變數,像這樣:
Student stu = new (1, "Mike");
var (id, name) = stu;
Console.WriteLine($"id={id}, name={name}");
執行結果:
id=1, name=Mike
結語
記錄(record)骨子裡只是特殊的、增強的類別(或結構),其優點如下:
- 可用簡短的語法寫出內含多項常用功能的物件,包括內建的複製操作和
ToString
方法、比較兩個物件的內容是否相同、分解式等等。 - 容易設計出唯讀物件,故特別適合用來封裝不可變的(immutable)資料物件。
- Thread-safe:不可變的物件在多執行緒的應用程式中是安全的,因為物件一旦完成初始化,便沒有任何程式能夠修改其內部狀態(故不會因為多條執行緒交錯執行而產生相互「踩踏」的情形)。
- 同樣是唯讀物件的好處:編譯時期便可確保物件內容不被修改,可減少一些意外或 bugs。
沒有留言: