文章

【笔记】Go 泛型类型信息的编译与运行时边界

【笔记】Go 泛型类型信息的编译与运行时边界

讨论 Go 泛型是否“丢失类型信息”之前,必须先问:说的是编译器进行静态检查所需的类型,生成机器码时采用的表示,还是运行时反射可见的动态类型?三者不是同一件事。

本文的语言语义依据 Go 1.26 specification;GCShape、wrapper 与 dictionary 来自 Go 1.26.4 编译器源码和汇编实验,只代表当前实现策略,不是语言规范承诺。

1. 中心模型:类型语义、代码生成、运行时表示

Go 泛型可以分成三层:

层次核心问题稳定性
语言与类型检查类型参数允许哪些类型和操作,实例化结果是什么类型Go specification 保证
编译器实现为不同实例生成几份机器码,如何传递辅助信息实现可变
运行时表示interface、reflect、类型断言能看到什么公开运行时语义 + 内部布局
flowchart LR
    S[泛型声明与约束] --> I[用类型实参实例化]
    I --> T[静态得到非泛型函数或命名类型]
    T --> C[编译器选择代码生成策略]
    C --> R[运行时值与类型描述]

真正可靠的结论是:Go 在编译期保持类型安全,实例化后的具体类型具有规范定义的身份;编译器可以在不改变这些可观察语义的前提下共享机器码。

2. 约束控制的是可用操作

2.1 类型参数不是运行时变量

在下面的声明中,T 是编译期类型参数:

1
2
3
4
5
6
func Min[T ~int | ~int64](a, b T) T {
	if a < b {
		return a
	}
	return b
}

约束接口描述可接受类型的集合,也决定函数体内对 T 值可执行的操作。因为类型集合中每个类型都支持 <,比较才合法。

编译器不是等运行时拿到某个值后再猜它是否可比较,而是在泛型函数自身被检查时就验证操作对约束覆盖的所有类型成立。

2.2 any 不是“无类型”

T any 表示类型实参可以是任意满足空接口的类型,但变量 v T 在泛型函数体内仍有静态类型 T。这和声明 v any 不同:后者的静态类型已经是接口。

约束越宽,函数体内可直接使用的操作越少;这是一种静态能力限制,不是类型信息已经被擦除。

3. 实例化在语言层做了什么

3.1 替换类型参数并检查约束

规范把实例化定义为两步:

  1. 将类型实参代入整个泛型声明;
  2. 检查每个类型实参是否满足对应约束。

例如:

1
2
3
4
5
6
type Box[T any] struct {
	Value T
}

var u Box[*User]
var o Box[*Order]

实例化泛型类型会得到新的非泛型命名类型。Box[*User]Box[*Order] 不是同一类型,即使两个字段在目标架构上的内存形状碰巧相同。

3.2 泛型函数实例化后也是非泛型函数

Identity[int] 表示把 T 替换为 int 后得到的函数实例。类型推断可以省略方括号中的一部分或全部实参,但不会改变实例化语义。

1
2
3
func Identity[T any](v T) T { return v }

x := Identity(42) // 推断 T 为 int,x 的静态类型是 int

若赋值、参数传递或返回值不符合实例化后的类型,程序在编译期失败,不会推迟到运行时。

4. “类型信息”在调用链中不会自动降级

4.1 泛型到泛型

1
2
func A[T any](v T) T { return B(v) }
func B[T any](v T) T { return v }

调用 A(&User{}) 时,类型推断和实例化使整条表达式的静态结果仍是 *User。函数跨越多少层不会自动把返回值改成 any

这来自每层函数签名的类型关系,而不是必须依赖某种特定运行时字典。

4.2 静态类型发生变化必须来自显式边界

下面的返回类型明确写成 any

1
2
3
4
5
func Erase[T any](v T) any {
	return v
}

x := Erase(&User{})

