跳转到内容

第 9 章 · 泛型

解决什么问题:同一段逻辑要用在不同类型上,既不想复制几份,也不想丢掉类型检查。

四种语言的态度:TS 和 Python 的泛型只给检查器看,运行时擦掉;C# 由运行时直接支持;Go 1.18 才加入,实现取了折中;JS 没有,也不需要。

写一个能装任意类型的栈:压入 1、2、3,看一眼栈顶,再依次全部弹出。再写一个通用的“按规则取最大”函数:调用方给出一组元素和一条规则(把元素换算成一个可比较的值),函数返回换算结果最大的那个元素。用它从单词 type、generics、go 里取最长的,从 7、42、19 里取最大的。栈要对整数和字符串都能用,取最大的函数也一样,而且装错类型时最好有人提醒。

不用泛型,写一个“对什么类型都能用”的栈只有两条路。一是复制:整数栈、字符串栈各写一份,逻辑一模一样,改一处要改所有副本。二是丢掉类型:让栈装“任何东西”(Java 早期的 Object、Go 早期的 interface{}),代价是取出来的东西要由调用方自己断言类型,装错了要到运行时才发现。泛型(generics)是第三条路:把类型本身当参数,写一次 Stack<T>,用的时候填进具体类型得到 Stack<int>、Stack<string>,编译器按填进去的类型检查每一次 push 和 pop。

三个基本术语。类型参数(type parameter)是定义里的占位符 T;实际填进去的 int 叫类型实参(type argument),填好的 Stack<int> 叫实例化(instantiation)。约束(constraint)规定 T 至少要具备什么能力:取最大值的函数需要比较大小,就得要求类型实参“可比较”,否则函数体里写不出 >;约束越紧,函数体能做的事越多,能填的类型越少。类型推断(type inference)让调用方不必每次写出类型实参,编译器从实参的类型反推 T。

同一份 Stack<T> 怎样变成机器能执行的东西,有三种做法。擦除(erasure):检查完就把 T 删掉,运行时只有一份不带类型的代码,不知道某个栈装的是什么。具化(reification):运行时保留类型实参,Stack<int> 和 Stack<string> 是两个真实存在、能被查询的类型。单态化(monomorphization):编译器为每个类型实参生成一份专门的代码,运行最快,代价是编译慢、二进制大。Go 走的是第四条路,介于擦除和单态化之间。

同一份 Stack 在擦除、具化和 Go 方案下的代码份数

看每一栏最右边一列的代码有几份:擦除只有一份;C# 给每个值类型(value type)一份、所有引用类型(reference type)合用一份;Go 按“形状”分组,每组一份,再用字典区分组内的不同类型。

Python 3.12 起有原生的泛型语法(PEP 695):class Stack[T]:、def max_by[T](...),在名字后面的方括号里声明类型参数。但它只给类型检查器(mypy、Pyright 等)看,解释器运行时不做任何检查。

from collections.abc import Callable
class Stack[T]:
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T:
return self._items.pop()
def peek(self) -> T:
return self._items[-1]
def __len__(self) -> int:
return len(self._items)
def max_by[T, K: (int, str)](items: list[T], key: Callable[[T], K]) -> T:
best = items[0]
for item in items[1:]:
if key(item) > key(best):
best = item
return best
s = Stack[int]()
for n in (1, 2, 3):
s.push(n)
print(f"栈顶:{s.peek()}")
popped = [str(s.pop()) for _ in range(len(s))]
print("弹出:" + " ".join(popped))
print(f"最长的词:{max_by(['type', 'generics', 'go'], len)}")
print(f"最大的数:{max_by([7, 42, 19], lambda n: n)}")
# 输出:
# 栈顶:3
# 弹出:3 2 1
# 最长的词:generics
# 最大的数:42

运行:python stack.py。约束写在冒号后面:K: (int, str) 是限定(constrained)写法,K 只能是 int 或 str 之一,检查器因此允许 key(item) > key(best);T: Hashable 这样只写一个类型的是上界(bound),任何 Hashable 的子类型都行。调用 max_by(['type', ...], len) 时检查器推断出 T 是 str、K 是 int。3.12 之前要先写 T = TypeVar("T"),再让类继承 Generic[T];新语法还会自动推断型变(variance),不必再手写 covariant=True。

底层怎么实现。 方括号语法在运行时确实会执行:解释器为 T 创建一个 TypeVar 对象,存进 Stack.__type_params__,并悄悄让 Stack 继承 typing.Generic。Stack[int] 返回一个别名对象,调用它就构造一个普通的 Stack 实例,构造完再给实例补一个 __orig_class__ 属性。除此之外没有任何作用:push 的字节码和不写 [T] 时完全一样,列表里存的都是对象引用,整数也是堆上的对象。这是彻底的擦除,检查全部发生在运行之前的检查器里。

  • list[int] 挡不住字符串:xs: list[int] = [] 之后 xs.append("a") 照常运行,只有跑了检查器才会报错;不跑检查器的项目,类型参数只是注释。
  • isinstance(x, Stack[int]) 直接抛 TypeError:运行时不知道一个栈装的是什么,带类型实参的泛型不能用来判断类型,只能写 isinstance(x, Stack)。

JavaScript 没有泛型,也不需要:变量不带类型,一个数组本来就能装任何值,普通的类和函数天然“对什么类型都能用”。

class Stack {
#items = [];
push(item) {
this.#items.push(item);
}
pop() {
if (this.#items.length === 0) throw new Error("栈是空的");
return this.#items.pop();
}
peek() {
return this.#items.at(-1);
}
get size() {
return this.#items.length;
}
}
function maxBy(items, key) {
let best = items[0];
for (const item of items.slice(1)) {
if (key(item) > key(best)) best = item;
}
return best;
}
const s = new Stack();
for (const n of [1, 2, 3]) s.push(n);
console.log(`栈顶:${s.peek()}`);
const popped = [];
while (s.size > 0) popped.push(s.pop());
console.log(`弹出:${popped.join(" ")}`);
console.log(`最长的词:${maxBy(["type", "generics", "go"], (w) => w.length)}`);
console.log(`最大的数:${maxBy([7, 42, 19], (n) => n)}`);
// 输出:
// 栈顶:3
// 弹出:3 2 1
// 最长的词:generics
// 最大的数:42

运行:node stack.js。这就是概念本身里说的“丢掉类型”那条路,只不过在 JS 里它是唯一的路,也不需要断言:取出来的值直接用。代价是没有任何工具帮你发现装错了:往数字栈里压一个字符串,maxBy 的规则返回了字符串,> 的一边是字符串、一边是数字,字符串会先转成数字再比,转不出来(如 "abc")就是 NaN,比较结果恒为假;两边都是字符串时才按字典序,"9" > "42" 为真。无论哪种,结果错了都不报错。

  • 比较规则混了类型不报错:规则函数有时返回数字、有时返回字符串时,> 会把字符串转成数字再比,转不出来得到 NaN,比较为假,得到一个看似合理的错误结果。
  • 空栈的 pop() 在原生数组上返回 undefined 而不是抛错,本例特意自己检查;忘了检查,undefined 会一路传下去。

TypeScript 有完整的泛型:类型参数、extends 约束、调用处推断。和它的整个类型系统一样,泛型只存在于编译期。

class Stack<T> {
#items: T[] = [];
push(item: T): void {
this.#items.push(item);
}
pop(): T {
const item = this.#items.pop(); // 类型是 T | undefined
if (item === undefined) throw new Error("栈是空的");
return item;
}
peek(): T | undefined {
return this.#items.at(-1);
}
get size(): number {
return this.#items.length;
}
}
function maxBy<T, K extends number | string>(items: T[], key: (item: T) => K): T {
let best = items[0];
for (const item of items.slice(1)) {
if (key(item) > key(best)) best = item;
}
return best;
}
const s = new Stack<number>();
for (const n of [1, 2, 3]) s.push(n);
console.log(`栈顶:${s.peek()}`);
const popped: number[] = [];
while (s.size > 0) popped.push(s.pop());
console.log(`弹出:${popped.join(" ")}`);
console.log(`最长的词:${maxBy(["type", "generics", "go"], (w) => w.length)}`); // T 推断为 string
console.log(`最大的数:${maxBy([7, 42, 19], (n) => n)}`);
// 输出:
// 栈顶:3
// 弹出:3 2 1
// 最长的词:generics
// 最大的数:42

运行:tsx stack.ts。s.push("4") 现在是编译错误。调用 maxBy 时没写类型实参,编译器先从数组推出 T 是 string,再拿这个类型检查箭头函数,推出 w 是 string、K 是 number;其中根据已推出的 T 给箭头函数的参数 w 定类型这一步,叫上下文类型(contextual typing)。K extends number | string 是约束:没有它,K 可以是任何类型,key(item) > key(best) 就编译不过。

底层怎么实现。 tsc 检查完后做类型擦除:<T>、: T、extends ... 全部删掉,输出的 JS 和上面手写的 JS 版本几乎逐行相同。运行时不存在 T,new Stack<number>() 和 new Stack<string>() 造出的是同一个类的对象。所以 TS 的泛型对运行速度没有任何影响,也没有任何运行时保护。

