第 4 章:不可變設計

最近更新日期

2026-09-08

軟體開發有個看似矛盾的真理:讓資料難以改變,反而能讓程式更好維護。

在傳統的物件導向程式設計中,我們習慣變數就是「可以變」的。但這種彈性往往也是 bug 的溫床:當一個物件的狀態可能被任何程式碼修改時,追蹤問題就會變得格外困難。

試想,你把一份文件放在公司的共用資料夾,任何人看到都可以隨手改幾個字,最後你還能相信這份文件的內容嗎?這就是「可變共享狀態」(mutable shared state)帶來的混亂。

不可變設計(immutable design)的核心思想就是反其道而行:物件一旦建立,就不允許修改。如果需要改變,那就建立一個新的。

本章會介紹 C# 如何支援這種設計模式:先從最基本的 struct 與 class 選擇談起,再進一步看現代 C# 為 immutable design 提供的 record 型別與 init 存取子。

4.1 為什麼需要不可變設計?

在深入語法之前,先來了解可變狀態可能導致哪些問題,而不可變的設計能夠解決什麼問題。

考慮底下這個看似無害的程式碼:

public class Customer
{
    public string Name { get; set; }
    public decimal Balance { get; set; }
}

public void ProcessPayment(Customer customer)
{
    customer.Balance -= 100;  // 直接修改傳入的物件
}

這段程式碼有幾個潛在問題:

  1. 副作用不明確:呼叫 ProcessPayment 會修改傳入的 customer 物件,但從函式簽章看不出來。呼叫端若沒預期到這個副作用,就可能在後續流程中讀到被改過的資料,進而產生 bug。
  2. 難以追蹤:如果 Balance 出錯,你需要搜尋整個程式庫來找出所有可能修改它的地方。
  3. 多執行緒不安全:多個執行緒同時修改同一個物件會導致競爭狀況(race condition)。

不可變設計的好處

不可變設計透過「一旦建立就不能修改」的原則,帶來以下好處:

  • 可預測性:物件的狀態不會在背後偷偷改變,程式碼行為更容易預測。
  • 執行緒安全:不可變物件天生就是執行緒安全的,因為沒有「修改」這個動作。
  • 無副作用:函式不會修改傳入的參數,避免隱藏的副作用。
  • 更好的快取:不可變物件可以安全地被快取和重用,因為內容永遠不變,多個地方可以共享同一個實例而不必擔心被修改(例如字串池、組態物件等)。

補充說明

  • 字串池(string pool/interning):.NET runtime(CLR)會自動將相同的字串字面值(literals,如 "Hello")指向同一個記憶體位置,避免重複配置。例如程式中的所有 "Hello" 都共享同一個實例。參閱:System.String.Intern 方法。
  • 組態物件(configuration object):應用程式的設定資料(如連線字串、API 端點等)。因為不可變,可以安全地在整個應用程式中共享單一實例。參閱:Options pattern in .NET。

4.2 struct vs. class:選擇正確的型別

在 C# 中,struct 和 class 是兩種基本的使用者自訂型別;前者為實值型別(value type),後者為參考型別(reference type)。雖然它們看起來很像——都能定義屬性、方法和建構式——但它們在記憶體中的行為卻天差地遠。搞清楚這點,是設計良好型別的第一步。

實值型別 vs. 參考型別

實值型別與參考型別的一個核心差異在於記憶體行為。且看以下範例:

// struct 是實值型別
struct Point
{
    public int X;
    public int Y;
}

// class 是參考型別
class Customer
{
    public string Name { get; set; }
}

這裡用 struct 定義了一個實值型別 Point,以及用 class 定義了一個參考型別 Customer。接著看如何使用它們:

// 實值型別的行為:複製完整內容
Point p1 = new Point { X = 10, Y = 20 };
Point p2 = p1;  // p2 是 p1 的完整副本
p2.X = 100;     // 只有 p2 被修改
Console.WriteLine(p1.X);  // 輸出 10(p1 沒變)

// 參考型別的行為:複製參考
Customer c1 = new Customer { Name = "Alice" };
Customer c2 = c1;  // c2 和 c1 指向同一個物件
c2.Name = "Bob";   // 修改的是同一個物件
Console.WriteLine(c1.Name);  // 輸出 "Bob"(c1 也變了!)

這個範例清楚展示了兩者的差異:

  • 實值型別(struct)就像「名片」:你給朋友一張名片,他在上面亂塗鴉,並不會影響你皮夾裡的那張名片。你們各自擁有獨立的副本,互不干擾。
  • 參考型別(class)就像「Google Doc 連結」:你把 Google Doc 的連結分享給朋友,他在文件裡打了一行字,你重新整理頁面也會看到那行字。你們共享同一個內容,可能互相干擾。

何時使用 struct?

建議在以下情況使用 struct:

  1. 小型資料結構:如座標點、RGB 顏色、日期時間範圍,或字典中的鍵值組(key-value pair)等。
  2. 不可變:建立後不會修改其狀態。
  3. 不需要多型:不需要繼承其他類別。

以下範例展示一個典型的應用場景:

public readonly struct Money
{
    public decimal Amount { get; }
    public string Currency { get; }

    public Money(decimal amount, string currency)
    {
        Amount = amount;
        Currency = currency;
    }

    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new InvalidOperationException("幣別不同,無法相加");

        return new Money(Amount + other.Amount, Currency);
    }
}

這個 Money 結構代表一個不可變的貨幣金額,包含兩個核心資訊:金額(Amount)和幣別(Currency)。這種設計能夠確保貨幣計算的安全性——例如 Add 方法會檢查幣別是否相同,避免誤將美元和台幣直接相加。

注意第 1 行的 readonly struct 宣告——在這個 Money 範例中,它能確保結構的狀態在建構後不再改變。Add 方法會傳回一個新的 Money 實例,而不是修改現有的。不過要留意:readonly struct 保證的是欄位本身不能再被重新指派;若欄位型別本身是可變的參考型別,仍然不代表深層不可變(deep immutability)。例如:

public readonly struct Order
{
    public List<string> Items { get; }

    public Order(List<string> items) => Items = items;
}

var order = new Order(new List<string> { "A" });
order.Items.Add("B");  // 編譯沒問題!Items 欄位的參考沒變,但內容被修改了

readonly struct 防止你把 Items 指向另一個 List<string>,但無法阻止你對這個 List<string> 本身呼叫 Add。若需要真正的深層不可變,應使用不可變集合(見本章 4.9 節)。

何時使用 class?

當你的型別不符合 struct 的使用條件時,就應該使用 class:

  1. 需要繼承:class 支援類別繼承。
  2. 實例較大:內容包含較多欄位或複雜的資料結構,使用 class 可避免大量複製的效能開銷。
  3. 需要參考語意:多個變數需要指向同一個實例。
  4. 生命週期管理:需要追蹤物件的建立與銷毀。

常見錯誤:可變的 struct

一種常見的不良設計是把結構設計成允許修改。例如底下的反面教材:

// ✘ 不建議這樣設計:可變的 struct
public struct MutablePoint
{
    public int X;
    public int Y;

    public void Move(int dx, int dy)
    {
        X += dx;
        Y += dy;
    }
}

