程式語言 · 泛型數學

# 泛型數學：一份加總，int、double、decimal 都能用（C# INumber&lt;T&gt;）

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;
}
```

[約束那篇](https://docs.smartech.tw/n/generics-constraints-where)講過：T 沒加約束，只能用 object 有的東西；要更多能力，就用 `where` 加。可是這次，**where 後面寫什麼都沒用**。問題不在 T，在 **+ 長在哪裡**。

> 一句話：**泛型數學**（generic math）就是「數學演算法只寫一次，各種數字型別都能用」。C# 11 讓介面能要求「型別本身要會 +」，寫上 `where T : INumber<T>`，`total + x` 就編得過。

## 第一步：+ 長在型別上，不在值上

先把 C# 的成員分成兩種：

- **實例成員**：掛在**某個值**身上，要先有值才能叫，例如 `a.CompareTo(b)`。
- **靜態成員**（static）：掛在**型別**身上，不用先有值，例如 `int.MaxValue`、`int.Parse("5")`。

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

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

實例成員：要先有一個值

a.CompareTo(b)
a.ToString()
例：IComparable&lt;T&gt; 要求的就是 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&lt;T&gt;
T 保證會什麼？+、-、\*、/、Zero…（INumber 列的）
✓ 編譯通過清單上有 +

1. 編譯器看到 total + x，T 還不知道是誰
2. 沒約束：去查「每個 T 都保證會的」，只剩 object 的成員
3. 找不到 +，報錯 CS0019
4. 有 INumber&lt;T&gt;：介面用 static abstract 要求了 +
5. 每個 T 都保證會 +，編譯通過

圖 B：同一行 total + x，沒約束查不到 +，有 INumber&lt;T&gt; 就查得到（動畫會循環，點圖可放大）

> 這段在講：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&lt;T&gt;只寫一次
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&lt;T&gt;，T 換成誰就用誰自己的 +；結果都來自練習 2（點圖可放大）

會不會像 ArrayList 那樣[裝箱](https://docs.smartech.tw/n/generics-boxing-list-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&lt;T&gt;

保證會：+　-　\*　/　&lt;　&gt;
保證有：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 靠名單列型別。

## 常見的坑

- **不能寫 `T total = 0;`**：0 是 int，不是 T，會得到 CS0029。用 `T.Zero`；要 2 就寫 `T.CreateChecked(2)`（把 2 換成 T 型別）。
- **不能寫 `INumber<int>.Zero`**：static abstract 成員只能透過型別參數（也就是 T）叫（CS8926）。在泛型方法裡寫 `T.Zero`。
- **Sum&lt;int&gt; 一樣會溢位**：用的就是 int 的 +，實測 `int.MaxValue + 1` 得 `-2147483648`。需要時讓輸出用更大的型別（.NET 部落格的 `Sum<T, TResult>`）。

## 動手做

先開一個練習資料夾：

```
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&lt;T&gt;，三種數字一起跑

建檔把 `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&lt;T&gt; 後，方法裡多了什麼能用？
**介面能要求型別提供靜態成員了，包括運算子。**
    - `INumber<T>` 要求了 +、-、\*、/、`Zero` 等，所以 `a + b`、`T.Zero` 都編得過。
    - T 是 int 就用 int 的 +，是 decimal 就用 decimal 的。
3. 泛型 Sum 用在 int 上會不會裝箱？Go 又是怎麼讓 T 能用 + 的？
**不會裝箱；Go 靠型別集合，名單上每個型別都會 +，+ 就能用。**
    - 實值型別執行時各有專屬程式碼，練習 3 量到 0 bytes。
    - Go 規格：運算子要對集合裡每個型別都成立。

## 參考來源

- [Microsoft Learn — Generic math](https://learn.microsoft.com/en-us/dotnet/standard/generics/math)（INumber&lt;T&gt;、Zero、CreateChecked）
- [Microsoft Learn — Explore static virtual members in C# interfaces](https://learn.microsoft.com/en-us/dotnet/csharp/advanced-topics/interface-implementation/static-virtual-interface-members)（static abstract 成員）
- Microsoft Learn — [interface keyword](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/interface)（T 是 int 就呼叫 int 的靜態成員）、[Operator overloading](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/operator-overloading)（運算子要宣告成 static）
- [Microsoft Learn — Generics in the runtime](https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/generics/generics-in-the-run-time)（實值型別各有專屬版本）
- [.NET Blog — .NET 7 Preview 5 – Generic Math](https://devblogs.microsoft.com/dotnet/dotnet-7-generic-math/)（Sum&lt;T, TResult&gt;）
- [The Go Programming Language Specification](https://go.dev/ref/spec)（型別集合、~T、運算子規則）、[package cmp](https://pkg.go.dev/cmp)（Go 1.21）、[x/exp/constraints](https://pkg.go.dev/golang.org/x/exp/constraints)
