徹底搞懂 C# 的 ref struct

整理 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" 解析出數值時:

  1. 一般做法:使用 Substring 或 Split。這會產生多個暫時的字串物件(堆積配置),增加垃圾回收(GC)的壓力。
  2. 高效能做法:使用 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 時代的開發者修煉》,內容有因應部落格的版面做一些調整。歡迎關注此部落格和我的臉書專頁發布的出版消息。


沒有留言:

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