程式語言 · 泛型

# 泛型與裝箱：抽屜每格先貼好標籤，int 直接放進去（C# List&lt;T&gt; 與 ArrayList）

Chance Lu

假設你要存一串分數，像 85、92、70。C# 裡有兩種清單裝得下：比較早期的 `ArrayList`，和後來的 `List<int>`。平常用起來差不多，差別在出錯的時候：不小心塞了一個字串進去，`ArrayList` 會照收，等程式跑到那一行才當掉；`List<int>` 在你還沒執行之前就會被擋下來，編輯器裡直接畫紅線。速度也差很多，後面實測 `ArrayList` 慢了二、三十倍。

這個差別來自 `List<int>` 用了**泛型**（generics）。寫清單的人沒有把「裡面裝什麼」寫死，而是留了一個位子，等你用的時候再告訴它。你寫 `List<int>`，它就是只裝 int 的清單；寫 `List<string>`，就是只裝字串的清單。

> 一句話：泛型就像**每一格都先貼好標籤的抽屜**。貼著「int」的抽屜，你想塞字串進去，**還沒執行就會被擋下來**；拿出來的時候也不用再檢查裡面是什麼。

ArrayList：沒貼標籤，什麼都收
1
2
"三"
混進來了

拿出來要自己轉：(int)item
程式跑起來才爆
要等走到那一行才發現

List&lt;int&gt;：每格貼好「int」標籤
1
2
✕ 擋下
"三"

拿出來直接是 int，不用轉
寫的當下就被擋
程式還沒跑就看到錯

總覽：沒貼標籤的清單什麼都收，放錯要等程式跑起來才爆；貼好標籤的，寫的當下就被擋（點圖可放大）

## 第一步：還沒有泛型的時候，大家怎麼寫

假設你要寫一個「找出最大值」的函式，整數要用，小數也要用。在 C# 還沒有泛型的年代，只有兩種做法，**各有各的麻煩**：

- **每種型別各寫一份**：寫一個 `MaxInt`、再寫一個 `MaxDouble`，裡面的邏輯一模一樣。哪天發現 bug，每一份都要記得去改。
- **全部當成 `object`**：只寫一份，什麼都收。代價是編譯器不知道裡面到底裝了什麼，拿出來時你得自己轉回原本的型別；轉錯了，要等程式跑到那裡才當掉。`ArrayList` 用的就是這種做法。

> 這段在講：以前想要「一份程式到處用」，就得放棄「放錯會被擋下來」，兩個只能選一個。泛型出現之後，兩個都能有。

## 第二步：List&lt;T&gt; 裡的 T 是什麼

你在文件上看到的 `List<T>`，那個 `T` 是一個**代號**，代表「之後才決定的型別」，正式名稱叫**型別參數**。就像抽屜上那張還沒寫字的標籤：抽屜只做一次，每個用的人在標籤上寫自己要裝什麼。

你寫 `List<int>`，就是在標籤上寫了 int。編譯器會照這個來檢查：這個清單只能放 int，你想放字串，它當場就告訴你不行。

寫一次：List&lt;T&gt;T＝先空著的型別
編譯器把 T 換成你填的型別

List&lt;int&gt;只收 int
List&lt;string&gt;只收 string
List&lt;Order&gt;只收你自己定義的 Order

圖 A：List&lt;T&gt; 只寫一次，T 填不同型別，就得到只收那種型別的清單（點圖可放大）

> 這段在講：`List<int>` 底下還是同一種清單，只是你先告訴編譯器「這裡面只放 int」，它就會一路幫你把關。

## 第三步：裝箱，ArrayList 慢在哪

先分清楚 C# 的兩種型別：

- **實值型別**（`int`、`double`、`struct`）：變數裡**直接裝著值**。
- **參考型別**（`object`、`string`、`class`）：變數裡裝的是**「東西在哪」的地址**。

