你寫了一個小小的 Shelf<T>(一個放東西的架子),程式裡用了四次:Shelf<int>、Shelf<double>、Shelf<string>、Shelf<object>。原始碼只有一份。那 CPU 真正在跑的程式碼,有幾份?
直覺會猜一份或四份。實際是三份:int 一份、double 一份,string 跟 object 合用一份。「動手做」會讓你親眼看到。
一句話:值型別每種大小不同,所以每種各做一份機器碼;參考型別的格子裡都是一樣大的地址,所以共用一份。
dotnet build 產出的不是 CPU 能直接執行的指令,而是一份「中間碼」(給 .NET 看的通用指令)。CPU 只看得懂機器碼,中間碼總得被翻譯。這一步認識負責翻譯的人。
它叫 JIT(just-in-time,「臨時才翻」的編譯器)。程式跑起來後,某個方法第一次被呼叫,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> 都用同一份。
圖 B:同一個 Shelf<T>,T 不同,格子大小就不同(點圖可放大)
這也是上一篇〈泛型與裝箱〉留下的答案:int 版機器碼知道格子就是 int,所以 int 直接放進陣列,不用開盒子。
一句話:值型別大小各不同,所以各翻一份;專用版裡值直接放,不用裝箱。
那 string、object、你寫的 class 呢?一個程式的 class 可能上百個,每個都翻一份太浪費。這一步看 JIT 怎麼省。
參考型別的格子裡放的是地址,不管指向 string 還是你的 class,地址都一樣大(64 位元是 8 bytes)。搬地址的指令一模一樣,所以 JIT 只翻一份,大家共用。
這份共用版叫 __Canon 版(canonical,「標準版」的意思)。T 只要是參考型別,都用它。
圖 C:四個封閉型別,JIT 只翻了三份(動畫會循環,點圖可放大)
那共用的程式碼裡寫 typeof(T),怎麼知道這次是 string 還是 object?執行時會另外帶著一份「這次 T 是誰」的資訊(官方叫 generic dictionary),需要時去查。(這裡我簡化了,細節見參考來源的 runtime 設計文件。)
一句話:地址都一樣大,所以參考型別只翻一份 __Canon 版。
Shelf<string> 跟 Shelf<object> 用同一份機器碼。那它們是同一個型別嗎?泛型類別的 static 欄位也只有一份嗎?
共用的只有機器碼。.NET 執行時替每個封閉型別各準備一份「型別資料」,記著它叫什麼、T 是誰。static 欄位跟著這份資料走,所以每個封閉型別各有一份,Microsoft 文件也這樣寫。
圖 D:左邊三份資料各自獨立,右邊只有兩份機器碼(點圖可放大)
一句話:機器碼可以共用,型別不會合併。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 實際跑出來的。
建檔把 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。
建檔把 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)。
建檔把 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 1.18 的泛型做法很像,叫 GC shape stenciling:把型別依記憶體長相(GC shape)分組,同一組只產一份機器碼,再用隱藏參數 dictionary 告訴它真正的型別。
| C#(.NET) | Go | |
|---|---|---|
| 什麼時候產機器碼 | 執行時,JIT 第一次用到 | 編譯時 |
| 怎麼分組 | 值型別每種一份;參考型別合成一份 | 底層型別相同的同一組;所有指標型別同一組 |
| 怎麼知道 T 是誰 | 執行時帶著的型別資訊 | 隱藏參數 dictionary |
想法一樣:長得一樣就共用。但 Go 的 string 不是指標,不會跟 *User 同組。
能不看筆記回答下面 4 題,這篇就過了。