整理 C# 的 ref struct 的用法和相關細節。
C# 7.2 引入了 ref struct,這是一種特殊的結構,只能存在於堆疊上,不能存在於堆積中。大多數情況下,你不會需要定義自己的 ref struct 型別,但了解這個概念有助於理解 .NET 的高效能 API,例如 Span<T> 和 ReadOnlySpan<T>。
底下是一個簡單的 ref struct 範例:
ref struct StackOnlyPoint
{
public int X;
public int Y;
}
寫法跟一般結構沒有太大差別,只是宣告的時候多加了 ref 關鍵字。
不過,ref struct 有許多嚴格的限制,包括:
- 不能作為類別或一般結構的欄位。
- C# 13 之前不能實作介面;C# 13 開始能實作介面,但無法轉型為介面型別。
- 不能被裝箱(boxing)。
- 不能用於陣列元素。
- 不能在非同步方法中使用。
- 不能在 Lambda 運算式或區域函式中使用(除非該 Lambda/區域函式本身也不會造成堆積配置)。
以下列舉幾個範例來分別展示違反相關限制而導致編譯錯誤的寫法。
(1) 不能作為類別或一般結構的欄位:
ref struct MyRefStruct { }
class MyClass
{
private MyRefStruct field; // ✗ 編譯錯誤
}
(2) 不能用於陣列元素:
ref struct MyRefStruct { }
var array = new MyRefStruct[10]; // ✗ 編譯錯誤
(3) 不能被裝箱(boxing):
ref struct MyRefStruct { }
object obj = new MyRefStruct(); // ✗ 編譯錯誤
(4) 不能在非同步方法中使用:
async Task ProcessAsync()
{
Span<int> span = stackalloc int[10]; // ✗ 編譯錯誤
await Task.Delay(100);
}
另外要留意的是,在 C# 13 之前,ref struct 完全不能實作介面。C# 13 開始雖然可以實作介面,但仍然無法轉型為介面型別(因為轉型會導致裝箱)。
怎麼限制那麼多?
看到這裡,你可能會有疑問:「為什麼 ref struct 有這麼多限制?那麼它到底用在哪裡呢?」
簡單來說,這些限制都是為了確保 ref struct 永遠不會「溜」到堆積上(如本節開頭提過的,它只能放在堆疊裡)。那麼如果它跑到堆積上又如何呢?
接著用一個例子來說明,會比較好理解。
ref struct 應用場景:零配置(Zero-Allocation)處理
前面提過,ref struct 主要是用在需要高效能的場景。其中一個最常見的應用是 Span<T> 與 ReadOnlySpan<T>。沒錯,它們就是以 ref struct 來宣告的:
public readonly ref struct Span<T> { ... }
public readonly ref struct ReadOnlySpan<T> { ... }
舉例來說,當我們要從字串 "X:100,Y:200" 解析出數值時:
- 一般做法:使用
Substring或Split。這會產生多個暫時的字串物件(堆積配置),增加垃圾回收(GC)的壓力。 - 高效能做法:使用
ReadOnlySpan<char>。它可以在不產生任何新字串物件的情況下,直接對原始字串進行「切片」(slice)。
範例:
string input = "X:100,Y:200";
// 傳統做法:產生多個字串物件 (增加 GC 壓力)
string[] parts = input.Split(','); // 配置陣列與字串
// 高效能做法:零配置 (zero allocation)
ReadOnlySpan<char> span = input.AsSpan();
// ... 直接操作 span,完全沒有產生新物件
此範例點出了 Span<T> 和 ReadOnlySpan<T> 的一個特色:它們像是一個「視窗」,可以直接透視既有的記憶體,而不需要複製它。
至於它們為什麼只能存在堆疊(stack),不能出現在堆積(heap),原因如下:
由於 Span<T> 和 ReadOnlySpan<T> 主要是用來「通用地」指向任何記憶體,因此其內部可能包含指向 stack 記憶區塊的指標(例如指向一個透過 stackalloc 配置的暫存陣列)。如果允許這個結構跑到 heap 上,一旦方法返回,堆疊記憶體隨之釋放,那個指標就會變成指向無效位址的「懸空指標」(dangling pointer),這可是會導致嚴重當機的。
把結構放在堆疊的好處
前面解釋了一堆 ref struct 的限制,也大致了解為什麼這種結構必須牢牢鎖在堆疊空間裡,不能讓它們溜進堆積(heap)。那到底為什麼最初會想要把結構放在堆疊呢?
把結構放在堆疊的主要目的是希望利用堆疊的特性:
- 配置/釋放極快:只需要移動 stack pointer,不需要像 heap 那樣搜尋可用空間或執行 GC。
- 零 GC 壓力:用完即丟,完全不給 Garbage Collector 找麻煩。
- Cache 友善:Stack 記憶體通常都在 CPU cache 裡,讀取速度很快。
了解這個最初的原因,再回頭看前面整理的一堆限制,以及為何必須把 ref struct 鎖在堆疊上(避免記憶體崩潰),應該就能全盤理解了。
結語
ref struct 的細節有點多,但還好一般應用場景不大會需要自訂 ref struct 型別,也不會特別去使用 Span<T> 和 ReadOnlySpan<T> 。
如果是講究效能的場合,它們就很好用。到時候如果已經忘光有關 ref struct 的細節,再回來看自己這篇筆記吧。
Keep learing!
本文摘錄自即將出版的新書《現代 C#:AI 時代的開發者修煉》,內容有因應部落格的版面做一些調整。歡迎關注此部落格和我的臉書專頁發布的出版消息。
沒有留言: