假設你在做一個小商城。商品價格用 decimal 存,評分用 int 存,兩邊都要算總和。你寫了 SumDecimal,再複製一份改成 SumInt。兩份迴圈一模一樣,只差型別。
學過泛型,你會想把它們合成一份:
static T Sum<T>(T[] xs)
{
T total = default!; // T 的預設值(! 先不用管)
foreach (var x in xs)
total = total + x; // 編譯錯誤 CS0019
return total;
}
約束那篇講過:T 沒加約束,只能用 object 有的東西;要更多能力,就用 where 加。可是這次,where 後面寫什麼都沒用。問題不在 T,在 + 長在哪裡。
一句話:泛型數學(generic math)就是「數學演算法只寫一次,各種數字型別都能用」。C# 11 讓介面能要求「型別本身要會 +」,寫上where T : INumber<T>,total + x就編得過。
先把 C# 的成員分成兩種:
a.CompareTo(b)。int.MaxValue、int.Parse("5")。運算子屬於靜態成員。型別自己定義 + 的寫法是 operator +,C# 規定它要宣告成 static。所以 a + b 其實是在叫「int 這個型別的 +」。
再看 where 怎麼運作:它要求 T 實作某個介面,介面列出 T 保證會的東西。問題是,C# 11 以前,介面只能要求實例成員。
圖 A:左欄介面要求得到,右欄以前要求不到;+ 剛好在右欄(點圖可放大)
這段在講:IComparable<T> 行得通,是因為 CompareTo 是實例成員。+ 是靜態的,以前沒有介面能保證「T 會 +」,所以 where 幫不上忙。
C# 11(.NET 7)補上了這塊。介面裡可以寫 static abstract 成員:前面加 static abstract、不寫內容,意思是「實作我的型別,要自己提供這個靜態成員」。
.NET 7 用它做了一組數字介面,最常用的是 INumber<T>:「T 是一種數字」。簡化後長這樣:
// 示意:真正的定義拆在好幾個介面裡
interface INumber<T>
{
static abstract T Zero { get; } // 這個型別的 0
static abstract T operator +(T left, T right); // 這個型別的 +
// …還有 - * / < >
}
int、double、decimal 都實作了它。int 實作的是 INumber<int>:T 在角括號裡再出現一次,表示「跟自己同型別」,int 加 int 得到 int。
現在 where 有東西可以要求了。看編譯器怎麼檢查 total + x:
圖 B:同一行 total + x,沒約束查不到 +,有 INumber<T> 就查得到(動畫會循環,點圖可放大)
這段在講:static abstract 讓介面能要求靜態成員,INumber<T> 把 +、Zero 這些數字該有的東西列好了。where 一寫,編譯器就確定每個 T 都會 +。
有了約束,Sum 只要改兩處:加上 where;起點改成 T.Zero,因為 0 是 int,不一定是 T。(INumber 在 System.Numerics。)
static T Sum<T>(T[] xs) where T : INumber<T>
{
T total = T.Zero; // 直接在 T 上叫靜態成員
foreach (var x in xs)
total = total + x;
return total;
}
T 是誰,就用誰的 +。傳 int 陣列,這行 + 就是 int 的 +;傳 decimal 陣列,就是 decimal 的 +。
圖 C:一份 Sum<T>,T 換成誰就用誰自己的 +;結果都來自練習 2(點圖可放大)
會不會像 ArrayList 那樣裝箱?不會。int、double、decimal 是實值型別,執行時每一種各有一份專屬程式碼,值直接放,就像替 int 手寫了一份 SumInt(怎麼做到的,講泛型怎麼執行的那篇會說)。練習 3 實測:多配置 0 bytes。
這段在講:Sum 只寫一次,T 是誰就用誰的 +,也沒有裝箱。
Go 反過來。C# 列的是 T「要會什麼」;Go 列的是 T「可以是誰」,這份名單叫型別集合(type set)。
type Number interface {
~int | ~int64 | ~float64
}
func Sum[T Number](xs []T) T {
var total T // 數字的零值就是 0
for _, x := range xs {
total = total + x
}
return total
}
| 是「或」;~int 是「底層是 int 的都算」,所以自訂的 type Score int 也進得來。Go 規格規定:對 T 用運算子,這個運算子要對集合裡每個型別都成立。名單上全是會 + 的數字,+ 就能用。
圖 D:C# 用介面列能力,Go 用名單列型別(點圖可放大)
現成名單也有:比大小用標準庫 cmp.Ordered(Go 1.21 起),整數、浮點數用 x/exp/constraints 的 Integer、Float。
這段在講:兩邊都在跟編譯器保證「T 一定會 +」。C# 靠介面列能力,Go 靠名單列型別。
T total = 0;:0 是 int,不是 T,會得到 CS0029。用 T.Zero;要 2 就寫 T.CreateChecked(2)(把 2 換成 T 型別)。INumber<int>.Zero:static abstract 成員只能透過型別參數(也就是 T)叫(CS8926)。在泛型方法裡寫 T.Zero。int.MaxValue + 1 得 -2147483648。需要時讓輸出用更大的型別(.NET 部落格的 Sum<T, TResult>)。先開一個練習資料夾:
mkdir lab && cd lab && dotnet new console
把 Program.cs 整個換掉再執行。先猜、再跑、再對答案。輸出是在 .NET 10 實際跑出來的。
建檔把開頭那段 Sum 整段貼進 Program.cs,最上面加兩行:
int[] scores = { 85, 92, 70 };
Console.WriteLine(Sum(scores));
先猜傳的明明是 int,會過嗎?
執行dotnet build:
Program.cs(7,17): error CS0019: Operator '+' cannot be applied to operands of type 'T' and 'T'
你會看到編譯器不管你這次傳了 int,它只看「每個 T 都保證會的」,裡面沒有 +(圖 B 的 ①)。
建檔把 Program.cs 換成下面這段,再把第三步的 Sum 貼在最下面:
using System.Numerics;
int[] scores = { 85, 92, 70 };
double[] temps = { 0.1, 0.2 };
decimal[] prices = { 0.1m, 0.2m };
Console.WriteLine(Sum(scores));
Console.WriteLine(Sum(temps));
Console.WriteLine(Sum(prices));
先猜0.1 + 0.2,double 和 decimal 印出來一樣嗎?
執行dotnet run:
247
0.30000000000000004
0.3
這證明同一份 Sum,double 和 decimal 結果不同:各自用了自己的 +(圖 C)。
建檔保留練習 2 最下面的 Sum,上面換成:
using System.Numerics;
int[] nums = new int[10_000_000];
Array.Fill(nums, 1);
long before = GC.GetAllocatedBytesForCurrentThread();
int total = Sum(nums);
long after = GC.GetAllocatedBytesForCurrentThread();
Console.WriteLine($"總和 {total},多配置了 {after - before} bytes");
先猜加總一千萬個 int,泛型版會多開幾個盒子?
執行dotnet run -c Release:
總和 10000000,多配置了 0 bytes
這證明0 bytes=一個盒子都沒開。換成 ArrayList 的寫法,每個 int 都要一個 24 bytes 的盒子。
| 名詞 | 白話 |
|---|---|
| 靜態成員(static) | 掛在型別身上的東西,不用先有值,例如 int.MaxValue、運算子 + |
| static abstract 介面成員 | 介面寫「實作我的型別,要自己提供這個靜態成員」(C# 11 起) |
INumber<T> | .NET 7 內建的「T 是一種數字」介面,保證有 +、-、*、/、Zero 等 |
| 型別集合(Go) | 約束裡列出「哪些型別能當 T」的名單,例如 ~int | ~float64 |
能不看筆記回答下面 3 題,這篇就過了。
INumber<T> 要求了 +、-、*、/、Zero 等,所以 a + b、T.Zero 都編得過。