8.3 类型检查技术
泛型使类型检查器面对的问题困难了许多。没有泛型时,检查器要判断的基本上只有一类问题:一个值是否属于某个类型。有了类型参数,它还要回答三个新的问题:一个类型实参是否满足约束;a + b、range s 这样的操作在类型尚未确定时是否成立;以及调用 Max(3, 5) 时,没有写出的 T 究竟是什么。本节依次讨论与之对应的三项技术:类型集、对类型参数操作的规则,以及类型推断,最后讨论编译器中实现这些规则的类型检查器 types2。它们是
8.1 与 8.2 所述设计在编译期的落实,编译器前端的整体结构见第 15 章。
flowchart LR
Q1["类型实参是否满足约束?"] --> T1["类型集<br/>约束是一个类型的集合"]
Q2["a + b、range s 是否成立?"] --> T2["对类型参数操作的规则<br/>逐类型成立,或要求相同的底层类型"]
Q3["Max(3, 5) 中的 T 是什么?"] --> T3["类型推断<br/>类型方程与合一"]
T1 --> IMPL["types2:编译器中支持泛型的类型检查器"]
T2 --> IMPL
T3 --> IMPL泛型的类型规则也有形式化的研究。2020 年,Griesemer、Taylor 与 Philip Wadler 等人以 Featherweight Java 为蓝本,提出了 Go 的两个核心演算 FG 与 FGG,后者带有类型参数。与 Java 不同,Go 的子类型关系是结构化的,泛型以单态化而非类型擦除实现;论文分别证明了两个演算的类型可靠性,并把 FGG 到 FG 的单态化翻译形式化,证明它保持类型且构成互模拟,作者称这是最早形式化单态化的工作之一 [Griesemer et al., 2020]。这项工作发表于类型参数提案在 2021 年被接受之前,而 Griesemer 与 Taylor 正是 Go 泛型的两位设计者。
8.3.1 类型集:约束是一个类型的集合
约束的核心概念是类型集(type set)。普通的接口描述一个类型有哪些方法,作为约束的接口则进一步描述哪些类型属于它(8.2.4)。类型集由类型项(type term)的并集构成,编译器
cmd/compile/internal/types2 包中的 term 结构直接沿用了集合的记法
[Go Authors, 2026c]:
| |
约束中的三种写法各自对应一种集合:int 是只含 int 的集合;~int 是 ,即所有底层类型为 int 的类型;A | B 是二者的并集 。语言规范对 ~T
有两条限制:T 的底层类型必须是它自身,因此 ~MyInt 不合法;T 也不能是接口,因此 ~error 不合法
[Go Authors, 2026a]。一个接口的类型集,是其各个接口元素的类型集之交:方法元素对应所有具有该方法的类型,联合元素对应其各个类型项之并
[Taylor and Griesemer, 2021]。types2 以下面的结构表示它:
| |
「类型 满足约束 」由此有了精确的定义: 属于 terms 所描述的集合,实现了 methods
中的全部方法,并且在 comparable 为真时是可比较的类型。检查器的工作就是计算这些集合,再判断归属。
comparable 值得单独说明。它无法写成若干类型项的并集,因此 _TypeSet 以一个独立的布尔字段表示它。Go
1.18 中,comparable 只允许严格可比较的类型,即保证 == 不会在运行时引发 panic 的类型,接口类型因此被排除在外。Go 1.20 放宽了这一规则:普通的接口类型,以及含有接口字段的复合类型,也可以满足
comparable 约束,代价是当两个接口值的动态类型不可比较时,比较会在运行时引发 panic
[Go Authors, 2023a]。为了表达能力,一部分检查从编译期推迟到了运行时。
8.3.2 对类型参数的操作:逐类型成立与相同的底层类型
类型集回答了「哪些类型可以传入」,泛型函数体中的操作则带来另一个问题:一个操作在类型参数上何时成立?Go 1.26 的规范对不同的操作采用两类规则,初学泛型时容易把二者混淆。
第一类规则要求操作对类型集中的每一个类型都成立。运算符属于此类:若操作数的类型是类型参数,运算符必须适用于其类型集中的每一个类型,运算则按实例化时的具体类型进行
[Go Authors, 2026a]。因此 a + b 在 interface{ ~float32 | ~float64 } 上成立,a < b
在包含十余种底层类型的 cmp.Ordered 上也成立,这正是 slices.Sort 得以实现的原因。len、cap
与下标同样遵循这一规则:len(s) 只需对类型集中的每个类型都合法;下标 s[i] 除此之外还要求各类型的元素类型相同。
第二类规则要求类型集中的所有类型具有相同的底层类型,适用于那些依赖统一的底层结构才能定义的操作:range、切片表达式 s[i:j]、append、make,以及复合字面量。只有知道统一的底层结构,编译器才能确定
range s 按何种结构遍历,s[i:j] 得到什么类型。规范为其中一些情形规定了例外:类型集中的通道可以方向不同,只要元素类型相同、方向不冲突;切片表达式与 append 允许 string 与底层类型为 []byte
的类型同时出现。
下面的例子展示了两类规则的分界:
| |
First 可以通过编译,因为切片与数组都能取长度、都能以下标访问,元素类型也都是 int;Sum 则不能:
./main.go:10:20: cannot range over s (variable of type T constrained by SliceOrArray): []int and [2]int have different underlying types
从 Go 1.18 到 Go 1.24,规范以核心类型(core type)这一概念表述第二类规则:非类型参数的核心类型就是它的底层类型;类型参数的核心类型是其类型集中所有类型共同的那个底层类型,若底层类型各不相同则不存在核心类型。为了处理通道方向与 []byte、string 混用等情形,核心类型的定义又附加了若干调整。Go 1.25
从规范中删除了这一概念,改为在每一处需要的地方写出明确而等价的规则,程序的行为没有任何变化
[Go Authors, 2025]。Griesemer 在博客中解释了这样做的理由:核心类型的定义对某些具体操作过于严格,例如 Go 1.24
的切片表达式依赖核心类型,于是同时允许 []byte 与 string 的类型参数无法切片,尽管这本可以成立;读者为了理解切片表达式这样与泛型无关的特性,也不得不先学习核心类型;而下标、len 与 cap
等不依赖核心类型的规则,反倒显得像是例外。改为逐项说明之后,规范的概念更少,非泛型代码的规则不再涉及泛型的概念,也为今后放宽具体操作的规则留出了余地 [Griesemer, 2025]。
在编译器中,求取共同底层类型的是 types2/under.go 中的 commonUnder 函数
[Go Authors, 2026c],其主体如下:
| |
上面 Sum 的错误信息正是由这里产生的。规范虽然已经删除了核心类型的说法,编译器中类型推断的代码与注释仍然使用「core type」与 coreTerm 这些名称;规范先行更名、实现中的旧称谓滞后,是阅读编译器源码时常见的现象。
8.3.3 类型推断:让调用方少写类型实参
如果每次调用泛型函数都要写出全部类型实参,例如 Max[int](3, 5),泛型的语法便显得冗长。Go
的类型检查器实现了 类型推断(type inference),使大多数调用可以写成 Max(3, 5),缺少的类型实参由编译器推出。
Griesemer 把 Go 的类型推断描述为一种类型的模式匹配:编译器从调用中收集一组类型方程,再以合一(unification)求解其中的类型参数 [Griesemer, 2023]。方程的来源有三种:显式给出的类型实参,每一个都构成一个平凡的方程;每个函数实参与其形参之间的赋值关系;以及每个类型参数与其约束之间的满足关系。合一递归地比较两个类型的结构,在类型参数与具体类型相遇处记下对应关系,若结构不符或同一类型参数对应了两个不同的类型,推断便失败。无类型常量单独处理:有类型的实参优先,只有当某个类型参数仍未确定时,才以对应的无类型常量的默认类型补齐。在 Go 1.26 中,这一过程由
types2/infer.go 中的 infer 方法完成:
| |
约束提供的方程有时是推断的关键。下面的 Scale 把切片的每个元素乘以一个系数:
| |
[2 4]
由实参 p 可知 S = Point;2 是无类型常量,单凭它无法确定 E。此时约束 S ~[]E 提供了一个方程:S
的底层类型 []int 必须与 []E 相同,合一随即得到 E = int。类型参数提案把这一步称为约束类型推断(constraint
type inference)[Taylor and Griesemer, 2021]。结果 q
的类型是 Point 而非 []int,因此可以调用 String 方法;若把 Scale 的签名写成
func Scale[E Number](s []E, c E) []E,返回值便丢失了 Point 这一类型。
Go 1.21 进一步增强了类型推断 [Go Authors, 2023b]。其一,由于类型实参必须实现其约束的全部方法,推断时会把类型实参的方法与约束的方法逐一匹配,从中推出更多的类型实参。8.2.4
的图就是一例:New([]*Vertex{a, b}) 只给出了节点切片,Go 1.21 及以后的编译器由
*Vertex 的 Edges 方法推出边的类型 *FromTo,而 Go 1.20 的编译器报告 cannot infer Edge。其二,泛型函数可以在赋值时由目标类型反向推断:
| |
Go 1.20 的编译器对此报告 cannot use generic function lessThan without instantiation。其三,同一个类型参数对应多个不同种类的无类型常量实参时,例如一个无类型整数与一个无类型浮点数,推断不再报错,而是按无类型常量运算的规则确定类型。规范中类型推断一节的表述也在这一版中得到澄清
[Go Authors, 2026b]。
与 ML、Haskell 采用的 Hindley-Milner 类型推断相比,Go 的推断有意保持局部。Hindley-Milner
系统可以推出函数定义本身的类型,程序员往往不必写出任何类型注解 [Milner, 1978];Go
则要求函数的签名写明类型,推断只在一次泛型函数调用或一次赋值的范围内,求解该函数的类型参数。推断失败时,错误信息可以准确地指出是哪一个类型参数无法确定。Go 也不推断泛型类型的类型实参,泛型类型总要显式实例化。Griesemer
解释说,实例化一个类型时只有类型实参可供推断,不像函数调用那样还有普通的实参,因此至少要给出一个类型实参,推断只在补全部分实例化的类型时才有用,这种情形并不常见;更重要的是,类型参数使得一类全新的递归类型成为可能,例如约束引用正在声明的类型本身的 type T[P T[P]] interface{ … },加上多个类型参数之间复杂的相互引用,类型推断会变得复杂得多,团队尚未完全理解其后果
[Griesemer, 2023]。
8.3.4 types2:编译器中支持泛型的类型检查器
实现上述规则的,是编译器内部的 types2 包(cmd/compile/internal/types2)。Go 1.18 为了支持泛型,完整替换了编译器原有的类型检查器,发布说明还提醒,部分错误信息的措辞因此有所改变
[Go Authors, 2022]。types2 是标准库 go/types 的姊妹实现:二者的算法同源、行为一致,区别在于 types2 直接处理编译器自己的语法树 cmd/compile/internal/syntax,而 go/types
处理标准库 go/ast 的语法树。
两份实现并存,是因为它们服务于不同的使用者。types2 服务于编译本身,与编译流程紧密结合,并对编译速度敏感。go/types 则是一个公开的库,gopls、各类静态检查工具与代码生成器都建立在它之上
[Go Authors, 2026d](第 16 章)。编辑器中的跳转、补全与重命名,因此与编译器对同一段泛型代码的理解保持一致。维持两份实现同步的负担,则通过代码生成来减轻:自 2023 年起,go/types 中的一部分源文件由测试程序从 types2 的对应文件自动生成,文件开头注明「DO NOT EDIT」与其来源。在 Go 1.26 中,go/types 的 74 个非测试源文件里有
46 个是这样生成的。
8.3.5 仍未解决的边界
推断的克制是有意的选择,也意味着它在若干边界之前停下。其一,推断并不完备:存在在类型上可以确定、但 Go
的算法不去求解的情形,例如约束缺少统一的底层类型时,更复杂的方法或许仍能推出类型信息;此时仍需显式写出类型实参。Griesemer 认为,推断失败的情形在实际代码中要么罕见,要么无关紧要
[Griesemer, 2023]。其二,在 Go 1.26 中,方法不能声明自己的类型参数,func (r Recv) M[T any]()
不合法;Go 1.27 放开了这一限制,但泛型方法不参与接口的满足,其经过见
8.4.2。其三,泛型类型不能被推断。其四,类型参数上的操作只能使用类型集共有的方法,即使类型集中的所有结构体都有同名的字段,也不能通过类型参数访问它:
| |
允许这种访问的提案(#48522)于 2023 年 11 月被拒绝,理由是应当先等待删除核心类型的整体清理(#63940),而不是逐点修补 [Beoran, 2021]。Griesemer 在解释删除核心类型的理由时指出,没有了核心类型,这一特性将成为普通字段访问规则的自然推论 [Griesemer, 2025];但截至
Go 1.27,它仍未实现。这些边界并非缺陷,而是先把保守、可预测的核心做扎实,再逐版谨慎放宽这一策略的结果。Go
1.21 对推断的增强与 Go 1.25 对规范的整理都是这样的放宽;Go 1.26 则取消了泛型类型不能在其类型参数列表中引用自身的限制,Griesemer 文中假设的 type T[P T[P]] 这类声明现已合法
[Go Authors, 2026e],这一点在 8.4 中讨论。
8.3.6 取舍:复杂度的重新安置
泛型的类型检查使 Go 类型系统的复杂度显著上升:类型集的并与交、共同底层类型的求解、基于合一的推断,都比泛型之前的检查复杂得多。Go 团队接受了这部分复杂度,但尽量把它限制在编译器内部:
- 对使用者而言,约束就是接口,无须学习一套新的约束语法(8.2);
- 调用泛型函数时,大多不必写出类型实参,由推断补齐;
- 推断与约束检查失败时,错误信息力求指出具体的类型参数,或者类型集中引起问题的具体类型。
这与 8.1 中的实现策略是同一问题的两面。那里讨论的是运行时:以 GC 形状模板化加字典,在代码膨胀与运行效率之间折中。这里讨论的是编译期:以类型集加保守的推断,在表达能力与使用者的认知负担之间折中。复杂度并没有消失,而是被安置到了编译器中这个最少打扰使用者的地方。
延伸阅读
- [Taylor and Griesemer, 2021] Ian Lance Taylor, Robert Griesemer. “Type Parameters Proposal.” Go design document, 2021. https://go.googlesource.com/proposal/+/refs/heads/master/design/43651-type-parameters 类型集、核心类型、函数实参推断与约束类型推断的设计依据。
- [Griesemer, 2023] Robert Griesemer. “Everything You Always Wanted to Know About Type Inference - And a Little Bit More.” The Go Blog, 2023-10-09. https://go.dev/blog/type-inference 类型方程、合一、无类型常量的处理,以及推断尚未覆盖的情形。
- [Go Authors, 2026a] The Go Authors. The Go Programming Language
Specification, “Interface types”、“Type sets”、“Type constraints”.
https://go.dev/ref/spec#Interface_types
类型集、
~T的限制与约束的规范定义。 - [Go Authors, 2026b] The Go Authors. The Go Programming Language Specification, “Type inference”. https://go.dev/ref/spec#Type_inference 类型推断的规范定义,含 Go 1.21 起的反向推断。
- [Go Authors, 2025] The Go Authors. Go 1.25 Release Notes:规范以专门的说明取代「core type」. https://go.dev/doc/go1.25 ;规范定义见 https://go.dev/ref/spec#Underlying_types Go 1.25 从规范中删除核心类型的概念,程序的行为不变。
- [Go Authors, 2026c] The Go Authors.
cmd/compile/internal/types2. https://github.com/golang/go/tree/master/src/cmd/compile/internal/types2 编译器前端支持泛型的类型检查器:typeset.go、typeterm.go、under.go、infer.go。 - [Go Authors, 2026d] The Go Authors.
go/typespackage documentation. https://pkg.go.dev/go/types 驱动gopls与各类静态检查工具的姊妹实现。 - [Griesemer, 2025] Robert Griesemer. “Goodbye core types - Hello Go as we know and love it!” The Go Blog, 2025-03-26. https://go.dev/blog/coretypes 核心类型的定义、它带来的问题,以及 Go 1.25 删除这一概念的理由(提案 #70128)。
- [Beoran, 2021] Beoran. “proposal: spec: permit referring to a field shared by all elements of a type set.” golang/go#48522, 2021-09-21. https://github.com/golang/go/issues/48522 通过类型参数访问类型集共有字段的提案,2023 年 11 月为等待核心类型的整体清理(#63940)而被拒绝。
- [Griesemer et al., 2020] Robert Griesemer, Raymond Hu, Wen Kokke, Julien Lange, Ian Lance Taylor, Bernardo Toninho, Philip Wadler, Nobuko Yoshida. “Featherweight Go.” Proceedings of the ACM on Programming Languages 4(OOPSLA): 1–29, 2020. https://doi.org/10.1145/3428217 Go 泛型的形式化:FG 与 FGG 两个演算、类型可靠性,以及单态化变换。
- [Milner, 1978] Robin Milner. “A Theory of Type Polymorphism in Programming.” Journal of Computer and System Sciences 17(3): 348–375, 1978. https://doi.org/10.1016/0022-0000(78)90014-4 Hindley-Milner 类型推断的出处之一,可与 Go 局部的推断对照。
- [Go Authors, 2022] The Go Authors. Go 1.18 Release Notes. https://go.dev/doc/go1.18 泛型的发布,以及为支持泛型而完整替换的编译器类型检查器。
- [Go Authors, 2023a] The Go Authors. Go 1.20 Release Notes.
https://go.dev/doc/go1.20
普通接口类型也可以满足
comparable约束。 - [Go Authors, 2023b] The Go Authors. Go 1.21 Release Notes. https://go.dev/doc/go1.21 类型推断的增强:方法匹配、反向推断与不同种类的无类型常量。
- [Go Authors, 2026e] The Go Authors. Go 1.26 Release Notes. https://go.dev/doc/go1.26 泛型类型可以在其类型参数列表中引用自身。