C# 9:Record 詳解

 從 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. 宣告型別的地方(第 1 行),record 變成了 class,而且實作了 IEquatable<T> 介面,以便比對兩個物件是否相等(稍後會進一步說明)。
  2. 加入一個基礎建構式:第 6~10 行。
  3. 加入一個拷貝建構式(copy constructor):第 12~16 行。
  4. 加入一個特殊命名的 Clone 方法:第 18~19 行。
  5. 加入一個分解式(deconstructor):第 21 行。
  6. 加入其他改寫方法,包括:ToStringEqualsGetHashCode 等等。

接著進一步說明記錄的複製以及一些重要方法,包括 ToStringEquals、和分解式 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>$ 方法。此時會發生兩件事:

  1. 首先,<Clone>$ 方法透過拷貝建構式來複製出一個新的副本。此複製過程是單純的一對一複製,不會去執行屬性的 init 存取子;亦即把來源記錄中的所有屬性和欄位(包括私有欄位)全部複製到新的記錄(副本)。
  2. 接著會執行 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 }

試試看:https://dotnetfiddle.net/YZZkYG

這樣一比較,就很清楚了:類別的 ToString 預設實作會傳回該類別的名稱;而我們在定義記錄類型 Student 的時候雖然沒有改寫任何方法,但編譯器會在背後代勞,讓改寫的 ToString 方法將整個物件的內容——包括型別名稱、公開屬性的名稱與值——全兜成一個容易閱讀的字串,方便我們隨時觀察或除錯物件的屬性值。這是使用 record 的其中一個好處。

有趣的小細節:編譯器提供的 ToString 方法只會處理公開屬性,對於宣告為 internalprotected、和 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

試試看:https://dotnetfiddle.net/mylPZ5

我們知道,.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. 宣告型別的地方(第 1 行),編譯器把 record 變成了 class,而且實作了 IEquatable<T> 介面,以便比對兩個物件是否相等。
  2. 第 3 行是改寫 Object 的 Equals 方法,其內部只是去呼叫 IEquatable<T> 介面的 Equals 方法(第 7 行)。

為了避免佔據太長的版面,這裡省略了 IEquatable<T>.Equals 方法的實作細節。你只要知道它會進行以下幾個比對操作就夠了:

  1. 兩物件的參考是否相等。若相等則傳回 True。
  2. 兩物件的 EqualityContract 是否相等。從剛才程式碼列表的倒數第 3 行可得知,這裡比較的是兩物件是否為同一個型別。若型別相等,才繼續往下比較。
  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

試試看:https://dotnetfiddle.net/hl8er6

結語

記錄(record)骨子裡只是特殊的、增強的類別(或結構),其優點如下:

  • 可用簡短的語法寫出內含多項常用功能的物件,包括內建的複製操作和 ToString 方法、比較兩個物件的內容是否相同、分解式等等。
  • 容易設計出唯讀物件,故特別適合用來封裝不可變的(immutable)資料物件。
  • Thread-safe:不可變的物件在多執行緒的應用程式中是安全的,因為物件一旦完成初始化,便沒有任何程式能夠修改其內部狀態(故不會因為多條執行緒交錯執行而產生相互「踩踏」的情形)。
  • 同樣是唯讀物件的好處:編譯時期便可確保物件內容不被修改,可減少一些意外或 bugs。

沒有留言:

技術提供:Blogger.
回頂端⬆️