程式語言 · 泛型

# 泛型在執行時長什麼樣：值型別各翻一份，參考型別共用一份（.NET JIT，附 Go 對照）

Chance Lu

你寫了一個小小的 `Shelf<T>`（一個放東西的架子），程式裡用了四次：`Shelf<int>`、`Shelf<double>`、`Shelf<string>`、`Shelf<object>`。原始碼只有一份。那 CPU 真正在跑的程式碼，有幾份？

直覺會猜一份或四份。**實際是三份**：int 一份、double 一份，string 跟 object 合用一份。「動手做」會讓你親眼看到。

> 一句話：**值型別**每種大小不同，所以**每種各做一份**機器碼；**參考型別**的格子裡都是一樣大的地址，所以**共用一份**。

## 第一步：CPU 跑的不是你寫的 C#

`dotnet build` 產出的**不是 CPU 能直接執行的指令**，而是一份「中間碼」（給 .NET 看的通用指令）。CPU 只看得懂**機器碼**，中間碼總得被翻譯。這一步認識負責翻譯的人。

它叫 **JIT**（just-in-time，「臨時才翻」的編譯器）。程式跑起來後，某個方法**第一次被呼叫**，JIT 才把它翻成機器碼。翻好的留在記憶體，**下次直接用**。

你寫的 C#Shelf&lt;T&gt;，一份
編譯
中間碼還是一份，T 還空著
JIT 翻譯
機器碼CPU 直接跑的指令
JIT：方法第一次被呼叫時才翻；翻好的留著，之後直接用

圖 A：泛型在中間碼裡只有一份，T 還沒決定（點圖可放大）

> 一句話：要幾份機器碼，是 **JIT 翻譯時**才決定的。

## 第二步：值型別，每種各翻一份

機器碼是很具體的指令，像「從這裡**搬 4 個 bytes**」。但 T 是 int 時一格 4 bytes，是 double 時一格 8 bytes，**同一段指令沒辦法兩種都對。**這一步看 JIT 怎麼解決。

先認識一個詞：**封閉型別**，就是 T 已經填好的具體型別，例如 `Shelf<int>`。`Shelf<T>` 像模具，`Shelf<int>` 是用模具做出來的成品。

JIT 第一次碰到 `Shelf<int>`，就把 T 換成 int **翻一份專用的**；碰到 `Shelf<double>`，再翻**另一份**。之後所有 `Shelf<int>` 都用同一份。

Shelf&lt;int&gt;
1
2
3
4
每格 4 bytes，int 直接放在格子裡
Shelf&lt;double&gt;
1.5
2.5
3.5
每格 8 bytes
要搬幾個 bytes 不一樣 → 機器碼得各翻一份

圖 B：同一個 Shelf&lt;T&gt;，T 不同，格子大小就不同（點圖可放大）

