程式語言 · 記憶體

抄一份還是指過去:值與參考、不複製的切片(C# struct 與 Span<T>,附 Go 對照)

Chance Lu · 學習文章 · 2026-11-04 23:32
Chance Lu

假設你在處理一份日誌,每一行開頭都是日期,像 2026-11-04。你要把年、月、日拆成三個數字,一天有一百萬行。

最順手的寫法是用 Substring 切三段,再丟給 int.Parse。結果完全正確,但每切一段,C# 就影印一張新字串。一百萬行就是三百萬張。後面「動手做」實測:多開了 96 MB 記憶體,GC 被叫出來清了 11 次。

可是年、月、日本來就在原字串裡,你只是想「看」那幾格。這篇講怎麼只看、不影印。

一句話:struct 一指派就整份影印,class 只抄地址。Span<T> 是開在原資料上的一扇窗,切一段只是換個窗框:不影印,也不在 heap 上開新東西。

四個新名詞

名詞白話
配置(allocation)在 heap 上開一塊新記憶體放東西。開得越多,GC 越常要出來清。
stack每個方法執行時的「工作桌」。區域變數放這裡,方法一結束整張桌子收掉,不用 GC。
Span<T>一扇窗:只記「從哪一格開始、看幾格」,指著別人已經有的記憶體。唯讀版叫 ReadOnlySpan<T>。
ref struct只准待在 stack 上的 struct。Span<T> 就是一種。

(heap、GC、裝箱前面幾篇講過:heap 是放物件的倉庫,GC 負責回收沒人用的東西。)

第一步:struct 影印整份,class 只抄地址

你要表示一個座標點,有 X、Y 兩個數字。寫成 struct Point 或 class Point,程式碼幾乎一樣。

但寫完 b = a 再改 b.X,a 會不會跟著變?兩種寫法答案相反。這一步看差在哪。

struct:b = a整份影印 stack(方法的工作桌) aX=1 Y=2 bX=99 Y=2 兩份各自獨立:改 b,a 還是 1 class:d = c只抄地址 stack c地址 d地址 heap(GC 管) 物件X=99 Y=2 c、d 指著同一個物件:改 d,c 也看到 99

圖 A:struct 指派是影印;class 指派是多一個人知道地址(點圖可放大)

資料住在哪也不同。class 物件一律開在 heap,要等 GC 回收。struct 跟著變數走:當區域變數就在 stack 上,當成 class 的欄位就嵌在那個物件裡面。

struct 也會跑進 heap:裝箱。把 struct 當成 object 或 interface 用,C# 會影印一份放進 heap 的盒子,就是〈泛型與裝箱〉講的那件事。練習 1 量到一個 Point 裝箱要 24 bytes。盒子是影本,之後改原本的 struct,盒子裡不會變。

一句話:struct 是影本,class 是地址;struct 一被當成 object,就多影印一份到 heap。

第二步:Substring 每切一段,就影印一份

回到日期。string 是 class,字串本體在 heap。你想要的只是其中「2026」這四個字。

問題是 Substring 回傳的是一個 string。要給你一個 string,它只能在 heap 上開一個新的,再把那幾個字抄進去。這一步看這樣切,代價有多大。

原字串heap 上,只有一份 2026-11-04 0358 Span 版stack 上的小窗 (0, 4)(5, 2)(8, 2) (起點, 長度) Substring 版heap 上的新字串 每一個都是新配置 "2026" "11" "04" ① Substring(0, 4):在 heap 開一個新字串,把 2026 抄進去新配置:1 個 ② Substring(5, 2):再開一個,抄 11新配置:2 個 ③ Substring(8, 2):再開一個,抄 04一行就 3 個新字串,parse 完全變垃圾 ④ AsSpan().Slice(0, 4):只記「從第 0 格開始、看 4 格」原字串一個字都沒抄 ⑤ Slice(5, 2):換一個窗框新配置:還是 0 個 ⑥ Slice(8, 2):再換一次三段都切好了,heap 上什麼都沒多

圖 B:上方是原字串。橘色=Substring 影印出的新字串;綠色=Span 只在原字串上框一段(動畫會循環,點圖可放大)

一句話:Substring 每切一段,就在 heap 上影印一份;切得越多,GC 越忙。

第三步:Span<T>,在原資料上開一扇窗

我們真正需要的,其實只有兩個數字:「從第 0 格開始」「看 4 格」。能不能只記這兩個,一個字都不抄?這一步認識做這件事的 Span<T>。

Span<T> 裡面只有兩個欄位:起點(指著某一格)和長度。它自己是個小 struct,放在 stack 上。

Slice(5, 2) 只是做出另一扇小窗:起點往後挪、長度改成 2。原資料一個字都沒動,heap 上也沒開任何東西。

Span<T>(一扇窗) ① 起點:指著某一格 ② 長度:看幾格 陣列 int[] 859270649958 字串 string唯讀版:ReadOnlySpan 2026-11-04 stack 上的暫存用 stackalloc 開的

圖 C:同一種窗,可以框在陣列、字串、stack 暫存上;綠框就是它看得到的範圍(點圖可放大)

改寫日期解析,只換兩處:先 AsSpan() 開窗,再用 Slice 代替 Substring。

// 之前:一行配置 3 個新字串
int y = int.Parse(input.Substring(0, 4));
int m = int.Parse(input.Substring(5, 2));
int d = int.Parse(input.Substring(8, 2));

// 之後:0 次配置
ReadOnlySpan<char> s = input.AsSpan();
int y = int.Parse(s.Slice(0, 4));
int m = int.Parse(s.Slice(5, 2));
int d = int.Parse(s.Slice(8, 2));

能這樣寫,是因為 int.Parse 有直接吃 ReadOnlySpan<char> 的版本,整段不必產生任何字串。練習 2:96,000,000 bytes → 0,GC 11 次 → 0 次。

陣列也一樣。scores[2..5] 會抄出一個新陣列(練習 3 量到 40 bytes);scores.AsSpan(2..5) 是 0。但窗是開在原陣列上的,透過 Span 寫入,改到的就是原陣列。

補充:stackalloc

Span<byte> buf = stackalloc byte[64]; 直接在 stack 上開一塊暫存,方法結束自動收回,完全不經過 GC。只適合小塊,開太大會把 stack 撐爆。

一句話:Span 只記起點和長度;切一段=換個窗框,不抄資料、不配置。

第四步:為什麼 Span 只能待在 stack 上

窗是指著別人的記憶體。它可能框著陣列的中間,也可能框著 stack 上的暫存。

麻煩在 stack 上那塊方法一結束就收回。如果窗能被存到 heap,它就可能活得比它框的東西還久,變成指著一塊已經收回的地方。這一步看 C# 怎麼防。

答案是 ref struct。Span<T> 宣告成 ref struct,編譯器保證它只在 stack 上,方法結束就跟著消失。所以凡是會把它搬進 heap 的寫法,編譯器全部擋下(練習 4 會看到這三個錯誤):

三條「搬進 heap」的路,編譯器都封死 stack:方法結束就收回 Span(窗)框著別人的記憶體 heap:活多久由 GC 決定 放進 class 的欄位CS8345 當成 object(裝箱)CS0029 跨過 await 還要用CS4007

圖 D:ref struct 的規則,就是不讓窗離開 stack(點圖可放大)

(官方還列了 GC 追蹤成本等其他理由,細節見參考來源。)

真的要存起來、跨 await?

改用 Memory<T>:它是可以放進 heap 的版本,一樣不抄資料。要處理的時候,再用 .Span 換回窗。

一句話:Span 是 ref struct,只活在 stack;要存起來或跨 await,就換 Memory<T>,或乾脆影印一份。

Go 對照

Go 的 slice 本來就是一扇窗。〈動態陣列〉講過:切片只是一個小表頭(ptr、len、cap),不抄底層陣列。字串也一樣,s[0:4] 只做一個新表頭,跟原字串共用同一份 bytes。

兩邊都不抄,差在窗能放在哪:

C# Span<T>Go slice
切一段要不要抄不抄不抄
能存進欄位、帶著走嗎不行,只能在 stack可以,放哪都行
誰保證不指到收回的記憶體編譯器的 ref struct 規則編譯器判斷要放 stack 還是 heap,GC 一路追
代價限制多小切片可能讓大陣列收不掉

struct 那半也一樣:Go 什麼都是傳值,struct 指派就是影印一份;想共用,就傳指標 *Point。

一句話:Go 用 GC 換自由,C# 用編譯器規則換「Span 永遠不會指到收回的地方」。

常見的坑

動手做

先開一個練習資料夾:

mkdir lab && cd lab && dotnet new console

每個練習都是把 Program.cs 整個換掉再執行。先猜、再跑、再對答案。輸出是在 .NET 10 實際跑出來的。

練習 1:影印還是共用?

量配置用的是 GC.GetAllocatedBytesForCurrentThread():回傳到目前為止配置了多少 bytes,前後相減就是中間配置的量。

建檔把 Program.cs 換成:

var a = new PointS { X = 1, Y = 2 };
var b = a;          // struct:整份抄過去
b.X = 99;
Console.WriteLine($"struct: a.X = {a.X}, b.X = {b.X}");

var c = new PointC { X = 1, Y = 2 };
var d = c;          // class:抄的只是地址
d.X = 99;
Console.WriteLine($"class : c.X = {c.X}, d.X = {d.X}");

long before = GC.GetAllocatedBytesForCurrentThread();
object boxed = a;   // 裝箱
long after = GC.GetAllocatedBytesForCurrentThread();
a.X = 7;
Console.WriteLine($"裝箱配置 {after - before} bytes,盒子裡的 X = {((PointS)boxed).X}");

struct PointS { public int X; public int Y; }
class PointC { public int X; public int Y; }

先猜a.X 和 c.X 各是多少?裝箱後把 a.X 改成 7,盒子裡會變 7 嗎?

執行dotnet run:

struct: a.X = 1, b.X = 99
class : c.X = 99, d.X = 99
裝箱配置 24 bytes,盒子裡的 X = 1

這證明struct 指派是影印、class 是共用(圖 A);裝箱也是影印,只是印到 heap 上。

練習 2:Substring 和 AsSpan,各配置多少?

Measure 怎麼寫的不用細看,它先暖身一次,再量配置量和 GC 次數。

建檔把 Program.cs 換成:

const int N = 1_000_000;
string input = "2026-11-04";

Measure("Substring", () =>
{
    long sum = 0;
    for (int i = 0; i < N; i++)
    {
        int y = int.Parse(input.Substring(0, 4));  // 新字串 "2026"
        int m = int.Parse(input.Substring(5, 2));  // 新字串 "11"
        int d = int.Parse(input.Substring(8, 2));  // 新字串 "04"
        sum += y + m + d;
    }
    return sum;
});

Measure("AsSpan   ", () =>
{
    long sum = 0;
    for (int i = 0; i < N; i++)
    {
        ReadOnlySpan<char> s = input.AsSpan();     // 一扇窗,指著原字串
        int y = int.Parse(s.Slice(0, 4));          // 窗框移到 "2026"
        int m = int.Parse(s.Slice(5, 2));
        int d = int.Parse(s.Slice(8, 2));
        sum += y + m + d;
    }
    return sum;
});

static void Measure(string name, Func<long> work)
{
    work();                                        // 先暖身一次
    int gc0 = GC.CollectionCount(0);
    long before = GC.GetAllocatedBytesForCurrentThread();
    long result = work();
    long bytes = GC.GetAllocatedBytesForCurrentThread() - before;
    int gcs = GC.CollectionCount(0) - gc0;
    Console.WriteLine($"{name}:配置 {bytes,12:N0} bytes,GC {gcs,3} 次(結果 {result})");
}

先猜一百萬行,Substring 版大概配置幾 MB?Span 版呢?

執行dotnet run -c Release:

Substring:配置   96,000,000 bytes,GC  11 次(結果 2041000000)
AsSpan   :配置            0 bytes,GC   0 次(結果 2041000000)

你會看到結果一樣,但 Substring 版每行 96 bytes(三個小字串),Span 版一個 byte 都沒配置,GC 也沒出場。這就是圖 B。

練習 3:陣列切片,抄一份還是開窗?

建檔把 Program.cs 換成:

int[] scores = { 85, 92, 70, 64, 99, 58 };

long t0 = GC.GetAllocatedBytesForCurrentThread();
int[] copy = scores[2..5];              // 範圍:抄出一個新陣列
long t1 = GC.GetAllocatedBytesForCurrentThread();
Span<int> view = scores.AsSpan(2..5);   // 窗:指著原陣列
long t2 = GC.GetAllocatedBytesForCurrentThread();

copy[0] = 0;
view[1] = 0;
Console.WriteLine($"scores = [{string.Join(", ", scores)}]");
Console.WriteLine($"抄陣列配置 {t1 - t0} bytes,開窗配置 {t2 - t1} bytes");

先猜copy[0] = 0 和 view[1] = 0,哪一個會改到 scores?

執行dotnet run:

scores = [85, 92, 70, 0, 99, 58]
抄陣列配置 40 bytes,開窗配置 0 bytes

這證明改 copy 不影響原陣列;改 view 的第 1 格,就是改 scores 的第 3 格(64 → 0)。開窗不配置,但會共用。

練習 4:想把窗帶出 stack,編譯器怎麼說?

建檔三段各試一次,每次把 Program.cs 換成其中一段,執行 dotnet build:

// ① 放進 class 欄位
class DateParser
{
    private ReadOnlySpan<char> _year;
}
// ② 當成 object
ReadOnlySpan<char> year = "2026-11-04".AsSpan(0, 4);
object o = year;
// ③ 跨過 await 再用
ReadOnlySpan<char> year = "2026-11-04".AsSpan(0, 4);
await Task.Delay(10);
Console.WriteLine(year.ToString());

你會看到三個都編譯失敗(只留錯誤那行):

Program.cs(3,13): error CS8345: Field or auto-implemented property cannot be of type 'ReadOnlySpan<char>' unless it is an instance member of a ref struct.
Program.cs(2,12): error CS0029: Cannot implicitly convert type 'System.ReadOnlySpan<char>' to 'object'
Program.cs(3,19): error CS4007: Instance of type 'System.ReadOnlySpan<char>' cannot be preserved across 'await' or 'yield' boundary.

再試把 ③ 的 AsSpan 那行搬到 await 之後,就能編譯,印出 2026:窗只要不「跨過」await 就沒事。

✔ 過關標準

能不看筆記回答下面 5 題,這篇就過了。

  1. struct 和 class,在「資料住哪」和「指派時抄什麼」上差在哪?看答案收合
    struct 的變數裡就是資料,指派時整份影印;class 的變數裡是地址,指派只抄地址,大家共用 heap 上同一個物件。
    • struct 跟著變數住:區域變數在 stack,當 class 欄位就嵌在物件裡。
    • class 物件一律在 heap,要 GC 回收。
  2. struct 什麼時候會被裝箱?裝箱後改原本的 struct,盒子會跟著變嗎?看答案收合
    當成 object 或 interface 用的時候;盒子是一份影本,不會跟著變。
    • 練習 1:一個 Point 裝箱配置 24 bytes,改 a.X 後盒子裡還是 1。
  3. 把 Substring 版的日期解析改成 Span 版,要改哪裡?省掉了哪些配置?看答案收合
    先 input.AsSpan(),再把 Substring(a, b) 換成 Slice(a, b),交給吃 ReadOnlySpan<char> 的 int.Parse。
    • 省掉的是每行三個新字串("2026"、"11"、"04")的配置。
    • 實測一百萬行:96,000,000 bytes → 0,GC 11 次 → 0 次。
  4. 為什麼 Span<T> 不能當 class 欄位、不能裝箱、不能跨 await?看答案收合
    它是 ref struct,只准待在 stack;這三件事都會把它搬進 heap。
    • 窗可能框著 stack 上的暫存,那塊方法結束就收回;窗若活得更久,就指到收回的地方。
    • 要存起來或跨 await,用 Memory<T>,或 .ToString()/.ToArray() 影印一份。
  5. Go 的 slice 也不抄資料,為什麼 Go 沒有「只能待在 stack」的限制?看答案收合
    Go 的編譯器會判斷變數該放 stack 還是 heap,GC 一路追蹤,所以 slice 放哪都安全。
    • 代價是小切片可能讓背後的大陣列收不掉。
    • C# 選擇用編譯器規則限制 Span 的去處。

參考來源