假設你要存一串分數,像 85、92、70。C# 裡有兩種清單裝得下:比較早期的 ArrayList,和後來的 List<int>。平常用起來差不多,差別在出錯的時候:不小心塞了一個字串進去,ArrayList 會照收,等程式跑到那一行才當掉;List<int> 在你還沒執行之前就會被擋下來,編輯器裡直接畫紅線。速度也差很多,後面實測 ArrayList 慢了二、三十倍。
這個差別來自 List<int> 用了泛型(generics)。寫清單的人沒有把「裡面裝什麼」寫死,而是留了一個位子,等你用的時候再告訴它。你寫 List<int>,它就是只裝 int 的清單;寫 List<string>,就是只裝字串的清單。
一句話:泛型就像每一格都先貼好標籤的抽屜。貼著「int」的抽屜,你想塞字串進去,還沒執行就會被擋下來;拿出來的時候也不用再檢查裡面是什麼。
總覽:沒貼標籤的清單什麼都收,放錯要等程式跑起來才爆;貼好標籤的,寫的當下就被擋(點圖可放大)
假設你要寫一個「找出最大值」的函式,整數要用,小數也要用。在 C# 還沒有泛型的年代,只有兩種做法,各有各的麻煩:
MaxInt、再寫一個 MaxDouble,裡面的邏輯一模一樣。哪天發現 bug,每一份都要記得去改。object:只寫一份,什麼都收。代價是編譯器不知道裡面到底裝了什麼,拿出來時你得自己轉回原本的型別;轉錯了,要等程式跑到那裡才當掉。ArrayList 用的就是這種做法。這段在講:以前想要「一份程式到處用」,就得放棄「放錯會被擋下來」,兩個只能選一個。泛型出現之後,兩個都能有。
你在文件上看到的 List<T>,那個 T 是一個代號,代表「之後才決定的型別」,正式名稱叫型別參數。就像抽屜上那張還沒寫字的標籤:抽屜只做一次,每個用的人在標籤上寫自己要裝什麼。
你寫 List<int>,就是在標籤上寫了 int。編譯器會照這個來檢查:這個清單只能放 int,你想放字串,它當場就告訴你不行。
圖 A:List<T> 只寫一次,T 填不同型別,就得到只收那種型別的清單(點圖可放大)
這段在講:List<int> 底下還是同一種清單,只是你先告訴編譯器「這裡面只放 int」,它就會一路幫你把關。
先分清楚 C# 的兩種型別:
int、double、struct):變數裡直接裝著值。object、string、class):變數裡裝的是「東西在哪」的地址。ArrayList 每一格都是 object,只能放地址。所以 int 放進去前,得先在 heap(程式另外開來放物件的記憶體)開一個盒子,把值抄進去,再把盒子的地址放進格子。這叫裝箱。讀出來時,要先檢查盒子裡真的是 int,再把值抄回來。
圖 B:同樣放一個 42,ArrayList 多了「開盒子、存地址、檢查、抄出來」四件事(動畫會循環,點圖可放大)
算一下大小(64 位元電腦):
放一千萬個 int,就是一千萬個盒子。練習 2 實測下來,記憶體差 8 倍,時間差二、三十倍。
一句話:ArrayList 每放一個 int 就多開一個盒子;List<int> 直接放值。
ArrayList。微軟文件明講不建議,改用 List<T>。真的要混放不同型別,用 List<object>。先開一個練習資料夾:
mkdir lab && cd lab && dotnet new console
每個練習都是把 Program.cs 整個換掉再執行。先猜、再跑、再對答案。輸出是在 .NET 10 實際跑出來的。
建檔把 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> 在寫的當下就擋下,程式根本跑不起來。
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 題,這篇就過了。