程式語言 · 泛型

泛型與裝箱:抽屜每格先貼好標籤,int 直接放進去(C# List<T> 與 ArrayList)

Chance Lu · 學習文章 · 2026-11-02 22:04
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<int>:每格貼好「int」標籤 1 2 ✕ 擋下 "三" 拿出來直接是 int,不用轉 寫的當下就被擋 程式還沒跑就看到錯

總覽:沒貼標籤的清單什麼都收,放錯要等程式跑起來才爆;貼好標籤的,寫的當下就被擋(點圖可放大)

第一步:還沒有泛型的時候,大家怎麼寫

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

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

第二步:List<T> 裡的 T 是什麼

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

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

寫一次:List<T>T=先空著的型別 編譯器把 T 換成你填的型別 List<int>只收 int List<string>只收 string List<Order>只收你自己定義的 Order

圖 A:List<T> 只寫一次,T 填不同型別,就得到只收那種型別的清單(點圖可放大)

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

第三步:裝箱,ArrayList 慢在哪

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

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

ArrayList:值要先裝進盒子 int 42你的值 heap:另外開的記憶體 盒子(24 bytes) object 外包裝+42 地址 … … … 陣列:每格只記「盒子在哪」 (int)list[0]檢查是 int ✓ → 抄出 42 List<int>:值直接放進陣列 int 42你的值 42 … … … 直接抄進去,不開盒子 讀的時候直接拿,不用檢查 ① list.Add(42):在 heap 開一個新盒子,把 42 抄進去(裝箱) ② 陣列那一格只存「盒子的地址」,不是 42 本身 ③ 讀 list[0]:先檢查盒子裡真的是 int,再把 42 抄出來 ④ List<int>:42 直接放進陣列那一格,讀的時候直接拿

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

算一下大小(64 位元電腦):

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

一句話:ArrayList 每放一個 int 就多開一個盒子;List<int> 直接放值。

常見的坑

動手做

先開一個練習資料夾:

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<int> 在寫的當下就擋下,程式根本跑不起來。

練習 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<int> 是 4 bytes × 一千萬 = 40 MB。

✔ 過關標準

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

  1. 沒有泛型時,「每種型別抄一份」和「全部當 object」各有什麼代價?看答案收合
    抄一份:同樣的程式要改好幾處;當 object:放錯要執行時才爆,放 int 還要裝箱。
  2. int 放進 ArrayList 時多做了哪些事?List<int> 為什麼不用?看答案收合
    要在 heap 開盒子、抄值、存地址;讀時要檢查再抄回來。
    • ArrayList 每格是 object,只能放地址。
    • List<int> 裡面就是每格放 int 的陣列,值直接放進去。
  3. 一千萬個 int:ArrayList 320 MB、List<int> 40 MB,怎麼算出來的?看答案收合
    320 = 80 MB 的地址(每格 8 bytes)+ 240 MB 的盒子(每個 24 bytes);40 = 每格 4 bytes 的 int。

參考來源