`ArrayList` 每一格都是 `object`，只能放地址。所以 int 放進去前，得先在 **heap**（程式另外開來放物件的記憶體）開一個**盒子**，把值抄進去，再把盒子的地址放進格子。這叫**裝箱**。讀出來時，要先檢查盒子裡真的是 int，再把值抄回來。

ArrayList：值要先裝進盒子
int 42你的值

heap：另外開的記憶體

盒子（24 bytes）
object 外包裝＋42

地址
…
…
…
陣列：每格只記「盒子在哪」

(int)list[0]檢查是 int ✓ → 抄出 42
List&lt;int&gt;：值直接放進陣列
int 42你的值
42
…
…
…

直接抄進去，不開盒子
讀的時候直接拿，不用檢查

① list.Add(42)：在 heap 開一個新盒子，把 42 抄進去（裝箱）
② 陣列那一格只存「盒子的地址」，不是 42 本身
③ 讀 list[0]：先檢查盒子裡真的是 int，再把 42 抄出來
④ List&lt;int&gt;：42 直接放進陣列那一格，讀的時候直接拿

圖 B：同樣放一個 42，ArrayList 多了「開盒子、存地址、檢查、抄出來」四件事（動畫會循環，點圖可放大）

算一下大小（64 位元電腦）：

- **一個盒子 24 bytes**：除了 4 bytes 的值，還帶物件標頭和型別資訊，湊整是 24 bytes。
- **ArrayList 每格 8 bytes**：放的是地址。
- **List&lt;int&gt; 每格 4 bytes**：它裡面就是一個每格直接放 int 的陣列，沒有盒子。

放一千萬個 int，就是**一千萬個盒子**。練習 2 實測下來，記憶體差 **8 倍**，時間差**二、三十倍**。

> 一句話：ArrayList 每放一個 int 就多開一個盒子；List&lt;int&gt; 直接放值。

## 常見的坑

- **新程式別用 `ArrayList`。**微軟文件明講不建議，改用 `List<T>`。真的要混放不同型別，用 `List<object>`。

## 動手做

先開一個練習資料夾：

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

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

### 練習 1：放錯東西，誰先發現？

建檔把 `Program.cs` 換成：

```
using System.Collections;

var items = new ArrayList();
items.Add(1);
items.Add(2);
items.Add("三");          // 編譯器不攔：ArrayList 什麼都收

int sum = 0;
foreach (object item in items)
{
    sum += (int)item;   // 拿出來要自己轉回 int
}
Console.WriteLine($"總和 = {sum}");
```

先猜會印出 `總和 = 3`，還是出錯？在什麼時候出錯？

執行`dotnet run`

```
Unhandled exception. System.InvalidCastException: Unable to cast object of type 'System.String' to type 'System.Int32'.
```

再試把 `Program.cs` 換成泛型版：

```
var nums = new List<int>();
nums.Add(1);
nums.Add(2);
nums.Add("三");         // 這次編譯器會攔嗎？

int sum = 0;
foreach (int n in nums)
{
    sum += n;           // 不用轉型
}
Console.WriteLine($"總和 = {sum}");
```

`dotnet run`：

```
Program.cs(4,10): error CS1503: Argument 1: cannot convert from 'string' to 'int'
```

這證明ArrayList 要到**程式跑起來、走到那一行**才爆；List&lt;int&gt; 在**寫的當下**就擋下，程式根本跑不起來。

### 練習 2：親手量裝箱的代價

`Measure` 怎麼寫的不用看懂，只看輸出。

建檔把 `Program.cs` 換成：