這是一個典型的反模式(anti-pattern)。當你習慣了 struct 是實值型別時,這種可變性會導致令人困惑的行為,或帶來意想不到的後果。如以下範例:

MutablePoint p = new MutablePoint { X = 10, Y = 20 };
p.Move(5, 5);  // p 變成 (15, 25)

// 但是...
MutablePoint[] points = new MutablePoint[1];
points[0] = new MutablePoint { X = 10, Y = 20 };
points[0].Move(5, 5);  // 對於陣列,這行會成功修改元素
Console.WriteLine($"{points[0].X}, {points[0].Y}"); // "15, 25"

// 但是...更令人困惑的情況
IList<MutablePoint> list =
    new List<MutablePoint> { new MutablePoint { X = 10, Y = 20 } };
list[0].Move(5, 5);  // 修改了「複本」後隨即遺失
Console.WriteLine($"{list[0].X}, {list[0].Y}"); // "10, 20"

對於陣列,直接對索引存取的元素呼叫方法是有效的,因為陣列的索引子在 C# 語言規格中是「可取址的變數」(但如果陣列被傳遞出去,這種副作用可能很危險)。可是在透過 IList<T> 介面來存取泛型串列的情況,索引子回傳的是 struct 的複本,所以 Move 方法修改的只是一個暫時的複本,原本在 list 中的資料並未被改變。這就是所謂的「靜默失敗」(silent failure),是最難除錯的一類問題。

原始碼: DemoMutablePoint

還有一種情況可能也令人困惑:

// ...(延續前面範例,省略重複的程式碼)
points[0].X = 99;  // 編譯和執行都沒問題

list[0].X = 99; // 編譯失敗!編譯錯誤訊息如下:
// 無法修改 'IList<MutablePoint>.this[int]' 的傳回值,因為其非變數

為了避免這類問題,實務上通常建議把 struct 設計成不可變。使用 readonly struct 可以讓編譯器幫你確保這一點。

readonly struct:編譯器強制的不可變性

readonly struct 可以確保結構中的所有欄位都是唯讀的,這不僅能清楚表達設計意圖,還能讓編譯器進行更多優化:

readonly struct Point
{
    public readonly int X;
    public readonly int Y;

    public Point(int x, int y)
    {
        X = x;
        Y = y;
    }
}

如果你試圖在 readonly struct 中定義可變欄位或寫入欄位的方法,編譯器會產生錯誤。你也可以只將特定的方法標記為 readonly,表示該方法不會修改任何欄位:

struct Point
{
    public int X;
    public int Y;

    // readonly 方法保證不會修改欄位
    public readonly double GetDistance()
    {
        return Math.Sqrt(X * X + Y * Y);
    }

    // 這個方法可以修改欄位
    public void Reset()
    {
        X = Y = 0;
    }
}

原始碼: DemoReadonlyStruct