此时 x 的静态类型是 any。调用方不能直接访问 User 字段,必须通过类型断言、type switch 或反射恢复动态类型信息。

这不是调用链自然磨损了类型,而是 API 签名主动选择了一个更宽的静态抽象。

5. GCShape 与 dictionary 解决的是代码生成问题

5.1 为什么要共享机器码

若编译器为每组类型实参完整复制函数体,会造成代码膨胀。当前 Go 编译器会按 GC shape 复用部分实例的机器码。

在 Go 1.26.4 的最小实验中,Through[*User]Through[*Order] 都出现 wrapper,同时共享:

1
Through[go.shape.*uint8]

这说明两个指针实例可以复用同一形状代码。但共享的是内部函数体,不是把语言层的 *User*Order 合并成一个类型。

5.2 dictionary 是当前编译器的辅助机制

shape 代码执行某些依赖具体类型的操作时,需要调用点提供额外信息。当前编译器会生成隐藏 dictionary 参数或相关 wrapper,携带该实例所需的类型、方法或转换信息。

更准确的说法是:dictionary 包含“这份共享代码执行所需的实例化信息”。不应笼统断言每个 dictionary 都保存某种固定格式的“完整类型表”,因为内容与布局属于编译器内部 ABI,可随优化改变。

5.3 规范不保证 shape 或 dictionary

Go specification 规定程序的类型规则与可观察行为,没有要求编译器必须:

  • 为每种实参 monomorphize 一份代码;
  • 按 GCShape 共享代码;
  • 使用 dictionary 隐藏参数;
  • 保持某个 wrapper 或符号命名格式。

未来编译器可以更换策略,只要不改变语言语义。因此,业务代码不能依赖 go.shape 符号、dictionary 内存布局或当前汇编形式。

6. 反射为什么仍能看到具体类型

6.1 reflect.TypeOf 观察动态类型

reflect.TypeOf 接收 any。把 v T 传进去时,会发生 interface conversion,interface 值携带动态类型和动态值。

1
2
3
4
5
6
func Type[T any](v T) reflect.Type {
	return reflect.TypeOf(v)
}

Type(&User{})  // *main.User
Type(&Order{}) // *main.Order

尽管这两个实例可能共享 shape 代码,转换得到的 interface 仍携带各自具体动态类型。reflect.Type 值可比较;相等当且仅当表示相同类型。

6.2 泛型实例化类型也保有身份

实验中:

1
reflect.TypeOf(Box[*User]{})  != reflect.TypeOf(Box[*Order]{})

两种实例化类型的字段布局可能一样,但反射身份不同。类型身份和内存 shape 是两条不同维度。

6.3 反射结果由表达式的动态值决定

需要注意 nil:

1
2
3
4
5
6
7
8
9
var p *User = nil
reflect.TypeOf(p)       // *main.User

var x any = p
x == nil                // false
reflect.TypeOf(x)       // *main.User

var y any = nil
reflect.TypeOf(y)       // nil

typed nil 装入 interface 后仍有动态类型,因此 interface 本身不等于 nil。这是 interface 语义,不是泛型特例。

7. interface 会隐藏静态具体类型,但不会销毁动态类型

7.1 静态视角变宽

1
var r io.Reader = file

之后通过变量 r,编译器只允许使用 io.Reader 方法集。具体类型对普通静态操作不可见,这就是抽象的目的。

7.2 动态视角通常仍在

若 interface 值非 nil,它仍包含一个动态具体类型,可以通过:

1
f, ok := r.(*os.File)

reflect.TypeOf(r) 观察。

所以“转成 interface 后类型完全丢失”不准确;更准确的是:具体类型不再是变量的静态类型,但作为动态类型随 interface 值保留。

7.3 有些抽象确实不可逆