這也是[上一篇〈泛型與裝箱〉](https://docs.smartech.tw/n/generics-boxing-list-arraylist)留下的答案：int 版機器碼**知道格子就是 int**，所以 int 直接放進陣列，**不用開盒子**。

> 一句話：值型別大小各不同，所以**各翻一份**；專用版裡值直接放，不用裝箱。

## 第三步：參考型別，大家共用一份

那 string、object、你寫的 class 呢？一個程式的 class 可能上百個，**每個都翻一份**太浪費。這一步看 JIT 怎麼省。

參考型別的格子裡放的是**地址**，不管指向 string 還是你的 class，地址都一樣大（64 位元是 8 bytes）。搬地址的指令**一模一樣**，所以 JIT 只翻一份，大家共用。

這份共用版叫 **\_\_Canon 版**（canonical，「標準版」的意思）。T 只要是參考型別，都用它。

程式用到的型別
JIT 第一次碰到時才翻
記憶體裡的機器碼
Shelf&lt;int&gt;
Shelf&lt;double&gt;
Shelf&lt;string&gt;
Shelf&lt;object&gt;

int 版
double 版
\_\_Canon 版所有參考型別共用

① 第一次用 Shelf&lt;int&gt;：JIT 翻出 int 專用版格子每格 4 bytes，int 直接放
② 第一次用 Shelf&lt;double&gt;：大小不同，再翻一份 double 版格子每格 8 bytes
③ 第一次用 Shelf&lt;string&gt;：翻出 \_\_Canon 版格子裡放的是地址，每格 8 bytes
④ 用到 Shelf&lt;object&gt;：不再翻，直接共用 \_\_Canon 版一樣是地址、一樣大，指令完全相同

圖 C：四個封閉型別，JIT 只翻了三份（動畫會循環，點圖可放大）

那共用的程式碼裡寫 `typeof(T)`，怎麼知道這次是 string 還是 object？執行時會**另外帶著一份「這次 T 是誰」的資訊**（官方叫 generic dictionary），需要時去查。（這裡我簡化了，細節見參考來源的 runtime 設計文件。）

> 一句話：地址都一樣大，所以參考型別**只翻一份 \_\_Canon 版**。

## 第四步：程式碼共用，型別還是各自獨立

`Shelf<string>` 跟 `Shelf<object>` 用同一份機器碼。那它們是**同一個型別**嗎？泛型類別的 **static 欄位**也只有一份嗎？

**共用的只有機器碼。**.NET 執行時替每個**封閉型別**各準備一份「型別資料」，記著它叫什麼、T 是誰。static 欄位跟著這份資料走，所以**每個封閉型別各有一份**，Microsoft 文件也這樣寫。

型別資料：每個封閉型別各一份
機器碼：可以共用
Counter&lt;int&gt;static Count = 2
Counter&lt;string&gt;static Count = 1
Counter&lt;object&gt;static Count = 0

int 版
\_\_Canon 版string、object 共用

圖 D：左邊三份資料各自獨立，右邊只有兩份機器碼（點圖可放大）

> 一句話：**機器碼可以共用，型別不會合併**。static 欄位屬於型別，所以每個封閉型別各有一份。

## 常見的坑

- **想讓所有 T 共用一個計數或快取，卻放在泛型類別的 static 裡。**結果 `Counter<int>` 和 `Counter<string>` 各算各的。真的要共用，放到**不是泛型**的類別。
- **以為共用機器碼就是同一個型別。**`List<string>` 不能當 `List<object>` 用，`typeof` 比較也不相等。

## 名詞白話

| 名詞 | 白話 |
| --- | --- |
| JIT | 程式跑起來後，方法第一次被呼叫時才把它翻成機器碼的翻譯員 |
| 封閉型別 | T 已經填好的具體型別，例如 `List<int>` |
| \_\_Canon 版 | 所有參考型別共用的那一份機器碼 |
| GC shape（Go） | Go 依「記憶體長相」分組，同一組共用一份機器碼 |

## 動手做

先開一個練習資料夾：

```
mkdir lab && cd lab && dotnet new console
```

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

### 練習 1：偷看 JIT 翻了幾份

建檔把 `Program.cs` 換成：

```
var a = new Shelf<int>();    a.Put(1);
var b = new Shelf<double>(); b.Put(1.5);
var c = new Shelf<string>(); c.Put("hi");
var d = new Shelf<object>(); d.Put(new object());

class Shelf<T>
{
    private T[] _items = new T[4];
    private int _count;
    public void Put(T item) => _items[_count++] = item;
}
```

先猜四個封閉型別，`Put` 會被翻成幾份機器碼？

執行設環境變數 `DOTNET_JitDisasmSummary=1`，JIT 每翻一個方法就印一行（macOS / Linux 寫法）：

```
DOTNET_JitDisasmSummary=1 dotnet run -c Release | grep Put
```

```
   3: JIT compiled Shelf`1[int]:Put(int) [Tier0, IL size=30, code size=116]
   5: JIT compiled Shelf`1[double]:Put(double) [Tier0, IL size=30, code size=116]
   7: JIT compiled Shelf`1[System.__Canon]:Put(System.__Canon) [Tier0, IL size=30, code size=108]
```

你會看到`Put` 只出現**三次**：int、double、`__Canon`，沒有 string 版和 object 版（``1` 表示有 1 個型別參數；`Tier0` 等標記先忽略）。這就是圖 C。

### 練習 2：static 欄位有幾份？

建檔把 `Program.cs` 換成：

```
Counter<int>.Count++;
Counter<int>.Count++;
Counter<string>.Count++;

Console.WriteLine($"Counter<int>.Count    = {Counter<int>.Count}");
Console.WriteLine($"Counter<string>.Count = {Counter<string>.Count}");
Console.WriteLine($"Counter<object>.Count = {Counter<object>.Count}");

class Counter<T>
{
    public static int Count;
}
```

先猜string 和 object 共用機器碼，那 `Counter<object>.Count` 會是 1 嗎？

執行`dotnet run`：

```
Counter<int>.Count    = 2
Counter<string>.Count = 1
Counter<object>.Count = 0
```

這證明每個封閉型別**各有一份** `Count`（圖 D）。

### 練習 3：共用程式碼，還認得出 T 嗎？

建檔把 `Program.cs` 換成：

```
Console.WriteLine(typeof(List<int>) == typeof(List<double>));
Console.WriteLine(typeof(List<string>) == typeof(List<object>));
Console.WriteLine(WhoAmI<string>());
Console.WriteLine(WhoAmI<object>());

static string WhoAmI<T>() => typeof(T).Name;
```

先猜第二行是 `True` 嗎？`WhoAmI` 兩次跑同一份 `__Canon` 版，印出來一樣嗎？

執行`dotnet run`：

```
False
False
String
Object
```

這證明型別永遠分開；共用的程式碼也能靠隨身那份資訊，認出這次的 T 是誰。

## Go 對照

Go 1.18 的泛型做法很像，叫 **GC shape stenciling**：把型別依**記憶體長相（GC shape）**分組，**同一組只產一份**機器碼，再用隱藏參數 dictionary 告訴它真正的型別。

|  | C#（.NET） | Go |
| --- | --- | --- |
| 什麼時候產機器碼 | 執行時，JIT 第一次用到 | 編譯時 |
| 怎麼分組 | 值型別每種一份；參考型別合成一份 | 底層型別相同的同一組；所有指標型別同一組 |
| 怎麼知道 T 是誰 | 執行時帶著的型別資訊 | 隱藏參數 dictionary |

想法一樣：**長得一樣就共用。**但 Go 的 `string` 不是指標，不會跟 `*User` 同組。

## ✔ 過關標準

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

1. 為什麼 List&lt;int&gt; 不用裝箱？跟 JIT 有什麼關係？
**JIT 替 List&lt;int&gt; 翻了一份 int 專用的機器碼，它知道格子就是 int，值直接放進陣列。**
    - int、double 格子大小不同，指令不同，所以值型別每種各翻一份。
2. List&lt;string&gt; 和 List&lt;object&gt; 跑的是同一份機器碼嗎？為什麼可以？
**是，兩個都用 \_\_Canon 版。**
    - 格子裡都是一樣大的地址，指令完全相同。
3. Counter&lt;string&gt; 和 Counter&lt;object&gt; 共用機器碼，為什麼 static Count 還是各一份？
**共用的只有機器碼；每個封閉型別各有自己的型別資料，static 欄位跟著型別走。**
    - 想讓所有 T 共用，要放到非泛型的類別。
4. Go 的 GC shape stenciling 跟 .NET 哪裡像、哪裡不同？
**像：都是「記憶體長得一樣就共用一份」，指標／參考型別合成一份。**
    - 不同：Go 編譯時就產好；.NET 執行時第一次用到才翻。

## 參考來源

- [Microsoft Learn — Generics in the runtime (C#)](https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/generics/generics-in-the-run-time)（值型別各一份、參考型別共用）
- [Microsoft Learn — Managed execution process](https://learn.microsoft.com/en-us/dotnet/standard/managed-execution-process)（JIT 第一次呼叫時才翻）
- [Microsoft Learn — Static classes and static class members](https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/static-classes-and-static-class-members)（每個封閉型別各有 static）
- [dotnet/runtime — Shared generics design](https://github.com/dotnet/runtime/blob/main/docs/design/coreclr/botr/shared-generics.md)（\_\_Canon、generic dictionary）
- [.NET Blog — Performance Improvements in .NET 7](https://devblogs.microsoft.com/dotnet/performance_improvements_in_net_7/)（DOTNET\_JitDisasmSummary）
- [Go — Generics implementation: dictionaries and GC shape stenciling (Go 1.18)](https://github.com/golang/proposal/blob/master/design/generics-implementation-dictionaries-go1.18.md)（GC shape、dictionary）