相關微軟文件:The struct type、 readonly (C# reference)

到這裡,設計一個唯讀結構看起來相當直觀。不過,這裡還有個隱藏的效能陷阱:如果不了解編譯器的行為,很容易在不知不覺中付出額外成本。

閱讀建議

掌握了上述 struct 與 class 的語意差異、可變結構陷阱以及 readonly struct 之後,你已經具備設計一般不可變資料型別的核心基礎。 接下來要討論的「防禦性複製」與「ref struct」,屬於進階的底層效能與記憶體機制。若你的主要目標是為業務邏輯、DTO 或領域模型建立不可變物件,可以直接跳讀 4.3 record:簡化資料型別的定義;日後需要調校極致效能或深入理解 Span 時再回頭研讀即可。

防禦性複製:隱藏的效能陷阱

當你在結構中混合使用 readonly 和「非 readonly」方法時,可能會觸發編譯器的防禦性複製(defensive copy)行為。這是一個重要但容易被忽略的效能問題。

什麼是防禦性複製?

當一個 readonly 方法呼叫非 readonly 方法時,編譯器無法確定被呼叫的方法是否會修改結構的狀態。為了保證 readonly 方法的承諾(不修改結構內容),編譯器會採取保險措施:先複製一份結構的副本(防禦性複製),然後在這個副本上呼叫非 readonly 方法。

這就像是你去圖書館借閱珍本古籍(readonly),卻需要在上面畫重點(呼叫方法)。館員為了保護古籍,先影印一份副本給你(防禦性複製)。你實際標記的是副本(修改狀態),最後離開時副本被丟棄。

結果是:古籍確實沒事(安全),但多花了影印的時間和紙張(效能損耗)。

參考以下範例:

struct Point
{
    public int X;
    public int Y;

    // readonly 方法
    public readonly void PrintInfo()
    {
        Console.WriteLine($"Point: ({X}, {Y})");
        LogState();  // 這裡會觸發防禦性複製,且通常有編譯警告:CS8656
    }

    // 非 readonly 方法(即使實際上沒有修改狀態)
    private void LogState()
    {
        Console.WriteLine($"Current state: X={X}, Y={Y}");
    }
}

在 PrintInfo 方法中呼叫 LogState 時,即使 LogState 實際上沒有修改任何欄位,編譯器仍會:

  1. 複製一份 Point 結構。
  2. 在副本上呼叫 LogState。
  3. 丟棄這個副本(因為 PrintInfo 是 readonly,不能保留修改)。

原始碼: DemoReadonlyMethod

補充:readonly 欄位與 in 參數

防禦性複製不只會發生在「readonly 方法呼叫非 readonly 方法時」。

更精確地說,當一個 struct 執行個體處於唯讀脈絡中,而你又呼叫它的非 readonly instance 成員時,編譯器就會先建立一份副本,然後在副本上進行呼叫,以保證維持「唯讀語意」。

所謂「唯讀脈絡」,是指編譯器在靜態分析時就能確認某個執行個體的位置不允許被重新賦值的情況。具體來說,只要你無法對該位置執行 myStruct = newValue; 這樣的賦值,它就處於唯讀脈絡中。readonly 欄位和 in 參數,就是這種唯讀脈絡的常見例子。

例如底下這個範例,Value 雖然看起來只是讀取資料,但讀取 property 本質上也是在呼叫 instance 成員。如果它沒有標示為 readonly,那麼透過 readonly 欄位或 in 參數來存取它時,都會觸發防禦性複製:

public struct Measurement
{
    private int _value;

    // 未標示 readonly 的 getter
    public int Value
    {
        get { return _value; }
    }

    public Measurement(int value)
    {
        _value = value;
    }
}

public class Holder
{
    public readonly Measurement Data;

    public Holder(Measurement data)
    {
        Data = data;   // 一般值複製,不算防禦性複製
    }

    public void Exec()
    {
        var value = Data.Value;   // 會觸發防禦性複製
    }

    public static void Exec(in Measurement data)
    {
        Console.WriteLine(data.Value);   // 也會觸發防禦性複製
    }
}

原始碼: DemoDefensiveCopy

在這個例子裡,Holder 建構式的參數 data 是一般的「以值傳遞」(by-value)參數,而 Data = data; 也只是一般的值複製;它們都不是為了保護 readonly 語意而額外產生的暫時副本,因此不算防禦性複製。

如果把 Value 的 getter 明確標示為 readonly,像這樣:

    public readonly int Value
    {
        get { return _value; }
    }

或者乾脆把整個 Measurement 宣告成 readonly struct,就能避免在此過程當中觸發編譯器的防禦性複製。

延伸閱讀

微軟文件 Structure types (C# reference) 的 readonly instance members 小節提到:在 readonly instance 成員中,如果呼叫了非 readonly 成員,編譯器會先建立結構的副本,再在副本上呼叫該成員,因此原始執行個體不會被修改。

這段話說明了防禦性複製的核心機制,而 readonly 欄位與 in 參數則是同樣原理在其他唯讀脈絡中的表現。

如何驗證防禦性複製?

  1. 編譯器警告:當 readonly 成員內部呼叫非 readonly 成員時,編譯器常發出 CS8656 診斷警告(建議使用 dotnet build -t:Rebuild 避免增量建置略過)。但請注意:沒有警告不代表沒有複製,例如透過 readonly 欄位或 in 參數間接呼叫非 readonly 成員時,編譯器目前不一定發出警告。
  2. 副作用觀察:在 struct 中設計遞增欄位的方法,若透過 readonly 欄位多次呼叫皆回傳相同初始值,即證明每次都在新的防禦性複本上執行。
  3. IL 反組譯檢查:透過 SharpLab 或 ILSpy 觀察編譯後的 IL,可最精確地確認編譯器是否產生了額外的區域複本配置指令。

效能影響

如果結構較大或這個方法被頻繁呼叫,防禦性複製的成本會累積:

struct LargeStruct
{
    public int Field1;
    public int Field2;
    // ... 假設還有許多欄位

    public readonly void Process()
    {
        // 每次呼叫 Helper 都會複製整個結構!
        Helper();
    }

    private void Helper()  // 忘記加 readonly
    {
        Console.WriteLine("Processing...");
    }
}

試想,如果 LargeStruct 包含大量欄位,每次呼叫 Helper 都會隱含地複製整個結構,而這個複製操作完全沒有必要,等於是純粹的效能浪費。

請注意:這裡刻意使用一般 struct,而不是 readonly struct。如果整個型別宣告為 readonly struct,那麼它的 instance 成員(建構式除外)都會隱含視為 readonly,上述 Helper 範例就不會觸發這種防禦性複製。

如何避免?

解決方案很簡單:將不會修改狀態的方法明確標記為 readonly:

struct Point
{
    public int X;
    public int Y;

    public readonly void PrintInfo()
    {
        Console.WriteLine($"Point: ({X}, {Y})");
        LogState();  // ✔ 不再觸發防禦性複製
    }

    // 明確標記為 readonly
    private readonly void LogState()
    {
        Console.WriteLine($"Current state: X={X}, Y={Y}");
    }
}

這樣一來,結構中的整條方法呼叫鏈都是 readonly 方法,中間就不會衍生隱藏的「防禦性複製」成本。

最佳實務

對於所有不修改結構狀態的方法,都應該明確加上 readonly 修飾詞。這不僅能避免防禦性複製,還能讓程式碼的意圖更清晰。

相關微軟文件:readonly (C# reference)、 IDE0251: Member can be made ‘readonly’

ref struct:只能存在於堆疊上的特殊結構

C# 還有一種特殊的 ref struct。它的核心特徵是 stack-only:其實體只能配置於堆疊(stack)上,編譯器嚴格禁止它逃逸至託管堆積(managed heap)(例如不能作為類別欄位、不能裝箱、不能跨越 await 邊界)。

在日常業務開發的不可變領域模型中,我們通常使用 readonly struct 或即將介紹的 record 就已足夠;ref struct 主要是為了支援極致效能的零配置操作(如 Span<T>、ReadOnlySpan<T>)。

有關 ref struct 的完整語言限制、零配置切片機制與記憶體安全運作原理,請直接參閱〈第 13 章:高效能記憶體操作〉的 13.4 節。

4.3 record:簡化資料型別的定義

有些型別的主要責任是保存和傳遞資料,例如 DTO、領域事件和應用程式設定。使用這類型別時,我們通常關心的是「資料內容是否相同」,而不是「兩個變數是否指向同一個物件」。除此之外,我們也希望物件能顯示有意義的內容,並能方便地建立一份只改動部分資料的副本。

如果使用一般的 class,這些功能往往需要自行實作,例如覆寫 Equals、GetHashCode 和 ToString,以及另外撰寫解構或複製物件的程式碼。每一項都不算困難,但合在一起便會產生不少重複的樣板程式碼。

C# 9 引入的 record,就是為了簡化這類以資料為核心(data-centric)的型別設計。接下來會先介紹 record 的基本宣告與編譯器提供的功能,再說明它的值相等性,最後比較 record class 和 record struct 的使用時機。

record 宣告語法

定義 record 的語法很簡潔:

// 主要建構式語法(primary constructor)
public record Person(string Name, int Age);

這種把參數直接寫在型別名稱後面的形式,稱為位置式 record(positional record)。就這麼一行,編譯器便會根據 Name 和 Age 產生多個實用成員,包括:

  • Name 和 Age 對應的 init 屬性。(init 存取子的介紹請參閱稍後的 4.4 節。)
  • 編譯器產生的值相等性相關成員,例如 Equals、GetHashCode,以及 ==、!= 運算子。
  • 有意義的 ToString 輸出,例如 Person { Name = Alice, Age = 30 }。
  • Deconstruct 方法,支援解構語法。
  • 支援 with 運算式所需的編譯器產生成員(詳見 4.5 節;record class 與 record struct 的內部機制略有不同)。

何謂主要建構式?

主要建構式(primary constructor)會把建構式參數直接寫在型別名稱後面。這項語法最初在 C# 9 隨著 record 引入,後來在 C# 12 擴展到一般的 class 和 struct。

對一般的 class 或 struct 而言,主要建構式參數不會自動變成欄位或屬性;對位置式 record 而言,編譯器則會產生對應的 public 屬性。這正是上例能以一行程式碼定義 Name 和 Age 的原因。

以下範例展示了 record 的幾個方便功能:

// 1. 具有 init 存取子的屬性
var person = new Person("Alice", 30);
// person.Name = "Bob";  // ✘ 編譯錯誤:init 屬性只能在初始化時設定

// 2. ToString 輸出更有意義
Console.WriteLine(person);
// 輸出:Person { Name = Alice, Age = 30 }

// 3. 值相等性:資料內容相同便視為相等
var person2 = new Person("Alice", 30);
Console.WriteLine(person == person2);  // True(內容相同)

// 4. Deconstruct 解構語法
var (name, age) = person;
Console.WriteLine($"{name} is {age} years old");  // Alice is 30 years old

// 5. 支援 with 運算式(詳見 4.5 節)
var olderPerson = person with { Age = 31 };
Console.WriteLine(olderPerson);  // Person { Name = Alice, Age = 31 }

如果用傳統的 class 來寫同樣的功能,通常需要多出許多樣板程式碼(boilerplate code)。

Note

record 的核心是「以資料為中心的語言支援」,本身並不保證物件不可變。上面的 Person 採用位置式 record class 宣告,所以 Name 和 Age 會產生為 init 屬性;如果改在 record 本體中宣告一般的 set 屬性,仍然可以建立可變的 record。

此外,init 限制的是屬性在初始化後不能被重新指派。若屬性參考的是可變物件,該物件的內容仍然可能被修改。4.4 節會進一步說明 init 存取子。

值相等性

前面列出的功能中,最值得先掌握的是值相等性(value equality)。以下範例展示了一般 class 和 record 的行為差異:

// class 使用參考相等
class PersonClass
{
    public string Name { get; set; }
    public int Age { get; set; }
}

var p1 = new PersonClass { Name = "Alice", Age = 30 };
var p2 = new PersonClass { Name = "Alice", Age = 30 };
Console.WriteLine(p1 == p2);  // False(不同的實例)

// record 使用值相等
record PersonRecord(string Name, int Age);

var r1 = new PersonRecord("Alice", 30);
var r2 = new PersonRecord("Alice", 30);
Console.WriteLine(r1 == r2);  // True(相同的值)

關鍵差異:

  • class 的預設行為:p1 == p2 比較的是參考位址。雖然 p1 和 p2 的內容完全相同,但它們是記憶體中的不同實例,所以結果是 False。
  • record 的預設行為:編譯器會自動替 record 產生符合 C# 相等性合約的完整成員,包含 IEquatable<T>、覆寫 Object.Equals、GetHashCode 以及 == / != 運算子。

Note:非遞迴的淺層值相等

record 的值相等性並非「遞迴式的深層比較」。若成員包含陣列、可變串列或其他參考型別,它依然依循該成員本身的相等性邏輯(例如兩個各別建立的陣列,即使內容相同,預設比較仍為 False)。

若需要自訂 record 的相等性邏輯(例如比對時忽略特定欄位),可定義 virtual bool Equals(PersonRecord? other) 方法,編譯器便會採用你的自訂實作;此時務必同時覆寫 GetHashCode(),確保「若 Equals 為 true,則雜湊碼必須一致」,否則放入 HashSet 或 Dictionary 時將無法正常檢索。

原始碼: DemoRecord

對於以資料為核心的型別,record 的比較行為通常更符合需求:兩個實例是否相等,取決於其資料成員各自的相等性規則,而不是物件位址。

這個特性讓 record 適合用於:

  • DTO(Data Transfer Object):在不同層級間傳遞資料
  • 領域事件(domain events):表示系統中發生的事情
  • 設定類別:應用程式的組態設定

record class vs. record struct

前面的 Person 示範了最常見的 record class。從 C# 10 開始,record 也可以宣告成實值型別的 record struct。因此,你可以根據資料大小、複製成本和使用方式,選擇適合的型別:

// record class(預設,等同於 record)
public record class PersonRecord(string Name, int Age);

// record struct(實值型別的 record)
public record struct Point(int X, int Y);

// readonly record struct(限制欄位與屬性重新指派)
public readonly record struct ImmutablePoint(int X, int Y);

record class 是預設行為,亦即當你寫 record MyRecord; 時,它實際上就是一個 record class。

使用位置式語法宣告 record class 時,主要建構式參數預設會產生 init-only 屬性,只能在初始化時設定,之後就不能重新指派:

// 位置式 record class:主要建構式參數產生 init-only 屬性
public record class PersonClass(string Name, int Age);

var p1 = new PersonClass("Alice", 30);
p1.Name = "Bob";  // ✘ 編譯錯誤!init-only 屬性不能在初始化之後修改

使用位置式語法宣告 record struct 時,主要建構式參數預設會產生 read-write 屬性,因此可以在建立後重新指派:

public record struct PersonStruct(string Name, int Age);

var p2 = new PersonStruct("Alice", 30);
p2.Name = "Bob";  // ✔ 可以修改!但可能不是你想要的

如果你希望限制這些位置式屬性在初始化後被重新指派,可以使用 readonly record struct:

// readonly 位置式 record struct:屬性是 init-only
public readonly record struct PersonStruct(string Name, int Age);

var p3 = new PersonStruct("Alice", 30);
p3.Name = "Bob";  // ✘ 編譯錯誤!

readonly 限制的是欄位與屬性的重新指派。若成員參考的是可變物件,該物件的內容仍然可能被修改。

何時使用 record class?

record class 是參考型別,適合用於:

1. 資料傳輸物件(DTO):在不同層級或服務之間傳遞資料

public record class UserDto(int Id, string Email, string DisplayName);

2. 領域事件(domain events):表示系統中發生的重要事件

public record class OrderPlacedEvent(int OrderId, DateTime PlacedAt,
                                     decimal Total);

3. 較大的資料結構:包含多個屬性或複雜的巢狀結構,不適合頻繁複製

public record class CustomerProfile(
    string Name,
    string Email,
    Address ShippingAddress,
    List<Order> RecentOrders
);

由於是參考型別,多個變數可以指向同一個實例。若採用 init-only 屬性並避免暴露可變的內部物件,便很適合在應用程式內傳遞和共享。

何時使用 record struct?

record struct 是實值型別,適用場景基本上跟 struct 相同(前面已經提過)。例如:

// 小型、獨立的值物件,如座標點、顏色。
public readonly record struct Point(int X, int Y);
public readonly record struct ColorRgb(byte R, byte G, byte B);

// 需要避免堆積配置的場景:高效能計算或頻繁建立/銷毀的資料
public readonly record struct TimeRange(DateTime Start, DateTime End);

// 字典的鍵值對:作為複合鍵使用
public readonly record struct CacheKey(string UserId, string Resource);
var cache = new Dictionary<CacheKey, string>();

選擇建議

選擇 record class 或 record struct 的原則與選擇 class 或 struct 相同:

  • 大多數情況:使用 record class(參考型別),除非有特定效能需求。
  • 小型結構:考慮使用 readonly record struct。
  • 不確定時:從 record class 開始,除非效能分析顯示需要優化。

下表整理了 class、struct、record class(亦即 record)、record struct 的關鍵差異:

特性 class struct record class record struct
型別分類 參考型別 實值型別 參考型別 實值型別
繼承能力 ✔ 支援繼承 ✘ 不支援繼承(可實作介面) ✔ 支援繼承 ✘ 不支援繼承(可實作介面)
預設相等性 參考相等 值相等(Equals/GetHashCode) 值相等(自動生成) 值相等(自動生成)
可變性 取決於成員宣告 預設可變(建議定義為 readonly struct) 取決於成員宣告;位置式屬性預設為 init-only 取決於成員宣告;位置式屬性預設為 read-write
ToString() 預設傳回型別名稱 預設傳回型別名稱 自動生成(顯示資料成員名稱和值) 自動生成(顯示資料成員名稱和值)
with 運算式 無,需手動實作 ✔ 支援(C# 10+) ✔ 自動支援 ✔ 自動支援
解構支援 需手動實作 Deconstruct 需手動實作 Deconstruct 位置式宣告自動支援 位置式宣告自動支援
典型場景 需要繼承、較大物件、需要參考語意 小型資料、高效能需求、避免堆積配置 DTO、領域事件、設定類別、不可變資料 小型值物件、複合鍵

4.4 init 存取子:初始化後不可重新指派

在 C# 9 之前,如果我們希望屬性在物件建立後不能再由外部重新指派,常見做法是使用「get 存取子」(get-only properties),並且在建構式中賦值。但這也意味著我們必須放棄 C# 3.0 就引入的「物件初始設定式(object initializers)」語法糖。這是一個令人兩難的抉擇:要限制重新指派,還是要語法便利性?

C# 9 引入的 init 存取子解決了這個問題,讓我們可以兩者兼得。它提供了一種「只能在初始化時設定」的屬性語法,讓你可以使用物件初始設定式,同時防止屬性在初始化完成後被重新指派。這項限制有助於不可變設計,但若屬性指向可變物件,仍不代表整個物件具有深層不可變性。

Init 存取子語法

比較傳統的寫法與 init 存取子:

// 傳統寫法:建構式參數
public class Person
{
    public string Name { get; }
    public int Age { get; }

    public Person(string name, int age)
    {
        Name = name;
        Age = age;
    }
}

var person = new Person("Alice", 30);  // 必須使用建構式

傳統寫法雖然能防止這些屬性在建立後被重新指派,但失去了物件初始設定式(object initializer)的便利性,且當屬性很多時,建構式的參數列表會變得很長。

使用 init 存取子後:

public class Person
{
    public string Name { get; init; }
    public int Age { get; init; }
}

// 可以使用物件初始設定式,初始化後便不能重新指派屬性
var person = new Person { Name = "Alice", Age = 30 };

// 嘗試之後修改會編譯錯誤
person.Name = "Bob";  // ✘ 編譯錯誤:init 屬性只能在初始化時設定

init 存取子兼顧了「限制屬性重新指派」與「使用物件初始設定式」兩項需求。

這種寫法的好處是:保留了物件初始設定式的靈活性(可以只設定部分屬性、順序無關),同時防止這些屬性在初始化完成後被重新指派。

不過,init 存取子有個小缺口:它無法強制呼叫端一定要設定某些屬性。如果呼叫端漏掉了 Name,物件仍然會被建立,只是屬性會是預設值。C# 11 引入的 required 修飾詞正好補上了這個缺口。

搭配 required 修飾詞(C# 11)

C# 11 引入了 required 修飾詞,讓你可以強制呼叫端必須在初始化時提供某些屬性:

public class Person
{
    public required string Name { get; init; }
    public int Age { get; init; }  // 非必要
}

// 必須提供 Name
var person = new Person { Name = "Alice" };  // ✔ 正確

// 不提供 Name 會編譯錯誤
var invalid = new Person { Age = 30 };  // ✘ 編譯錯誤:Name 是必要的

required 關鍵字確保了物件在建立時,呼叫端必須初始化那些必要成員,避免了「完全沒設定」的半初始化狀態(例如建立了 Person 卻完全沒提供 Name)。

不過要留意:required 保證的是「必須初始化」,不等於「一定不是 null」。若屬性型別是不可為 null 的參考型別,編譯器通常會透過 nullable reference types 提示警告;若要嚴格防止無效值,仍應搭配適當的驗證邏輯。

這個組合(required + init)提供了很實用的折衷:既有初始化時的語言層級檢查,又保留物件初始設定式的便利性。

原始碼: DemoInitAccessor

4.5 with 運算式:非破壞性修改

採用不可變設計時,我們不會直接修改既有物件,而是以它為基礎建立一份帶有新值的副本。with 運算式便提供了這種「複製並修改」的簡潔語法。

以下示範基本用法:

public record Person(string Name, int Age);

var alice = new Person("Alice", 30);

// 使用 with 運算式建立一個新的實例,只修改特定屬性
var olderAlice = alice with { Age = 31 };

Console.WriteLine(alice);       // Person { Name = Alice, Age = 30 }
Console.WriteLine(olderAlice);  // Person { Name = Alice, Age = 31 }

with 運算式的語意是:「建立一個副本,同時設定副本的某些屬性值」。這項操作不會修改原始物件,卻提供了便利的「修改」語法。這種「非破壞性修改」(nondestructive mutation)是函數式程式設計的核心概念之一——我們並沒有破壞原始物件,而是建立了一個新版本。

這就像是影印後修正:你拿著原稿(原始物件)去影印機印一份(建立副本),然後在影印本上改掉電話號碼(修改屬性)。原稿始終保持不變。

修改多個屬性

你可以在一個 with 運算式中修改多個屬性:

var bob = alice with { Name = "Bob", Age = 25 };

語法讀起來就像在說「以 alice 為基礎,但 Name 和 Age 改成新值」,相當直覺。

with 運算式的內部機制

with 運算式在底層包含兩個關鍵階段:

  1. 複製階段:
    • record class:編譯器透過合成的複製建構式建立副本。這是一個欄位層級的淺層複製(含私有與隱藏背後欄位),並繞過 init 存取子,確保完整還原內部狀態。
    • record struct / 一般 struct:直接進行實值複製(value copy),不呼叫複製建構式。
  2. 更新階段:針對花括號內指定的成員呼叫其 init 或 setter 存取子套用新值。因此,被修改的屬性依然會執行驗證邏輯;未指定的成員則直接沿用複製階段的數值。

這個兩階段模型保證了非破壞性修改的效能(無需從頭建構整個物件),同時兼顧了屬性驗證。

Note:淺層複製提醒

with 複製的是物件本身。若成員參考的是外部可變物件(如陣列或一般 List),新舊實例仍會指向同一個集合,對集合內容的改動將互相影響。

Ask AI:

請說明在 C# 中,使用 record 和 with 運算式時,如果成員包含可變參考型別(如 List<T>),該如何達成深層複製的效果?請提供程式範例。

與一般 class 的關係

with 運算式不只可用於 record 型別,一般 struct 也支援 with。不過,對於一般 class,單靠複製建構式或 Clone 方法並不會讓 with 語法可用;如果你需要類似效果,就必須明確呼叫自己提供的複製方法。也請注意:record struct 與一般 struct 的複製基礎都是一般的實值複製,不是 record class 那套隱藏的 clone/copy constructor 機制。

public class Person
{
    public string Name { get; init; } = "";
    public int Age { get; init; }

    public Person() { }

    protected Person(Person original)
    {
        Name = original.Name;
        Age = original.Age;
    }

    public Person Copy(string? name = null, int? age = null)
    {
        return new Person(this)
        {
            Name = name ?? Name,
            Age = age ?? Age
        };
    }
}

var alice = new Person { Name = "Alice", Age = 30 };
var bob = alice.Copy(name: "Bob");  // 一般 class 要明確呼叫複製方法

原始碼: DemoWithExpression

4.6 計算屬性與延遲求值

不可變型別經常需要包含計算屬性(derived/calculated properties),即從其他欄位計算而來的值。一個常見的做法是延遲求值(lazy evaluation),也就是把計算推遲到真正需要其值時才進行;若計算成本較高,通常會進一步搭配快取(memoization)供後續存取直接重用。

以下示範如何定義一個簡單的計算屬性:

public record Point(double X, double Y)
{
    public double DistanceFromOrigin => Math.Sqrt(X * X + Y * Y);
}

這個做法簡潔,但每次存取 DistanceFromOrigin 時都會重新計算,可能造成不必要的效能開銷。

延遲求值與快取

若計算成本較高,可以快取計算結果:

public record Point(double X, double Y)
{
    private double? _distanceCache;

    public double DistanceFromOrigin
    {
        get
        {
            if (_distanceCache == null)
            {
                _distanceCache = Math.Sqrt(X * X + Y * Y);
            }
            return _distanceCache.Value;
        }
    }
}

使用 Null 聯合指派運算子 (??=) 可以讓程式碼更簡潔:

public double DistanceFromOrigin =>
              _distanceCache ??= Math.Sqrt(X * X + Y * Y);

這行程式碼的意思是:如果 _distanceCache 為 null,就計算並賦值;否則直接回傳快取值。

運算子執行順序

_distanceCache ??= expression 的執行順序是:

  1. 檢查 _distanceCache 是否為 null。
  2. 如果是 null,計算右側的 expression 並賦值給 _distanceCache。
  3. 回傳 _distanceCache 的值(可能是原本的值,或剛計算的新值)。

這個運算式會先讀取左側,再視需要計算並指派右側,因此語意上很簡潔;但它不是多執行緒意義下的原子操作,也不等於執行緒安全的延遲初始化(lazy initialization)——在多執行緒情況下,兩個執行緒可能同時讀到 null,各自計算後重複賦值。若計算是純函式(如這裡的 Math.Sqrt),重複計算結果相同,通常無害;但若計算有副作用,就可能出問題。如需真正的執行緒安全延遲初始化,可考慮使用 Lazy<T>。

關於 ??= 運算子的詳細說明,請參閱第三章。

快取失效處理:確保非破壞性修改正確運作

當不可變物件使用 with 運算式建立新版本時,快取必須正確失效。如果直接複製私有欄位,新物件可能會沿用舊的計算結果。

在 record class 中,最簡潔的解法是自訂複製建構式,在複製時不複製私有快取欄位,讓新物件於首次存取時重新計算:

public record Point(double X, double Y)
{
    private double? _distanceCache;
    public double DistanceFromOrigin =>
                  _distanceCache ??= Math.Sqrt(X * X + Y * Y);

    // 自訂複製建構式:只複製 X 與 Y,讓 _distanceCache 預設為 null
    protected Point(Point other) => (X, Y) = other;
}

var p1 = new Point(3, 4);
Console.WriteLine(p1.DistanceFromOrigin);  // 5(計算並快取)

var p2 = p1 with { Y = 5 };
Console.WriteLine(p2.DistanceFromOrigin);  // 5.83...(快取重新計算)

原始碼: DemoLazyEvaluation

進階技巧:使用 init 存取子精確失效

上述複製建構式的缺點是每次 with 都會使快取重置(即使修改的屬性與該計算無關)。若追求極致精確,可搭配 C# 14 的 field 關鍵字,在相關屬性的 init 存取子中明確指派 _distanceCache = null,使快取僅在關鍵成員變更時才失效。

設計原則

在不可變型別中使用延遲求值是安全且常見的優化技巧。雖然技術上我們「修改」了 _distanceCache 欄位,但這不違反不可變性的原則,因為它不影響物件的邏輯狀態。這種模式稱為邏輯不可變性(logical immutability)。

4.7 實戰應用:不可變的領域模型

假設我們正在開發一個訂單系統,使用 record 來定義「不可變的訂單」:

using System.Collections.Immutable;

// 使用 record 定義不可變的訂單項目
public record OrderItem(string ProductName, int Quantity, decimal UnitPrice)
{
    public decimal TotalPrice => Quantity * UnitPrice;
}

// 使用 record 定義不可變的訂單
public record Order(
    int OrderId,
    string CustomerName,
    ImmutableList<OrderItem> Items,
    DateTime CreatedAt)
{
    public decimal TotalAmount => Items.Sum(item => item.TotalPrice);

    // 使用 with 運算式來「修改」訂單
    public Order AddItem(OrderItem item)
    {
        return this with { Items = Items.Add(item) };
    }
}

這裡刻意使用 ImmutableList<OrderItem>,而不是把 List<OrderItem> 包成 IReadOnlyList<OrderItem>。後者只是不提供可寫介面,底層集合仍可能被外部修改;前者才是真正不可變的集合。本章 4.9 節會再詳細介紹。

接著示範如何使用這個不可變的訂單模型:

using System.Collections.Immutable;

var order = new Order(
    OrderId: 1,
    CustomerName: "Alice",
    Items: ImmutableList.Create(
        new OrderItem("Keyboard", 1, 2500)),
    CreatedAt: DateTime.Now
);

// 「修改」訂單(實際上會建立新的訂單物件)
var updatedOrder = order.AddItem(new OrderItem("Mouse", 2, 800));

Console.WriteLine($"原始訂單金額: {order.TotalAmount}");      // 2500
Console.WriteLine($"更新後訂單金額: {updatedOrder.TotalAmount}");  // 4100

在這個範例中,我們完全沒有修改任何既有的物件,而是建立新的物件來代表新的狀態。由於連 Items 也使用真正的不可變集合,因此原始 order 的內容不會因外部還持有集合參考而被偷偷改變。

這種設計的優點:

  1. 歷史追蹤:每次「修改」都會產生新版本,可以輕易實作撤銷/重做
  2. 執行緒安全:不可變物件可以安全地在多執行緒環境中使用
  3. 可預測性:函式不會有副作用,更容易理解和測試

原始碼: DemoOrderModel

封閉狀態建模:Union Types(C# 15)

前面介紹的 Order 與 OrderItem 都是由多個屬性同時組合而成的資料模型,在型別理論中稱為積型別(product types)——它的狀態空間是「屬性 A 的可能值 × 屬性 B 的可能值」。

但在實務領域建模中,我們常遇到另一種完全對立的需求:封閉的互斥狀態。這在型別理論中被稱為和型別(sum types)或判別聯合(discriminated unions)——它的狀態空間是「狀態 A 的可能值 + 狀態 B 的可能值」,且任一時刻只能是其中一種。

舉例來說,訂單的付款方式只可能是「現金」、「信用卡」或「銀行轉帳」,且每種方式攜帶的資訊完全不同:

  • 現金需要找零金額(Amount)
  • 信用卡需要卡號(CardNumber)與授權金額
  • 銀行轉帳需要帳號(AccountNo)與轉帳金額

在 C# 15 之前,模擬這種結構通常只有兩種妥協方式:一是宣告一個包含所有欄位的單一類別,搭配列舉與一堆容易出錯的 null 欄位;二是定義一個抽象基底類別或 record,再讓各狀態去繼承它。後者雖然型別安全,但宣告繁瑣,且除非額外限制,否則在型別系統上始終處於開放繼承狀態。

C# 15 正式引入了原生聯合型別(union types),以極簡的語法解決這個建模痛點:

// (1) 先定義各個互斥狀態的不可變資料載體
public record class Cash(decimal Amount);
public record class CreditCard(string CardNumber, decimal Amount);
public record class BankTransfer(string AccountNo, decimal Amount);

// (2) 使用 C# 15 union 關鍵字宣告封閉的付款方式
public union PaymentMethod(Cash, CreditCard, BankTransfer);
  1. 每個狀態依然是獨立、不可變的 record,各自封裝所屬的欄位與邏輯。
  2. union PaymentMethod(...) 直接宣告 PaymentMethod 只能是括號內列出的三種型別之一,無須手寫抽象基底類別。

在呼叫端,成員型別可以直接隱含轉換為聯合型別:

// 直接指派成員實例,語法自然直觀
PaymentMethod method1 = new Cash(500m);
PaymentMethod method2 = new CreditCard("4111-2222-3333-4444", 1200m);

原始碼: DemoUnionTypes

何時使用繼承?何時使用聯合型別?

在不可變架構設計中,選擇類別繼承階層還是聯合型別,取決於領域狀態的開放性:

  • 使用物件導向繼承(Open Hierarchy):當你的系統是開放式的,預期未來或外部套件可能隨時擴充新的子類別(例如繪圖系統中的圖形基底類別 Shape,任何第三方都可以新增 Triangle)。
  • 使用聯合型別(Closed Hierarchy):當狀態在業務邏輯上是封閉且固定的(例如訂單付款方式、網路請求結果的 Success 與 Failure)。聯合型別能確保不會有未知的第三方型別混入。

延伸:與模式比對的威力結合

聯合型別不僅讓領域模型更精確,更重大的價值在於與模式比對的結合:當你使用 switch 運算式處理 PaymentMethod 時,編譯器能夠確切掌握所有可能的分支,提供編譯期完全窮盡性檢查(exhaustiveness check),不再需要手寫毫無意義的 _ => throw 預設分支!我們將在〈第 6 章:模式比對〉中深入探討這項機制。

AI 協作:重構為不可變設計

剛才展示的是從頭設計不可變模型的方式。實務上,你可能更常需要把既有的類別改成不可變的結構。這類重構牽涉到型別選擇、初始化方式與 API 設計,很適合先請 AI 工具協助整理方向:

Prompt

請將此專案中的核心資料物件重構為不可變的型別。要求:

  1. 使用 record 或 readonly struct(視情況選擇)
  2. 使用 init 存取子
  3. 提供 with 運算式友善的 API
  4. 說明你的設計選擇理由

4.8 手動實作值相等的代價

前文 4.3 節展示了 record 開箱即用的值相等性。相對地,如果你在一般 class 或自訂 struct 中需要實作完整的值相等性,就必須手動滿足 C# 的四重相等性合約:

  1. 實作 IEquatable<T> 介面:提供強型別且避免裝箱的 Equals(T? other) 方法。
  2. 覆寫 Object.Equals(object? obj):確保以非泛型 object 呼叫時維持相同結果。
  3. 覆寫 Object.GetHashCode():確保相等物件產生相同的雜湊碼,這對 Dictionary 與 HashSet 的檢索至關重要。
  4. 多載 == 與 != 運算子:確保使用運算子比對時與 Equals 邏輯一致。

以下是手動實作時必須維護的骨架:

public class Person : IEquatable<Person>
{
    public string Name { get; }
    public int Age { get; }

    public Person(string name, int age) => (Name, Age) = (name, age);

    // 1. 強型別 IEquatable<T>
    public bool Equals(Person? other) =>
        other is not null && (ReferenceEquals(this, other) || (Name == other.Name && Age == other.Age));

    // 2. 覆寫 Object.Equals
    public override bool Equals(object? obj) => Equals(obj as Person);

    // 3. 覆寫 GetHashCode(必須與 Equals 邏輯嚴格一致!)
    public override int GetHashCode() => HashCode.Combine(Name, Age);

    // 4. 運算子多載
    public static bool operator ==(Person? left, Person? right) =>
        left is null ? right is null : left.Equals(right);

    public static bool operator !=(Person? left, Person? right) => !(left == right);
}

漏掉上述任何一個環節,都可能導致隱晦的行為不一致(例如 == 回傳 false 但 Equals 回傳 true,或是放入字典後無法命中)。這正是現代 C# 在領域模型與資料傳遞中強烈推薦優先使用 record 的核心原因:編譯器會自動且正確地為你合成這整套繁複的樣板代碼。

原始碼: DemoEquality

4.9 不可變集合(immutable collections)

到目前為止,我們談的都是單一物件的不可變設計。但如果物件裡面包含了一個串列(List),而這個串列本身卻可以被隨意修改,那前面的努力可能就白費了。

這時候,你就需要 .NET 的不可變集合(immutable collections),例如 ImmutableArray 和 ImmutableList。這些集合並不是類似把 List<T> 轉型成 IReadOnlyList<T> 這麼簡單,而是在底層實作上有著根本的不同。

可變集合的問題

.NET 的可變集合(例如 List<T>)即使你把它放在 readonly 欄位中,或者透過 IReadOnlyList<T> 回傳,底層的資料仍然可以被修改(例如被轉型回 List<T>,或是被多執行緒同時存取)。請看以下範例的說明:

class NumberStore
{
  private readonly List<int> _numbers = new List<int> { 1, 2, 3 };

  public void Demo()
  {
    _numbers.Add(4);  // 可修改!readonly 只保證欄位參考不變,不保證內容不變

    IReadOnlyList<int> readOnlyView = _numbers;
    ((List<int>)readOnlyView).Add(5);  // 可以透過轉型來修改!
  }
}

這裡的 readonly 關鍵字是在確保 _numbers 這個欄位在初始化之後,就不能再被重新指派給另一個 List<int> 物件。亦即 readonly 只保證參考不變,不保證內容不變。

System.Collections.Immutable

.NET 提供了 System.Collections.Immutable 套件,其中包含了一組真正不可變的集合類別,例如 ImmutableList<T>, ImmutableDictionary<TKey, TValue> 等。

這些集合的特點是:任何修改操作都不會改變原始集合,而是回傳一個新的集合。

這就像是拍照(快照):當你對著風景拍了一張照片(建立集合),之後風景不管怎麼變(修改操作),你手上的那張照片永遠是當時的樣子。如果你想要新的風景,就得再拍一張新的照片(回傳新集合)。

範例:

using System.Collections.Immutable;

var list1 = ImmutableList.Create(1, 2, 3);
var list2 = list1.Add(4);

Console.WriteLine(string.Join(", ", list1)); // 1, 2, 3 (不變)
Console.WriteLine(string.Join(", ", list2)); // 1, 2, 3, 4 (新的)

注意 list1 的內容完全沒有改變,這讓我們可以放心地把 list1 傳遞給其他方法,而不必擔心它被修改。

效能考量

由於每次修改都會產生新物件,你可能會擔心效能問題。這些集合內部使用了特殊的資料結構(如 AVL 樹)來實作結構共享(structural sharing),因此在「複製」時並不會真的複製所有元素,而是盡可能共用既有的節點。

儘管如此,相較於可變集合,它們在寫入時的開銷仍然較大。如果你需要進行大量的修改操作(例如在迴圈中初始化一個串列),建議使用 builder 模式:

var builder = ImmutableList.CreateBuilder<int>();
builder.Add(1);
builder.Add(2);
// ... 大量操作 ...
var list = builder.ToImmutable();

使用 Builder 模式可以避免在大量操作時產生過多的中介物件,這是常見的效能優化技巧,而且兼顧了效能與不可變性。

Builder 的運作原理

Builder 的運作方式是:在建構階段使用一般的可變集合,完成後才「凍結」成不可變集合。這樣可以避免在初始化階段每次 Add 都產生新物件,等於把「多次小修改」合併成「一次大轉換」,大幅提升效能。

ImmutableArray vs. ImmutableList

同一套件中,另一個常見的選擇是 ImmutableArray<T>。它的內部實作只是包裝了一個簡單的陣列,因此在記憶體佔用和讀取效能上,ImmutableArray<T> 明顯優於採用樹狀結構的 ImmutableList<T>。

  • ImmutableArray:優異的讀取效能(O(1),連續記憶體存取),但每次修改都需要複製整個陣列(O(N))。適合「建立一次,讀取多次」的場景。
  • ImmutableList:採用樹狀結構並利用結構共享。Add 與按索引刪除的 RemoveAt 為 O(log N);按值刪除的 Remove(value) 則需要先搜尋元素,在單次相等比較為 O(1) 的前提下,最壞為 O(N)。依索引存取為 O(log N),完整列舉為 O(N)。適合需要頻繁修改集合內容、又希望維持不可變語意的場景。

所以,如果你的集合資料大多時候只是用來讀取,建議優先選擇 ImmutableArray<T>。

原始碼: DemoImmutableCollections

除了 System.Collections.Immutable 提供的不可變集合外,如果你的情境是「建立一次後就只需讀取、完全不需要修改」,.NET 8+ 還提供了另一組更好的選擇。

FrozenDictionary 與 FrozenSet

.NET 8 引入了 System.Collections.Frozen 命名空間,其中包含 FrozenDictionary<TKey, TValue> 和 FrozenSet<T>。這兩種型別與 ImmutableDictionary/ImmutableHashSet 的差異在於目標不同:

  • ImmutableDictionary:支援非破壞性修改(每次修改回傳新實例),底層採樹狀結構,新增元素較快,但查詢較慢。
  • FrozenDictionary:建立後完全凍結,不支援任何修改操作。它針對大量查詢最佳化,但建構時需要較高的前期成本;相較於一般 Dictionary 的實際效益,應以資料與存取模式量測。適合「啟動時建立一次、之後大量讀取」的場景,例如組態查找表、關鍵字對應表等。

FrozenSet<T> 則是對應 ImmutableHashSet<T> 的高效唯讀版本,同樣針對查詢效能最佳化。

範例:

using System.Collections.Frozen;

var config = new Dictionary<string, string>
{
    ["timeout"] = "30",
    ["retries"] = "3"
}.ToFrozenDictionary();

// 從建立後不再變更的查找表讀取設定值
string value = config["timeout"];

總結來說,選擇哪種集合取決於你的使用模式:需要頻繁修改並保留歷史版本,用 ImmutableList 或 ImmutableDictionary;資料大多只讀、偶爾修改,用 ImmutableArray;建立後完全不再修改、追求極致查詢效能,用 FrozenDictionary 或 FrozenSet。

本章重點回顧

  • 不可變設計透過限制修改,帶來更可預測、更安全、更容易維護的程式碼。
  • struct vs. class:struct 是實值型別,適合小型、不可變的資料;class 是參考型別,適合需要繼承或較大的資料結構。
  • readonly struct:編譯器強制結構的資料成員維持唯讀,並有助於避免因成員未正確標示 readonly 而導致的防禦性複製。
  • ref struct:只能存在於堆疊上的特殊結構型別,用於高效能場景(如 Span<T>)。
  • record:以資料為核心的型別,提供編譯器產生的值相等性、ToString 與 with 運算式支援,但本身不保證不可變。可選擇 record class(參考型別)或 record struct(實值型別)。
  • init 存取子:讓屬性只能在初始化時設定,結合 required 可確保必要屬性。
  • with 運算式:提供「非破壞性修改」的語法,建立新實例而非修改原始物件。
  • 計算屬性與延遲求值:使用快取來優化計算屬性的效能,這種做法不違反邏輯不可變性。
  • 相等性比較:理解參考相等與值相等的差異,並知道如何正確實作 IEquatable<T> 和 GetHashCode()(或者直接使用 record)。
  • 不可變集合:使用 System.Collections.Immutable 確保集合本身的不可變性,並善用 builder 優化效能。若需要在啟動後大量讀取而不再修改,可改用 System.Collections.Frozen 中的 FrozenDictionary 或 FrozenSet 以獲得更佳查詢效能。
  • 聯合型別(union types,C# 15):原生支援判別聯合與和型別,為互斥封閉狀態建立安全且強型別的不可變領域模型。

C# 早已不只提供傳統的 getters/setters。從 readonly struct 到 record,再到 init 存取子與 union,這些工具讓「不可變」的設計更容易落實。

下次設計新的型別時,不妨試著把「預設不可變」當成優先選項。少掉那些「不知道誰改了我的資料」的除錯時間,就能把時間花在更有價值的事情上。

本章術語

英文 中文 說明
Copy constructor 複製建構式 用於複製物件的特殊建構式;record class 可由編譯器自動產生
Deep copy 深層複製 遞迴複製物件及其所有子物件和集合
Discriminated union 判別聯合 包含多種可能型別之一且具備型別標籤的資料結構
Hash code 雜湊碼 用於雜湊表快速查找的數值
Immutable 不可變的 一旦建立就無法修改的物件
Immutable collection 不可變集合 修改時會回傳新集合的資料結構,如 ImmutableList
init accessor init 存取子 只能在初始化時設定的屬性存取子
Lazy evaluation 延遲求值 將計算推遲到真正需要時才進行;在屬性實作中常進一步搭配快取以避免重複計算
Logical immutability 邏輯不可變性 物件的邏輯狀態不變,但內部可能有快取等優化
Primary constructor 主要建構式 在型別宣告時直接定義參數的建構式語法
Product type 積型別 由多個屬性同時組合而成的複合型別(如 class 或 record)
readonly struct 唯讀結構 所有欄位都是唯讀的結構型別
Record 記錄型別 C# 9 引入、以資料為核心並提供值相等性的型別;本身不保證不可變
ref struct 參考結構 只能存在於堆疊上的特殊結構型別
Reference equality 參考相等 兩個變數指向記憶體中同一個實例時視為相等
required modifier required 修飾詞 C# 11 引入,強制初始化時必須提供的屬性
Shallow copy 淺層複製 只複製物件本身的欄位,不遞迴複製子物件
Structural equality 結構相等 兩個物件的結構與內容完全相同時視為相等
Structural sharing 結構共享 不可變集合內部共用節點以節省記憶體的技術
Sum type 和型別 狀態空間為各可能分支之和的互斥型別(如 union)
Union type 聯合型別 C# 15 引入,宣告一組互斥型別集合的語言特性
Value equality 值相等 根據屬性值判斷相等,而非參考位址
with expression with 運算式 以現有實例為基礎建立副本,並替指定成員設定新值的語法