程式語言 · 泛型數學

泛型數學:一份加總,int、double、decimal 都能用(C# INumber<T>)

Chance Lu · 學習文章 · 2026-11-04 20:51
Chance Lu

假設你在做一個小商城。商品價格用 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# 的成員分成兩種:

運算子屬於靜態成員。型別自己定義 + 的寫法是 operator +,C# 規定它要宣告成 static。所以 a + b 其實是在叫「int 這個型別的 +」。

再看 where 怎麼運作:它要求 T 實作某個介面,介面列出 T 保證會的東西。問題是,C# 11 以前,介面只能要求實例成員。

實例成員:要先有一個值 a.CompareTo(b) a.ToString() 例:IComparable<T> 要求的就是 CompareTo 介面要求得到 → where 能保證 靜態成員:掛在型別身上 a + b=叫 int 的 static + int.MaxValue型別本身的值 還有「這個型別的 0」也算這一類 C# 11 以前:沒有介面能要求

圖 A:左欄介面要求得到,右欄以前要求不到;+ 剛好在右欄(點圖可放大)

這段在講:IComparable<T> 行得通,是因為 CompareTo 是實例成員。+ 是靜態的,以前沒有介面能保證「T 會 +」,所以 where 幫不上忙。

第二步:static abstract,介面終於能要求「型別會 +」

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:

total + x編譯器要檢查這行 ① 沒寫 where T 保證會什麼?只有 object 的:Equals、ToString… ✕ CS0019清單上沒有 + ② where T : INumber<T> T 保證會什麼?+、-、*、/、Zero…(INumber 列的) ✓ 編譯通過清單上有 + 1. 編譯器看到 total + x,T 還不知道是誰 2. 沒約束:去查「每個 T 都保證會的」,只剩 object 的成員 3. 找不到 +,報錯 CS0019 4. 有 INumber<T>:介面用 static abstract 要求了 + 5. 每個 T 都保證會 +,編譯通過

圖 B:同一行 total + x,沒約束查不到 +,有 INumber<T> 就查得到(動畫會循環,點圖可放大)

這段在講:static abstract 讓介面能要求靜態成員,INumber<T> 把 +、Zero 這些數字該有的東西列好了。where 一寫,編譯器就確定每個 T 都會 +。

第三步:Sum 寫一次,三種數字都能用

有了約束,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 的 +。

Sum<T>只寫一次 T = int → 用 int 的 +{ 85, 92, 70 } → 247 T = double → 用 double 的 +{ 0.1, 0.2 } → 0.30000000000000004(二進位小數的誤差) T = decimal → 用 decimal 的 +{ 0.1m, 0.2m } → 0.3(十進位,算錢用它)

圖 C:一份 Sum<T>,T 換成誰就用誰自己的 +;結果都來自練習 2(點圖可放大)

會不會像 ArrayList 那樣裝箱?不會。int、double、decimal 是實值型別,執行時每一種各有一份專屬程式碼,值直接放,就像替 int 手寫了一份 SumInt(怎麼做到的,講泛型怎麼執行的那篇會說)。練習 3 實測:多配置 0 bytes。

這段在講:Sum 只寫一次,T 是誰就用誰的 +,也沒有裝箱。

Go 對照:直接列出誰能當 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 用運算子,這個運算子要對集合裡每個型別都成立。名單上全是會 + 的數字,+ 就能用。

C#:列出 T 要會什麼 where T : INumber<T> 保證會:+ - * / < > 保證有:T.Zero、T.One 型別實作了這個介面,就能當 T Go:列出 T 可以是誰 ~int | ~int64 | ~float64 int int64 float64 Score(底層 int) 名單上每個都會 + → + 就能用

圖 D:C# 用介面列能力,Go 用名單列型別(點圖可放大)

現成名單也有:比大小用標準庫 cmp.Ordered(Go 1.21 起),整數、浮點數用 x/exp/constraints 的 Integer、Float。

這段在講:兩邊都在跟編譯器保證「T 一定會 +」。C# 靠介面列能力,Go 靠名單列型別。

常見的坑

動手做

先開一個練習資料夾:

mkdir lab && cd lab && dotnet new console

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

練習 1:沒有約束,+ 編不過

建檔把開頭那段 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 的 ①)。

練習 2:加上 INumber<T>,三種數字一起跑

建檔把 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)。

練習 3:有沒有裝箱?量給你看

建檔保留練習 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 題,這篇就過了。

  1. 為什麼 C# 11 以前,where 寫什麼都沒辦法讓 T 能用 +?看答案收合
    + 是靜態成員,而以前的介面只能要求實例成員。
    • where 靠介面保證 T 會什麼。CompareTo 是實例成員,要求得到;+ 掛在型別上,要求不到。
    • 沒有保證,就報 CS0019。
  2. static abstract 改變了什麼?寫上 where T : INumber<T> 後,方法裡多了什麼能用?看答案收合
    介面能要求型別提供靜態成員了,包括運算子。
    • INumber<T> 要求了 +、-、*、/、Zero 等,所以 a + b、T.Zero 都編得過。
    • T 是 int 就用 int 的 +,是 decimal 就用 decimal 的。
  3. 泛型 Sum 用在 int 上會不會裝箱?Go 又是怎麼讓 T 能用 + 的?看答案收合
    不會裝箱;Go 靠型別集合,名單上每個型別都會 +,+ 就能用。
    • 實值型別執行時各有專屬程式碼,練習 3 量到 0 bytes。
    • Go 規格:運算子要對集合裡每個型別都成立。

參考來源