```
using System.Collections;
using System.Diagnostics;

const int N = 10_000_000;            // 一千萬個 int

for (int round = 1; round <= 3; round++)
{
    Console.WriteLine($"--- 第 {round} 輪 ---");
    Measure("ArrayList", () =>
    {
        var list = new ArrayList(N);
        for (int i = 0; i < N; i++) list.Add(i);        // 每次 Add 都裝箱
        long sum = 0;
        for (int i = 0; i < N; i++) sum += (int)list[i]!; // 每次讀都要檢查、抄出來
        return sum;
    });
    Measure("List<int>", () =>
    {
        var list = new List<int>(N);
        for (int i = 0; i < N; i++) list.Add(i);        // 直接放 int
        long sum = 0;
        for (int i = 0; i < N; i++) sum += list[i];      // 直接拿 int
        return sum;
    });
}

static void Measure(string name, Func<long> work)
{
    GC.Collect();
    long bytesBefore = GC.GetAllocatedBytesForCurrentThread();
    int gcBefore = GC.CollectionCount(0);
    var sw = Stopwatch.StartNew();

    long sum = work();

    sw.Stop();
    long mb = (GC.GetAllocatedBytesForCurrentThread() - bytesBefore) / 1_000_000;
    int gcs = GC.CollectionCount(0) - gcBefore;
    Console.WriteLine($"{name,-10} 花 {sw.ElapsedMilliseconds,4} ms，配置 {mb,4} MB，GC {gcs,3} 次（sum={sum}）");
}
```

先猜兩邊總和一樣。記憶體差幾倍？時間差幾倍？

執行量效能一定要用 Release 模式：

```
dotnet run -c Release
```

```
--- 第 1 輪 ---
ArrayList  花  461 ms，配置  320 MB，GC  30 次（sum=49999995000000）
List<int>  花   12 ms，配置   40 MB，GC   0 次（sum=49999995000000）
--- 第 2 輪 ---
ArrayList  花  253 ms，配置  320 MB，GC  29 次（sum=49999995000000）
List<int>  花   11 ms，配置   40 MB，GC   0 次（sum=49999995000000）
--- 第 3 輪 ---
ArrayList  花  435 ms，配置  320 MB，GC  30 次（sum=49999995000000）
List<int>  花   11 ms，配置   40 MB，GC   0 次（sum=49999995000000）
```

你會看到記憶體每輪都是 **320 MB 對 40 MB**；時間每輪都慢 20 倍以上。**你的毫秒會不同，MB 應該一樣。**（GC＝系統回收沒人用的記憶體，那些盒子要靠它收。）

這證明用第三步的數字算：8 bytes × 一千萬 ＝ 80 MB 的地址，加上 24 bytes × 一千萬 ＝ 240 MB 的盒子，正好 320 MB。List&lt;int&gt; 是 4 bytes × 一千萬 ＝ 40 MB。

## ✔ 過關標準

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

1. 沒有泛型時，「每種型別抄一份」和「全部當 object」各有什麼代價？
**抄一份：同樣的程式要改好幾處；當 object：放錯要執行時才爆，放 int 還要裝箱。**
2. int 放進 ArrayList 時多做了哪些事？List&lt;int&gt; 為什麼不用？
**要在 heap 開盒子、抄值、存地址；讀時要檢查再抄回來。**
    - ArrayList 每格是 object，只能放地址。
    - List&lt;int&gt; 裡面就是每格放 int 的陣列，值直接放進去。
3. 一千萬個 int：ArrayList 320 MB、List&lt;int&gt; 40 MB，怎麼算出來的？
**320 ＝ 80 MB 的地址（每格 8 bytes）＋ 240 MB 的盒子（每個 24 bytes）；40 ＝ 每格 4 bytes 的 int。**

## 參考來源

- [Microsoft Learn — Generics in .NET](https://learn.microsoft.com/en-us/dotnet/standard/generics/)（型別參數、型別安全、泛型集合不必裝箱）
- [Microsoft Learn — Boxing and Unboxing](https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/types/boxing-and-unboxing)（裝箱在 heap 配置新物件並複製值、讀出時先檢查型別）
- [Microsoft Learn — ArrayList](https://learn.microsoft.com/en-us/dotnet/api/system.collections.arraylist)（不建議新程式使用，改用 List&lt;T&gt; 或 List&lt;object&gt;）
- [Microsoft Learn — List&lt;T&gt;：Performance considerations](https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.list-1)（值型別元素不必裝箱）