  • 想在运行时判断 T 做不到:x instanceof T 报错 TS2693:T 只表示类型,却被当作值使用。要按类型分支,只能让调用方把信息作为值传进来:一个构造函数(ctor: new () => T)或一个类型守卫(isT: (x: unknown) => x is T)。
  • 推断不出来时 T 变成 unknown:new Stack() 没写类型实参、构造函数又没有参数可推,得到的是 Stack<unknown>,之后取出的值什么都不能直接做。
  • as 断言对泛型同样不检查:JSON.parse(raw) as Stack<number> 编译器全信,运行时拿到的只是一个普通对象,连 push 方法都没有。

C# 的泛型是原生的,而且由 .NET 运行时直接支持:编译后的中间语言(IL)里保留着类型参数,运行时知道每个实例化的确切类型。

using System;
using System.Collections.Generic;
var s = new Stack<int>();
foreach (var n in new[] { 1, 2, 3 }) s.Push(n);
Console.WriteLine($"栈顶:{s.Peek()}");
var popped = new List<int>();
while (s.Count > 0) popped.Add(s.Pop());
Console.WriteLine($"弹出:{string.Join(" ", popped)}");
Console.WriteLine($"最长的词:{MaxBy(["type", "generics", "go"], w => w.Length)}");
Console.WriteLine($"最大的数:{MaxBy([7, 42, 19], n => n)}");
static T MaxBy<T, K>(T[] items, Func<T, K> key) where K : IComparable<K>
{
var best = items[0];
foreach (var item in items[1..])
if (key(item).CompareTo(key(best)) > 0) best = item;
return best;
}
class Stack<T>
{
private readonly List<T> items = [];
public int Count => items.Count;
public void Push(T item) => items.Add(item);
public T Peek() => items[^1];
public T Pop()
{
var item = items[^1];
items.RemoveAt(items.Count - 1);
return item;
}
}
// 输出:
// 栈顶:3
// 弹出:3 2 1
// 最长的词:generics
// 最大的数:42

运行:dotnet run stack.cs。标准库本来就有 System.Collections.Generic.Stack<T>,同名时当前文件里定义的类优先,所以这里用的是自己写的这个。约束写在 where 子句里:where K : IComparable<K> 要求 K 能和同类比较,函数体才能调用 CompareTo;其他约束还有 class、struct、new()(有无参构造函数)、notnull、某个基类等。泛型方法的类型实参能推断,泛型类的构造则要写出 Stack<int>。

因为类型实参在运行时存在,泛型代码里可以写 typeof(T)、new T[10]、x is T,反射能列出 Stack<int> 的类型实参。Stack<int> 和 Stack<string> 是两个不同的类型,泛型类的静态字段在每个实例化里各有一份。

底层怎么实现。 C# 编译器只生成一份带类型参数的 IL。JIT 第一次遇到某个实例化时才生成机器码,规则如图中间一栏:类型实参是值类型时,为每个值类型单独生成一份,List<int> 的内部数组就是 int[],元素直接存值,不装箱(boxing);类型实参是引用类型时,所有引用类型共用一份代码(运行时内部用占位类型 System.__Canon 表示),因为引用都是一个指针宽,操作方式相同。共享代码里遇到 typeof(T) 这类需要确切类型的地方,就经一个隐藏的泛型上下文到该实例化的字典里查,这和 Go 的做法相似。

