這篇文章要探討的是 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 實際上沒有修改任何欄位,編譯器仍會:
- 複製一份
Point結構。 - 在副本上呼叫
LogState。 - 丟棄這個副本(因為
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 小節提到:在
readonlyinstance 成員中,如果呼叫了非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)。
簡單來說:
- 想快速抓出明顯問題:先看
CS8656。 - 想驗證
readonly欄位或in參數:寫一個有副作用的測試方法來觀察結果。 - 想做最精準的確認:看 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!
沒有留言: