假設你在處理一份日誌,每一行開頭都是日期,像 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 負責回收沒人用的東西。)
你要表示一個座標點,有 X、Y 兩個數字。寫成 struct Point 或 class Point,程式碼幾乎一樣。
但寫完 b = a 再改 b.X,a 會不會跟著變?兩種寫法答案相反。這一步看差在哪。
b = a 把 X、Y 整份影印給 b,之後各改各的。d = c 只抄地址,兩個變數指著同一個物件。圖 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。
回到日期。string 是 class,字串本體在 heap。你想要的只是其中「2026」這四個字。
問題是 Substring 回傳的是一個 string。要給你一個 string,它只能在 heap 上開一個新的,再把那幾個字抄進去。這一步看這樣切,代價有多大。
圖 B:上方是原字串。橘色=Substring 影印出的新字串;綠色=Span 只在原字串上框一段(動畫會循環,點圖可放大)
一句話:Substring 每切一段,就在 heap 上影印一份;切得越多,GC 越忙。
我們真正需要的,其實只有兩個數字:「從第 0 格開始」「看 4 格」。能不能只記這兩個,一個字都不抄?這一步認識做這件事的 Span<T>。
Span<T> 裡面只有兩個欄位:起點(指著某一格)和長度。它自己是個小 struct,放在 stack 上。
Slice(5, 2) 只是做出另一扇小窗:起點往後挪、長度改成 2。原資料一個字都沒動,heap 上也沒開任何東西。
圖 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 只記起點和長度;切一段=換個窗框,不抄資料、不配置。
窗是指著別人的記憶體。它可能框著陣列的中間,也可能框著 stack 上的暫存。
麻煩在 stack 上那塊方法一結束就收回。如果窗能被存到 heap,它就可能活得比它框的東西還久,變成指著一塊已經收回的地方。這一步看 C# 怎麼防。
答案是 ref struct。Span<T> 宣告成 ref struct,編譯器保證它只在 stack 上,方法結束就跟著消失。所以凡是會把它搬進 heap 的寫法,編譯器全部擋下(練習 4 會看到這三個錯誤):
圖 D:ref struct 的規則,就是不讓窗離開 stack(點圖可放大)
(官方還列了 GC 追蹤成本等其他理由,細節見參考來源。)
真的要存起來、跨 await?
改用 Memory<T>:它是可以放進 heap 的版本,一樣不抄資料。要處理的時候,再用 .Span 換回窗。
一句話:Span 是 ref struct,只活在 stack;要存起來或跨 await,就換 Memory<T>,或乾脆影印一份。
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 永遠不會指到收回的地方」。
.ToString() 或 .ToArray(),只在那一刻配置一次。Span<T>(不是唯讀版)寫進去,改的是原陣列。不想被改,就給對方 ReadOnlySpan<T>。先開一個練習資料夾:
mkdir lab && cd lab && dotnet new console
每個練習都是把 Program.cs 整個換掉再執行。先猜、再跑、再對答案。輸出是在 .NET 10 實際跑出來的。
量配置用的是 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 上。
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。
建檔把 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)。開窗不配置,但會共用。
建檔三段各試一次,每次把 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 題,這篇就過了。
input.AsSpan(),再把 Substring(a, b) 換成 Slice(a, b),交給吃 ReadOnlySpan<char> 的 int.Parse。
.ToString()/.ToArray() 影印一份。