程式語言 · 記憶體

# 抄一份還是指過去：值與參考、不複製的切片（C# struct 與 Span&lt;T&gt;，附 Go 對照）

Chance Lu

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

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

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

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

## 四個新名詞

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

（heap、GC、裝箱前面幾篇講過：heap 是放物件的倉庫，GC 負責回收沒人用的東西。）

## 第一步：struct 影印整份，class 只抄地址

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

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

- **struct 變數裡就是資料本身。**`b = a` 把 X、Y 整份影印給 b，之後各改各的。
- **class 變數裡只放地址**，物件本體在 heap。`d = c` 只抄地址，兩個變數指著**同一個**物件。

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 的盒子，就是[〈泛型與裝箱〉](https://docs.smartech.tw/n/generics-boxing-list-arraylist)講的那件事。練習 1 量到一個 Point 裝箱要 24 bytes。盒子是影本，之後改原本的 struct，盒子裡不會變。

> 一句話：struct 是影本，class 是地址；struct 一被當成 object，就多影印一份到 heap。

## 第二步：Substring 每切一段，就影印一份

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

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

- 一行日期切三段，就是**三次配置**。練習 2 量到每行 96 bytes。
- 這三個字串 parse 完就沒人要了，**全變垃圾**，等 GC 來收。
- 一百萬行就是 96 MB 的垃圾，GC 跑了 11 次。

原字串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&lt;T&gt;，在原資料上開一扇窗

我們真正需要的，其實只有兩個數字：「從第 0 格開始」「看 4 格」。能不能只記這兩個，一個字都不抄？這一步認識做這件事的 Span&lt;T&gt;。

Span&lt;T&gt; 裡面**只有兩個欄位：起點（指著某一格）和長度**。它自己是個小 struct，放在 stack 上。

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

Span&lt;T&gt;（一扇窗）
① 起點：指著某一格
② 長度：看幾格
陣列 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&lt;char&gt; 的版本**，整段不必產生任何字串。練習 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&lt;T&gt; 宣告成 ref struct，編譯器保證它**只在 stack 上**，方法結束就跟著消失。所以凡是會把它搬進 heap 的寫法，編譯器全部擋下（練習 4 會看到這三個錯誤）：

- **放進 class 的欄位**：class 物件在 heap，欄位也在 heap。
- **當成 object**：那就是裝箱，等於影印到 heap。
- **跨過 await 再用**：async 方法遇到 await 會先離開、之後回來接著跑，中間要用的區域變數會被存進 heap 保管。

三條「搬進 heap」的路，編譯器都封死

stack：方法結束就收回

Span（窗）框著別人的記憶體

heap：活多久由 GC 決定

放進 class 的欄位CS8345
當成 object（裝箱）CS0029
跨過 await 還要用CS4007

圖 D：ref struct 的規則，就是不讓窗離開 stack（點圖可放大）

（官方還列了 GC 追蹤成本等其他理由，細節見參考來源。）

真的要存起來、跨 await？

改用 **Memory&lt;T&gt;**：它是可以放進 heap 的版本，一樣不抄資料。要處理的時候，再用 `.Span` 換回窗。

> 一句話：Span 是 ref struct，只活在 stack；要存起來或跨 await，就換 Memory&lt;T&gt;，或乾脆影印一份。

## Go 對照

Go 的 slice 本來就是一扇窗。[〈動態陣列〉](https://docs.smartech.tw/n/dynamic-array-list-slice)講過：切片只是一個小表頭（ptr、len、cap），**不抄底層陣列**。字串也一樣，`s[0:4]` 只做一個新表頭，跟原字串共用同一份 bytes。

兩邊都不抄，差在**窗能放在哪**：

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

struct 那半也一樣：Go 什麼都是傳值，struct 指派就是影印一份；想共用，就傳指標 `*Point`。

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

## 常見的坑

- **要留著用，才影印。**Span 不能存起來；真的要保留，用 `.ToString()` 或 `.ToArray()`，只在那一刻配置一次。
- **窗是能寫的。**`Span<T>`（不是唯讀版）寫進去，改的是原陣列。不想被改，就給對方 `ReadOnlySpan<T>`。
- **struct 別做太大。**每次指派、傳參數都整份影印。Microsoft 建議 struct 小（16 bytes 以下）、不可變、不常被裝箱，否則用 class。

## 動手做

先開一個練習資料夾：

```
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&lt;char&gt; 的 `int.Parse`。**
    - 省掉的是每行三個新字串（"2026"、"11"、"04"）的配置。
    - 實測一百萬行：96,000,000 bytes → 0，GC 11 次 → 0 次。
4. 為什麼 Span&lt;T&gt; 不能當 class 欄位、不能裝箱、不能跨 await？
**它是 ref struct，只准待在 stack；這三件事都會把它搬進 heap。**
    - 窗可能框著 stack 上的暫存，那塊方法結束就收回；窗若活得更久，就指到收回的地方。
    - 要存起來或跨 await，用 Memory&lt;T&gt;，或 `.ToString()`／`.ToArray()` 影印一份。
5. Go 的 slice 也不抄資料，為什麼 Go 沒有「只能待在 stack」的限制？
**Go 的編譯器會判斷變數該放 stack 還是 heap，GC 一路追蹤，所以 slice 放哪都安全。**
    - 代價是小切片可能讓背後的大陣列收不掉。
    - C# 選擇用編譯器規則限制 Span 的去處。

## 參考來源

- [Microsoft Learn — Structure types (C# reference)](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/struct)（值語意、指派與傳參數會複製、裝箱）
- [Microsoft Learn — Choosing between class and struct](https://learn.microsoft.com/en-us/dotnet/standard/design-guidelines/choosing-between-class-and-struct)（配置在哪、16 bytes 建議）
- [Microsoft Learn — ref struct types](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/ref-struct)（不能當 class 欄位、不能裝箱、不能跨 await）
- [Microsoft Learn — Memory-related and span types](https://learn.microsoft.com/en-us/dotnet/standard/memory-and-spans/)（Span 不複製、Memory&lt;T&gt;）
- [Stephen Toub — All About Span: Exploring a New .NET Mainstay（MSDN Magazine）](https://learn.microsoft.com/en-us/archive/msdn-magazine/2018/january/csharp-all-about-span-exploring-a-new-net-mainstay)（Substring 會配置、Span 版 Parse、為何只能在 stack）
- [The Go Blog — Arrays, slices (and strings): The mechanics of 'append'](https://go.dev/blog/slices)（slice 表頭、字串切片不複製）
- [Go FAQ — Stack or heap?](https://go.dev/doc/faq#stack_or_heap)、[Go FAQ — Pass by value](https://go.dev/doc/faq#pass_by_value)