  • 约束不够时不能用运算符:static T Max<T>(T a, T b) => a > b ? a : b; 报 CS0019:运算符 > 无法应用于 T 和 T,因为约束里没说 T 支持 >;.NET 7 之前也没有接口能表达这一点。.NET 7 起的泛型数学(generic math)补上了这一块:where T : INumber<T> 之后可以写 T.Zero、a + b,靠的是 C# 11 的接口静态抽象成员。
  • 值类型实例化越多,机器码越多:每个值类型一份专门代码,用了几十种结构体作类型实参的大型泛型库,JIT 时间和 NativeAOT 编译出的体积都会明显增加。
  • 以为静态字段是共享的:在 Stack<T> 里放一个静态计数器统计创建了多少个栈,Stack<int> 和 Stack<string> 各数各的。

Go 1.18(2022 年)起有类型参数,写在方括号里:type Stack[T any]、func MaxBy[T any, K cmp.Ordered]。约束就是接口,any 表示没有限制。

package main
import (
"cmp"
"fmt"
"strconv"
"strings"
)
type Stack[T any] struct {
items []T
}
func (s *Stack[T]) Push(item T) { s.items = append(s.items, item) }
func (s *Stack[T]) Peek() T { return s.items[len(s.items)-1] }
func (s *Stack[T]) Pop() T {
item := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return item
}
func (s *Stack[T]) Len() int { return len(s.items) }
func MaxBy[T any, K cmp.Ordered](items []T, key func(T) K) T {
best := items[0]
for _, item := range items[1:] {
if key(item) > key(best) {
best = item
}
}
return best
}
func main() {
s := &Stack[int]{}
for _, n := range []int{1, 2, 3} {
s.Push(n)
}
fmt.Printf("栈顶:%d\n", s.Peek())
var popped []string
for s.Len() > 0 {
popped = append(popped, strconv.Itoa(s.Pop()))
}
fmt.Println("弹出:" + strings.Join(popped, " "))
fmt.Println("最长的词:" + MaxBy([]string{"type", "generics", "go"}, func(w string) int { return len(w) }))
fmt.Printf("最大的数:%d\n", MaxBy([]int{7, 42, 19}, func(n int) int { return n }))
}
// 输出:
// 栈顶:3
// 弹出:3 2 1
// 最长的词:generics
// 最大的数:42

运行:go run stack.go。Go 的接口在泛型里多了一种写法:类型集合(type set)。interface{ ~int | ~float64 } 表示“底层类型是 int 或 float64 的所有类型”,~ 让 type MyInt int 这样的自定义类型也算在内。cmp.Ordered(Go 1.21 进入标准库)就是这样定义的,列出了所有支持 <、> 的类型,所以 MaxBy 里能直接写 key(item) > key(best)。调用时 T、K 都从实参推断出来;方法 Push 等写在 *Stack[T] 上,用的是类型的参数 T。

方法自己的类型参数是新近才有的:Go 1.27(2026 年 8 月)起,方法可以声明自己的类型参数,例如给 Stack[T] 加一个 Map[U any](f func(T) U);但接口里的方法仍然不能有类型参数,所以这样的泛型方法不能用来满足任何接口。

底层怎么实现。 Go 用的是“GC 形状模板化加字典”(GC shape stenciling with dictionaries)。形状(shape)大致由底层类型决定,所有指针类型属于同一个形状。编译器为每个形状生成一份代码,如图最下一栏:Stack[int] 和 Stack[MyInt] 共用 Stack[go.shape.int],Stack[*A] 和 Stack[*B] 共用 Stack[go.shape.*uint8],这些名字在编译出的符号表里能直接看到。同一形状里各类型的差别(具体的类型描述符、方法地址)放进字典,每次调用作为隐藏参数传进去。所以基本类型实例化接近单态化的速度、元素不装箱,指针类型实例化则像擦除一样只有一份代码。运行时通过字典知道 T 是什么,泛型函数里可以对 any(v) 做类型判断,也能用 reflect.TypeFor[T]()。

