C# 的防禦性複製(defensive copy)

這篇文章要探討的是 C# 防禦性複製。


背景

比爾淑(Bill Chung)私訊給我,指出《現代 C#》第 4 章有錯誤,如下圖(紅色虛線處是誤植,應刪除):


我修正之後,他接著又提供一個蠻有意思的範例,裡面展示了兩個比較微妙(tricky)的場景:readonly 欄位和函式的 in 參數。於是,我決定把比爾淑提供的範例稍微改寫之後,放進書中的第 4 章,作為補充。

我從這個議題的討論當中獲益不少,特別感謝比爾淑的指正和討論,讓這本書進一步完善。

以下是正文,摘自《現代 C#》第 4 章。


當你在結構中混合使用 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 成員時,編譯器就會先建立一份副本,然後在副本上進行呼叫,以保證維持「唯讀語意」。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);   // 也會觸發防禦性複製
    }
}

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

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

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

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

原始碼: DemoDefensiveCopy

延伸閱讀

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

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

如何自行驗證是否發生防禦性複製?

有幾種方式可以自行驗證,但它們能回答的問題不完全一樣。

第一種方式是看編譯器警告。當 readonly 成員呼叫非 readonly 成員時,編譯器通常會發出 CS8656 警告,指出這次呼叫會產生 this 的隱含副本。建議使用 dotnet build -t:Rebuild,確保專案真的重新編譯,才不會因為增量建置而漏看警告。

不過要注意:沒有警告,不代表沒有防禦性複製。 例如透過 readonly 欄位或 in 參數來呼叫非 readonly instance 成員時,就會發生防禦性複製,但編譯器不一定會主動提示。

第二種方式是設計一個「可觀察副作用」的測試。例如在 struct 中放入一個會修改欄位的非 readonly 方法:

public int IncrementAndGet()
{
    _value++;
    return _value;
}

如果你用一般變數連續呼叫兩次,結果會遞增;但若透過 readonly 欄位或 in 參數來呼叫,若兩次都得到相同結果,就表示每次都是在新的副本上操作,原本的狀態並沒有被保留下來。

例如:

var s = new Measurement(10);

Console.WriteLine(s.IncrementAndGet());  // 11
Console.WriteLine(s.IncrementAndGet());  // 12

這裡的兩次呼叫都是作用在同一個區域變數 s 上,因此第二次會延續第一次修改後的狀態。

相對地,若透過 readonly 欄位來呼叫:

var holder = new Holder(new Measurement(10));

Console.WriteLine(holder.Data.IncrementAndGet());  // 11
Console.WriteLine(holder.Data.IncrementAndGet());  // 11

這裡的 Data 是 readonly 欄位,因此每次呼叫 IncrementAndGet() 時,編譯器都會先建立一份副本,再在副本上執行遞增;原本的 holder.Data 並沒有被修改。若改成  in 參數,觀察到的行為也是一樣。

第三種方式是檢查 IL(中介語言)。這是最精準的方法,因為你可以直接看到編譯器是否產生了額外的副本操作。若你想從語言實作層面完全確認,可以使用反組譯工具來查看 IL(例如 SharpLab、ILSpy)。

簡單來說:

  1. 想快速抓出明顯問題:先看 CS8656
  2. 想驗證 readonly 欄位或 in 參數:寫一個有副作用的測試方法來觀察結果。
  3. 想做最精準的確認:看 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'

結語

C# 編譯器為了保證 readonly 語意不被破壞,會在背後默默進行防禦性複製。雖然單次複製的成本微乎其微,但在重視效能或頻繁呼叫的場景中,這往往是一個容易被忽略的效能陷阱。

保持良好的撰寫習慣,記得為所有不會修改結構狀態的方法加上 readonly 修飾詞,不僅能避開這項隱藏成本,也能讓程式碼的設計意圖更加明確。

以上就是《現代 C#》第四章有關「防禦性複製」的全部內容。

Keep learning!


沒有留言:

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