并非所有宽化都能无条件恢复:

  • 如果只保存序列化字节而没有类型标签,原具体类型可能无法唯一判断;
  • unsafe.Pointer、整数地址或自定义 erased storage 可能主动抛弃类型联系;
  • 多个具体类型经有损转换得到同一值后,无法从结果反推来源类型;
  • interface 只允许断言它实际携带的动态类型,不能恢复编译期曾经出现但未存入值中的任意信息。

因此不要使用“类型信息永不丢失”这种无限结论。Go 保证的是类型系统和各语言构造的语义,不保证任意程序都能从运行时数据逆推出完整编译期历史。

8. 与 Java 类型擦除不宜简单对立

Java 泛型和 Go 泛型的运行时模型不同,但用“Java 擦除有缺陷,Go 两层信息都完整”来概括会掩盖真正问题。

应分别比较:

  • 参数化类型在语言层是否具有可区分身份;
  • 运行时反射 API 暴露哪些类型参数;
  • 泛型代码如何生成和复用机器码;
  • 转换到顶层抽象后还保留哪些动态描述。

Go 当前的 shape/dictionary 是实现选择,不是“比类型擦除更强”的语言口号。判断某段代码是否类型安全,应先读函数签名和规范,而不是从编译器后端策略倒推。

9. 用最小实验验证三层边界

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package main

import (
	"fmt"
	"reflect"
)

type Box[T any] struct{ Value T }
type User struct{ Name string }
type Order struct{ ID int }

func Through[T any](v T) (reflect.Type, reflect.Type) {
	return reflect.TypeOf(v), reflect.TypeOf(Box[T]{Value: v})
}

func Erase[T any](v T) any { return v }

func main() {
	u, ub := Through(&User{})
	o, ob := Through(&Order{})
	fmt.Println(u, o, u == o)
	fmt.Println(ub, ob, ub == ob)

	x := Erase(&User{})
	fmt.Printf("dynamic=%T asserted=%t\n", x, x.(*User) != nil)
}

输出为:

1
2
3
*main.User *main.Order false
main.Box[*main.User] main.Box[*main.Order] false
dynamic=*main.User asserted=true

它证明公开可观察语义中的类型身份不同,但不能单独证明编译器采用何种代码生成策略。要观察后端实现,还需结合:

1
2
go build -gcflags=-S ./...
go tool nm <binary>

汇编与符号只用于研究当前实现,不应写进业务正确性前提。

10. 常见判断的校准

说法更准确的结论
泛型经过多层调用会逐渐丢类型不会自动发生;由每层签名决定静态类型关系
T any 等于把值转成 any不等于;前者是宽约束,后者是接口静态类型
shape 相同就是同一类型错;shape 是代码生成分类,类型身份由语言规则决定
dictionary 保存所有完整类型信息过度承诺;它保存当前实现执行共享代码所需的信息
转成 interface 后具体类型消失静态视角变宽,但非 nil interface 通常仍携带动态类型
Go 泛型绝不会丢失任何类型信息范围过大;有损转换、序列化和 unsafe 可以主动擦除联系

11. 复习索引

  1. 约束是类型集合,也是泛型函数可用操作的静态上限;
  2. 实例化通过类型替换和约束检查,得到非泛型函数或命名类型;
  3. 静态具体类型是否保留,首先看 API 签名,不看调用层数;
  4. GCShape 共享机器码,dictionary 为当前实现补充实例信息,二者都不是规范协议;
  5. interface conversion 隐藏静态具体类型,但携带运行时动态类型;
  6. 反射能区分具体实例,不代表运行时保存了全部编译期推导历史。

12. 核验入口

  • Go specification 的 Type parameter declarations、Instantiations、Assignments 与 Conversions;
  • reflect.Type 文档:类型身份、可比较性与 TypeOf
  • cmd/compile/internal/noder:当前 dictionary 构造;
  • cmd/compile/internal/reflectdata:实例化类型、shape 与反射数据生成;
  • go build -gcflags=-S:观察当前版本生成的 wrapper 和 shape 函数。
本文由作者按照 CC BY 4.0 进行授权