  • 在类型参数上调方法有一次间接调用:v.String() 要先从字典里取出方法地址再调用,编译器也没法内联;热点路径上,这样的泛型函数可能比手写的具体类型版本、甚至比直接用接口还慢。
  • 带类型集合的接口只能当约束:var x interface{ ~int | ~float64 } 编译不过,它不是普通的接口类型,不能用来声明变量或字段。
  • 1.27 之前写不出泛型方法:想要 s.Map(f) 只能写成普通函数 Map(s, f);要兼容旧版本的库至今仍是这样。
维度 Python JS/TS C# Go
支持程度 原生(3.12 语法,只给检查器) JS 没有;TS 原生 原生 原生(1.18 起)
实现机制 擦除 擦除 具化,值类型专门化、引用类型共享 GC 形状模板化 + 字典
约束写法 K: (int, str)、T: Hashable K extends number,可写联合类型 where K : IComparable<K> K cmp.Ordered、类型集合
运行时能否知道类型参数 不能(实例上只有 __orig_class__) 不能 能,typeof(T) 能,经字典,reflect.TypeFor[T]()
值类型的性能 整数也是对象,无差别 和不用泛型相同 每个值类型一份专门代码,不装箱 按形状共享,基本类型不装箱
泛型方法 有 有 有 1.27 起有,接口方法不能有
类型推断 检查器推断 调用处推断,含上下文类型 方法能推断,构造类要写 函数调用处推断

差别最大的是“运行时能否知道类型参数”一行:它决定了能不能按 T 分支、能不能反射,也和性能一行直接相关。

TS 和 Python 的泛型是叠加在动态运行时上的检查层,只能擦除。Python 的类型标注从 PEP 484(2014 年)开始就定位为可选的、给工具看的,解释器从未打算按标注检查,PEP 695 只是让写法更顺手。TS 的设计目标里明确写着不添加、也不依赖运行时类型信息,输出要是干净的 JS。两者的运行时都是动态的,一个列表本来就能装任何东西,具化既没有必要,也意味着要给每个对象附加类型信息、改变已有 JS 和 Python 代码的行为。后果是泛型零运行时成本、零运行时保护,跨越边界(解析 JSON、读外部数据)时必须另行校验。

C# 的泛型是 .NET 2.0(2005 年)改了运行时才加进来的,设计来自微软剑桥研究院的 Don Syme 和 Andrew Kennedy。微软选择在 2.0 里一并升级运行时和 IL 格式,换来具化。同一时期 Java 5 为了让已有的 JVM 和类库不必改动,选了擦除。具化带来的后果是:反射能看到完整类型,值类型集合不装箱、性能接近手写,代价是运行时和 JIT 更复杂,值类型实例化多了会增加机器码。

Go 拖到 1.18 才加泛型,是因为设计者一直没找到满意的折中。Russ Cox 2009 年把这概括为“泛型困境”:不要泛型,程序员写得慢(C 的做法);全部单态化,编译慢、二进制大(C++ 的做法);全部装箱擦除,运行慢(Java 的做法)。Go 把编译速度看得很重,最终的 GC 形状方案就是在三者之间取中:按形状共享代码控制编译时间和体积,基本类型仍有专门代码,指针类型付出字典间接调用的代价。

这些选择的后果可以归到三处。反射:C# 和 Go 能在运行时拿到 T,TS 和 Python 拿不到,只能把类型作为值传进去。二进制体积:C++ 式单态化最大,Go 和 C# 按形状或引用类型共享来压缩,擦除最小。性能:C# 和 Go 的值类型实例化不装箱;Python 本来所有值都是对象,泛型不改变什么;TS 的代码和不用泛型时一模一样。

Java 的泛型是擦除的:List<Integer> 和 List<String> 运行时都是 List,new T() 和 obj instanceof List<String>(obj 是 Object 时)编译不过;类型实参只能是引用类型,int 要装箱成 Integer 才能放进集合。这是 2004 年为了兼容已有 JVM 做的取舍。

C++ 的模板(template)是单态化的代表:编译器为每组模板实参生成一份完整代码,std::vector<int> 和 std::vector<double> 是两份独立的机器码,运行时没有任何间接开销;代价是编译慢、体积大,出错信息常常长达数屏。C++20 的概念(concepts)给模板加上了约束写法。

  • 先写具体类型,出现第二个用法时再泛化;只为一种类型服务的泛型只是多了一层类型参数。
  • 约束只要求实际用到的能力:取最大值只需要“可比较”,不要写成“必须是数字”。C# 用 IComparable<T> 或 INumber<T>,Go 用 cmp.Ordered,TS 用 extends,Python 用上界或限定。
  • 在擦除的语言里,外部数据进来时用运行时校验(类型守卫、校验库或手写检查)代替 as 和标注;类型参数只保证你自己写的代码之间一致。
  • Go 的热点路径上,对指针类型的泛型函数做一次基准测试;必要时用具体类型或直接用接口。

用 C# 和 Go 各写一个泛型函数 Filter:参数是一个切片(或数组)和一个判断函数,返回满足判断的元素。用它从 1 到 6 这六个数里留下偶数,从 type、generics、go、map 里留下长度大于 3 的单词,打印两行:

偶数:2 4 6
长度大于 3 的词:type generics

提示:Filter 需要约束吗?判断函数的类型怎么写?在 Go 里,类型实参能不能都省掉?

  • 泛型是复制代码和丢掉类型之外的第三条路:把类型当参数,写一次,按填入的类型检查。
  • 类型参数、约束、推断是三个基本概念;约束越紧,函数体能做的越多,能填的类型越少。
  • 实现有三种:擦除(TS、Python、Java)、具化(C#)、单态化(C++);Go 按 GC 形状共享代码、用字典传类型信息,取了折中。
  • 实现方式决定后果:运行时能不能知道 T,值类型装不装箱,二进制有多大。
  • JS 没有泛型,也不需要;代价是没有工具提醒你装错了。