第 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 走的是第四条路,介于擦除和单态化之间。
看每一栏最右边一列的代码有几份:擦除只有一份;C# 给每个值类型(value type)一份、所有引用类型(reference type)合用一份;Go 按“形状”分组,每组一份,再用字典区分组内的不同类型。
Python 怎么做
Section titled “Python 怎么做”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)。
JS/TS 怎么做
Section titled “JS/TS 怎么做”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 推断为 stringconsole.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# 怎么做
Section titled “C# 怎么做”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 怎么做
Section titled “Go 怎么做”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 的代码和不用泛型时一模一样。
旁注:别的语言
Section titled “旁注:别的语言”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 没有泛型,也不需要;代价是没有工具提醒你装错了。