深入理解 Go / Rust / C++ 泛型
从语言设计 → 类型系统 → 编译器实现 → 机器码 → 内存布局 → 性能与工程实践这几个层次,系统地比较 Go、Rust 和 C++ 的泛型
先给出一个最重要的结论:
Go 的泛型采用 GC Shape Stenciling(基于 GC 形状的代码生成)与 Dictionary(字典)相结合的实现方式;Rust 的泛型主要采用 Monomorphization(单态化);C++ 的模板则采用模板实例化机制,在编译期根据模板参数生成特化的类型或函数。
三者都能实现编译期类型检查,但它们在代码生成、类型信息传递、抽象能力和编译成本方面有显著差异。
需要注意,C++ 模板和 Rust 泛型在语言设计上也有很大区别,不能简单地认为它们只是两种不同的单态化实现。
下面开始拆解。
一、先理解:泛型究竟解决什么问题?
1. 没有泛型时,我们会遇到什么问题?
假设你正在开发一个系统,需要实现一个通用的 Max 函数。
先不考虑泛型,我们只能为不同类型编写不同的函数。
func MaxInt(a, b int) int {
if a > b {
return a
}
return b
}
func MaxFloat64(a, b float64) float64 {
if a > b {
return a
}
return b
}
func MaxString(a, b string) string {
if a > b {
return a
}
return b
}
这三个函数的业务逻辑几乎完全相同,区别只在于参数类型和比较操作。
假如我们还要支持 int32、int64、uint64、float32 等类型,就得不断重复代码。
我们希望写一次逻辑,让多个类型复用。
这就是泛型的基本目的。
2. Go 泛型怎么写?
Go 1.18 开始正式支持类型参数。
package main
import "fmt"
func Max[T ~int | ~int32 | ~int64 | ~float32 | ~float64](
a, b T,
) T {
if a > b {
return a
}
return b
}
func main() {
fmt.Println(Max(10, 20))
fmt.Println(Max(1.5, 2.5))
}
这里最重要的是:
[T ~int | ~int32 | ~int64 | ~float32 | ~float64]
它声明了一个类型参数 T,并通过类型约束限定 T 可以代表哪些类型。
其中:
- T:类型参数。
~int:允许底层类型为 int 的类型。|:表示类型集合的并集。~int | ~float64:表示底层类型为 int 或 float64 的类型集合。
例如:
type UserID int
func main() {
fmt.Println(Max(UserID(10), UserID(20)))
}
UserID 是一个独立定义的类型,但其底层类型是 int,因此满足 ~int 的约束。
注意:如果只写 int,就不能因此认为所有底层类型为 int 的自定义类型也会被允许。
还有一个容易忽略的细节:
type UserID int
type OrderID int
虽然 UserID 和 OrderID 的底层类型相同,但它们是不同的定义类型,不能直接混合传入要求同一个 T 的函数。
例如:
Max(UserID(1), OrderID(2))
无法通过类型检查,因为编译器不能为这个调用推导出同时满足两个参数的同一个 T。
这说明 Go 泛型并不是简单地把类型都抹掉。
在编译期,Go 仍然会检查具体类型、类型约束以及运算是否合法。
二、Go、Rust、C++ 的泛型分别是什么?
先用一个表格建立整体认识。
| 对比维度 | Go 泛型 | Rust 泛型 | C++ 模板 |
|---|---|---|---|
| 引入时间 | Go 1.18,2022 年 | Rust 早期版本即支持 | C++98 起支持 |
| 主要语法 | [T any] | <T> | template<typename T> |
| 类型约束 | Interface / Type Set | Trait Bounds | 模板约束、Concepts 等 |
| 编译期类型检查 | 是 | 是 | 是 |
| 主要代码生成方式 | GC Shape + Dictionary | 单态化 | 模板实例化 |
| 是否为每个具体类型生成独立代码 | 不一定 | 通常按具体实例生成 | 通常按具体实例化生成 |
| 类型信息如何传递 | 某些操作需要字典 | 通常通过单态化确定 | 通常在实例化时确定 |
| 泛型容器的内存布局 | 由具体类型决定 | 由具体类型决定 | 由具体类型决定 |
| 运行时动态分派 | 可能通过接口等实现 | 可通过 dyn Trait 实现 | 可通过虚函数等实现 |
| 编译器复杂度 | 相对克制 | 较复杂 | 模板系统非常复杂 |
| 典型代价 | 部分场景存在间接操作或代码共享限制 | 编译时间、二进制体积 | 编译时间、二进制体积、模板诊断复杂度 |
这里有一个重要区别。
泛型的语言语义,不等于泛型的底层实现。
例如,Go 规定了泛型函数应该如何进行类型检查、类型推导和调用,但并不意味着每个具体类型都必须拥有独立的一份机器码。
同样,Rust 的泛型在语言层面有自己的类型系统,而单态化是 Rust 编译器生成可执行代码的重要机制。
C++ 模板则是一套更广泛的编译期机制,除了类型参数,还支持非类型模板参数、模板模板参数、偏特化等能力。
因此,接下来必须分开研究两个问题:
- 编译器如何确定一个泛型程序是否合法?
- 编译器最终如何把它变成机器码?
第二个问题才是特别关心的底层实现。
三、Go 泛型的底层实现:GC Shape + Dictionary
这是整个问题最值得深入的部分。
Go 泛型的实现并不是简单的「每种类型生成一份代码」,也不是简单的「所有类型共用一份代码」。
它采用的是一种折中方案:
GC Shape Stenciling + Dictionary。
官方设计文档对此有明确说明。
为了理解它,我们先从最简单的泛型函数开始。
3.1 从一个 Go 泛型函数开始
func Identity[T any](x T) T {
return x
}
我们这样调用:
a := Identity[int](10)
b := Identity[string]("hello")
c := Identity[*User](&User{})
从语言层面来看,我们使用了三个实例:
Identity[int]
Identity[string]
Identity[*User]
但问题来了。
假设 Go 编译器对每种具体类型都生成一份独立机器码,那么它就会产生类似下面的三个函数:
func IdentityInt(x int) int {
return x
}
func IdentityString(x string) string {
return x
}
func IdentityUserPtr(x *User) *User {
return x
}
这叫作针对具体类型生成独立实现。
这种方式可以让每个版本都使用最合适的机器类型,但如果类型组合很多,生成的代码也会变多。
Go 选择了一种不同的策略。
它会根据类型的 GC Shape 对实例进行归类,让形状相同的实例共享同一份生成的代码。
但要分清两件事:
- 代码共享的判据就是 GC Shape:形状相同 → 落到同一份生成代码(stencil)。
- 共享的代码不一定能直接处理所有具体类型:代码里若存在需要具体类型的操作(类型的大小、字段偏移、方法地址、
%T等),就要靠 Dictionary 在运行时补充信息。
这需要进一步解释。
3.2 什么是 GC Shape?
GC 是 Garbage Collection,也就是垃圾回收。
Go 是带有垃圾回收器的语言,因此编译器必须知道对象和栈帧中的哪些位置包含指针。
举个简单的例子。
假设有三个类型:
type A struct {
X int64
Y int64
}
type B struct {
X *int64
Y *int64
}
type C struct {
X int64
Y *int64
}
在常见的 64 位平台上,可以把它们的内存结构粗略理解为:
A:
+--------+--------+
| int64 | int64 |
+--------+--------+
非指针 非指针
B:
+--------+--------+
| *int64 | *int64 |
+--------+--------+
指针 指针
C:
+--------+--------+
| int64 | *int64 |
+--------+--------+
非指针 指针
假设每个字段都是 8 字节,并忽略额外的对齐等细节。
这三个类型的大小都可能是 16 字节。
但是它们的指针布局不同:
- 对于 A:第 1 个机器字不是指针,第 2 个机器字不是指针。
- 对于 B:第 1 个机器字是指针,第 2 个机器字是指针。
- 对于 C:第 1 个机器字不是指针,第 2 个机器字是指针。
垃圾回收器需要区分这些布局。
如果把 A 的数据误认为两个指针,就可能错误地解释内存。
如果把 B 的指针误认为普通整数,又可能导致垃圾回收器无法正确追踪对象。
所以,GC Shape 不是简单的「占用多少字节」。
它主要与以下信息相关:
- 类型的大小。
- 所需的内存对齐。
- 内存中哪些位置包含指针。
官方设计文档对 GC Shape 的定义也强调了这些因素。
一个重要的纠正
不能认为:
A 和 B 都是 16 字节 所以 A 和 B 一定可以共享泛型代码。
这是错误的。
它们的指针布局不同。
更重要的是,即使两个类型的 GC Shape 相同,泛型代码中的操作也可能存在差异。
这就是 Dictionary 出场的原因。
3.3 Go 为什么不直接按照 GC Shape 合并所有类型?
假设有下面这个泛型函数:
func PrintValue[T any](v T) {
fmt.Println(v)
}
当 T 是 int 时:
PrintValue[int](123)
当 T 是 string 时:
PrintValue[string]("hello")
这两种类型不仅表示方式不同,打印方式也不同。 编译器需要知道具体类型,才能正确执行相关操作。
又例如:
func NewValue[T any]() T {
var v T
return v
}
如果 T 是一个结构体,那么编译器必须知道它的具体布局,才能正确处理零值和返回值。
再比如:
func IsZero[T comparable](v T) bool {
var zero T
return v == zero
}
对于不同类型,比较操作可能有不同的实现方式。
因此,仅仅知道一个类型的大小和指针布局是不够的。
Go 的设计思路是:
- 对适当相同 GC Shape 的实例共享一部分生成代码。
- 对代码执行过程中需要区分具体类型的操作,通过 Dictionary 提供必要的信息。
这就是两个机制必须一起工作的原因。
四、Go 的 Dictionary 到底是什么?
这是 Go 泛型底层实现最容易让人误解的部分。
先说结论:
Dictionary 不是泛型对象本身,也不是一个装着所有类型值的容器。它是编译器生成的一组类型相关元数据及操作所需的信息。
官方文档指出,Go 1.18 的实现通过字典传递类型参数相关的信息,以便生成的泛型函数实例能够正确处理具体类型。
我们用一个例子来理解。
4.1 一个需要比较具体类型的泛型函数
func Max[T ~int | ~int64](a, b T) T {
if a > b {
return a
}
return b
}
调用:
x := Max[int](10, 20)
y := Max[int64](100, 200)
在源码层面,我们看到的是:
Max[T]
但是编译器生成的机器码必须能够完成具体的比较。
例如,在 amd64 平台上:
- int 通常是 64 位整数。
- int64 是 64 位有符号整数。
在这个例子里,两者的 GC Shape 相同(8 字节、无指针),因此会落到同一份 stencil。
但要注意:判据始终是 GC Shape,而不是「大小相同」。大小相同而指针布局不同的类型(例如 int64 与 *int64)不会合并。
而且,这个例子还有一个值得注意的细节:
Max 中的 > 是内建操作,不是用户通过接口传入的任意方法。
不能据此推断 Go 会把所有泛型运算都变成 Dictionary 中的函数调用。
Dictionary 需要提供哪些信息,取决于具体泛型函数使用了什么类型相关操作。
4.2 Dictionary 在概念上包含什么?
为了便于理解,我们可以把它抽象成:
type DictionaryConcept struct {
TypeInfo []TypeMetadata
DerivedTypes []TypeMetadata
SubDictionaries []DictionaryConcept
Helpers []OperationMetadata
}
这只是帮助理解的伪代码,并不是 Go 运行时中的真实结构体定义。
真实字典由编译器根据泛型函数的需求生成。
它可能包含:
- 类型参数对应的类型信息。
- 泛型函数中使用的派生类型信息。
- 调用其他泛型函数时需要的子字典。
- 某些类型相关操作需要的辅助信息。
官方实现文档详细讨论了这些内容。
为什么需要类型信息?
例如:
func Describe[T any](v T) {
fmt.Printf("%T\n", v)
}
这里的 %T 要求程序获得具体的类型信息。
编译器必须确保这个操作能够正确执行。
在泛型代码中,类型参数 T 并不是运行时可以随意读取的普通变量。
它是编译期的类型参数。
如果某个操作需要具体类型信息,编译器就必须安排适当的机制来提供这些信息。
为什么需要子字典?
考虑:
func Outer[T any](x T) {
Inner[T](x)
}
func Inner[T any](x T) {
fmt.Println(x)
}
Outer 并不知道 T 最终是什么具体类型。
例如:
Outerint
或者:
Outerstring
当 Outer 调用 Inner[T] 时,Inner 所需的类型信息需要从 Outer 的泛型实例化上下文中传递下去。
这就是子字典等机制存在的原因之一。
当然,具体代码生成还可能进行优化,不能认为所有泛型函数调用都会机械地多出一次独立字典分配。
4.3 Dictionary 一定意味着运行时反射吗?
不一定。
这里需要区分三个概念:
| 概念 | 含义 |
|---|---|
| 类型参数 | 源码层面的泛型参数,例如 T |
| Dictionary | 编译器生成的、与具体类型相关的元数据及操作信息 |
| Reflection | 程序通过反射 API 检查或操作类型和值 |
它们不是同一个东西。
Go 泛型并不是通过反射来完成所有类型操作的。
对于很多操作,编译器可以在编译期直接确定具体类型和指令。
对于另一些操作,生成的代码需要借助 Dictionary 或运行时辅助机制。
因此,更准确的说法是:
Go 泛型可能在运行时携带类型相关的信息,但这不意味着泛型调用本身等价于反射。
五、Go 的泛型函数最终是怎样执行的?
现在我们把前面的概念串起来。
假设你有:
func First[T any](items []T) T {
return items[0]
}
然后:
ints := []int{10, 20, 30}
x := First[int](ints)
从源码角度来看,执行过程可以抽象为:
编译期
│
▼
解析泛型函数 First[T]
│
▼
检查 T 是否满足约束
│
▼
根据实际类型实例化 First[int]
│
▼
分析所需的 GC Shape 和类型信息
│
▼
生成或复用相应的代码实现
│
▼
准备需要的 Dictionary
│
▼
链接
│
▼
运行期
│
▼
调用生成的机器码
这是一张概念流程图,不代表 Go 编译器内部只有这些步骤,也不意味着每次调用都会动态创建一个 Dictionary。
Dictionary 通常是编译器生成的静态数据,不是每次调用都在堆上分配一个字典。
这一点非常重要。
如果每次调用泛型函数都要在堆上分配字典,开销会非常不合理。
实际实现中,字典通常在编译期生成,并由调用代码引用。
六、Rust 泛型的底层实现:Monomorphization
接下来研究 Rust。
如果说 Go 选择了代码共享与类型信息传递之间的折中,那么 Rust 的核心思路就更直接:
在编译期确定泛型的具体类型,并为使用到的具体类型组合生成相应的实现。
Rust 官方文档将这一过程称为 Monomorphization,即单态化。
6.1 Rust 泛型示例
fn identity<T>(x: T) -> T {
x
}
fn main() {
let a = identity::<i32>(10);
let b = identity::<f64>(3.14);
let c = identity::<String>("hello".to_string());
}
源码只定义了一次:
fn identity<T>(x: T) -> T {
x
}
但编译器需要为这些具体类型生成可执行代码。
概念上,可以把结果理解成:
fn identity_i32(x: i32) -> i32 {
x
}
fn identity_f64(x: f64) -> f64 {
x
}
fn identity_string(x: String) -> String {
x
}
这些代码只是为了帮助理解单态化的伪代码,不是 Rust 编译器真正生成的源文件。
实际生成的是编译器内部表示和机器码。
这意味着什么?
对于:
let a = identity::<i32>(10);
编译器知道:
T = i32
因此可以把类型参数替换为 i32,再继续优化。
对于:
let b = identity::<f64>(3.14);
编译器则处理另一个具体类型。
到生成机器码时,泛型抽象已经被具体类型实例化了。
一般情况下,运行时不需要再通过一个 Dictionary 判断 T 到底是什么。
这就是 Rust 泛型通常被称为零成本抽象的重要原因。
不过,必须强调:
零成本泛型不意味着零编译成本,也不意味着最终二进制没有额外开销。
单态化可能导致:
- 编译时间增加。
- 机器码体积变大。
- 更多实例化函数需要进行优化。
- 编译缓存和链接工作增加。
Rust 官方文档也明确说明,单态化会生成针对具体类型的实现。
6.2 Rust 的泛型约束去了哪里?
假设我们写:
fn max_value<T: PartialOrd>(a: T, b: T) -> T {
if a >= b {
a
} else {
b
}
}
这里的:
T: PartialOrd
表示 T 必须实现 PartialOrd Trait。
那么,编译器怎么知道 a >= b 是合法的?
因为 PartialOrd 为类型提供了相关的比较能力。
当编译器检查:
max_value::<i32>(10, 20)
时,它会验证 i32 满足相应的 Trait 约束。
随后,在单态化过程中,编译器根据具体类型生成相应实现。
从概念上看:
fn max_i32(a: i32, b: i32) -> i32 {
if a >= b {
a
} else {
b
}
}
由于 i32 的比较方式已经确定,编译器通常能够将相关操作直接编译为具体指令。
这与 Go 的 Dictionary 机制形成了一个鲜明对比。
Go 的泛型实现可能在生成代码中传递 Dictionary,以便代码共享时仍能正确处理类型差异。
Rust 则主要依赖具体类型实例化,让编译器有机会直接生成针对具体类型的代码。
当然,Rust 的 Trait 系统还有动态分派、关联类型、泛型关联类型等机制,不能把整个 Trait 系统都简单等同于单态化。
七、Rust 中的泛型与动态分派不是一回事
这部分很关键。
很多人会认为:
Rust 的 Trait 就相当于 Go 的 Interface。
这句话只在某些场景下成立。
Rust Trait 可以用于静态分派,也可以通过 Trait Object 实现动态分派。
这两种机制的底层行为并不一样。
7.1 静态分派:泛型参数
例如:
trait Worker {
fn work(&self);
}
struct Planner;
impl Worker for Planner {
fn work(&self) {
println!("planning");
}
}
fn execute<T: Worker>(worker: &T) {
worker.work();
}
调用:
let planner = Planner;
execute(&planner);
这里的 T 是具体类型 Planner。
编译器可以生成相应的具体实现。
概念上相当于:
fn execute_planner(worker: &Planner) {
worker.work();
}
由于类型已经确定,编译器通常可以直接调用 Planner::work,甚至进一步内联函数。
这种方式一般没有通过虚函数表进行动态分派的必要。
7.2 动态分派:Trait Object
再看:
fn execute(worker: &dyn Worker) {
worker.work();
}
这里使用的是:
&dyn Worker
它不是普通的泛型类型参数。
这是 Trait Object。
概念上,Trait Object 的引用通常包含:
+------------------+
| 数据指针 |
+------------------+
| vtable 指针 |
+------------------+
数据指针指向具体对象。
vtable 保存与该 Trait Object 相关的类型具体实现所需的函数入口等信息。
调用:
worker.work();
时,程序通过动态分派机制确定要调用哪个实现。
需要注意,Trait Object 的实际表示和调用细节属于 Rust 编译器与 ABI 实现层面的内容,上图是帮助理解的概念模型。
两种机制对比
| 特性 | T: Worker | &dyn Worker |
|---|---|---|
| 分派方式 | 通常静态分派 | 动态分派 |
| 编译器是否知道具体类型 | 在具体实例化时知道 | 调用点不一定知道 |
| 是否通常需要 vtable 调用 | 不需要 | 需要 |
| 是否可能内联 | 通常更容易 | 一般更困难,但优化器有时能消除间接调用 |
| 是否容易产生大量实例化代码 | 可能 | 通常可以共享动态调用路径 |
所以,Rust 泛型并不是只能走静态分派。
它同时提供了静态分派和动态分派两套机制。
泛型约束决定哪些类型可以参与调用;分派策略决定调用具体实现的方式。
这是两个不同的维度。
八、C++ 模板的底层实现:模板实例化
现在来看 C++。
C++ 的模板与 Go、Rust 的泛型有一个共同点:
它们都可以让程序员编写通用算法和数据结构。
但 C++ 模板的设计范围更广。
它不仅允许类型参数,还支持非类型模板参数、模板模板参数,以及复杂的模板元编程。
C++ 模板的核心机制之一是模板实例化:编译器使用具体模板实参生成相应的特化实体。
8.1 C++ 模板示例
template<typename T>
T identity(T x) {
return x;
}
int main() {
int a = identity<int>(10);
double b = identity<double>(3.14);
}
概念上,编译器会生成类似:
int identity_int(int x) {
return x;
}
double identity_double(double x) {
return x;
}
的具体实现。
同样,这只是概念上的展开。
真实的 C++ 编译器会先进行模板实参推导、语义检查、模板实例化和优化,再生成目标代码。
如果你使用 GCC 或 Clang,后续通常还会经过 LLVM 或其他后端处理。
8.2 C++ 模板与 Rust 泛型的相似点
二者都经常生成针对具体类型的机器码。
因此:
identity<int>(10)
和:
identity::<i32>(10)
在底层思路上存在相似之处。
但不要因此认为二者的类型系统相同。
8.3 一个关键差异:类型检查发生在什么时候
这是两种「单态化」最容易混淆的地方:
- Rust:泛型函数体在定义处就用 trait bound 做类型检查。编译器要求函数体对任意满足约束的 T 都成立,写错在定义时就报错,不必等到某个具体实例。
- C++:模板体在定义处只做与模板参数无关的检查(两阶段查找的第一阶段),涉及
T的语义检查要等到实例化时才进行。同一个模板可能对int合法、对std::string报错。
由此带来两个直接后果:
- C++ 的模板错误往往在实例化点展开一长串调用栈;Rust 的约束错误集中在定义处,提示「
T不满足bound」。 - Rust 的泛型体被保证「对全部满足约束的类型都成立」,C++ 模板体只保证「对实际出现的实例成立」。
这是语法相似、编译期行为不同的典型例子,也是不能把两者简单等同的原因。
C++ 模板允许许多在 Rust 中需要不同机制才能表达的编译期操作。
例如,C++ 支持非类型模板参数:
template<typename T, std::size_t N>
struct Array {
T data[N];
};
然后:
Array<int, 10> a;
Array<int, 20> b;
这里的 N 不是一个类型,而是一个编译期数值参数。
Array<int, 10> 与 Array<int, 20> 是不同的模板特化类型。
Rust 也支持常量泛型:
struct Array<T, const N: usize> {
data: [T; N],
}
因此,这项能力并不是 C++ 独有的。
但 C++ 模板还具有独特的偏特化、SFINAE、Concepts 和模板元编程生态,这些特性共同构成了一个非常强大、也非常复杂的编译期系统。
九、C++ 模板为什么经常被说成「编译期编程」?
因为模板不只是用来生成不同类型的容器。
它还可以参与复杂的编译期计算和代码选择。
例如:
template<int N>
struct Factorial {
static constexpr int value = N * Factorial<N - 1>::value;
};
template<>
struct Factorial<0> {
static constexpr int value = 1;
};
int main() {
constexpr int result = Factorial<5>::value;
}
这里的:
Factorial<5>
会通过模板实例化递归地形成一系列类型。
概念上相当于:
Factorial<5>
│
▼
5 * Factorial<4>::value
│
▼
5 * 4 * Factorial<3>::value
│
▼
5 * 4 * 3 * 2 * 1
│
▼
120
这段代码使用模板递归表达了编译期计算。
不过,在现代 C++ 中,类似的数值计算通常更适合使用 constexpr 或 consteval,不一定要使用模板递归。
这只是为了说明 C++ 模板的表达能力。
Rust 和 Go 也支持不同形式的编译期检查与计算,但不能直接把它们的泛型系统视为与 C++ 模板元编程完全等价的机制。
十、C++ 模板为什么可能导致二进制膨胀?
假设有:
template<typename T>
T process(T x) {
return x + x;
}
然后:
process<int>(1);
process<double>(1.0);
process<long>(2L);
process<float>(2.0f);
编译器通常需要为这些不同的类型实例化生成相应实现。
概念上:
process<int>
process<double>
process<long>
process<float>
于是,编译器需要处理多个具体版本。
如果模板被大量使用,并且参数组合非常多,就可能导致:
- 编译时间增加。
- 目标文件变大。
- 链接时间增加。
- 机器码体积增大。
不过,这并不意味着最终二进制必然包含四份完全独立、大小相同的函数。
编译器可能内联函数,链接器可能合并某些重复实例,优化器也可能删除不可达代码。
C++ 模板实例化和链接层面的去重是不同阶段的事情。
十一、三者最核心的差异:到底生成几份机器码?
现在把 Go、Rust 和 C++ 放到同一个例子里。
假设我们定义一个泛型函数:
Process<T>(value T) -> T
并使用以下类型:
int32
int64
float64
*User
*Order
我们想知道编译器最终生成几份机器码。
答案是:不能只根据源码中使用了几个类型参数来确定。
因为三者的策略不同。
11.1 Rust:以具体实例化为核心
Rust 通常会为实际使用的具体类型实例生成独立实现。
概念上:
Process<i32>
Process<i64>
Process<f64>
Process<*User>
Process<*Order>
通常对应不同的单态化实例。
但具体的代码生成和最终二进制布局仍然可能受到优化、内联、链接去重等因素影响。
11.2 C++:以模板实例化为核心
C++ 编译器通常根据不同的模板实参生成相应的模板特化实体。
概念上也是:
Process<int32>
Process<int64>
Process<double>
Process<User*>
Process<Order*>
但同样不能直接推导出最终二进制一定包含五份完全独立的机器码。
11.3 Go:按 GC Shape 共享部分代码
Go 则不同。
判据就是 GC Shape。
例如,所有指针类型在机器层面都是一个机器字、且该字要被 GC 当作指针扫描,因此它们属于同一个 GC Shape:
Process[*User] Process[*Order]
这两个实例会共享同一份生成的代码,具体类型的差异由 Dictionary 在需要时补充(例如字段偏移按 *User 还是 *Order 解读)。
但要注意区分「共享代码」与「所有类型都能合并」:
int与*User:在 amd64 上都是 8 字节,但一个是普通整数、一个要被 GC 扫描,GC Shape 不同,因此不会合并。int64与float64:GC Shape 相同(8 字节、无指针),因此会共享同一份 stencil;但整数加法与浮点加法是不同的指令,这个差异只能靠 Dictionary 里的 helper 在运行时分发。
所以更准确的说法是:GC Shape 决定「共享哪一份代码」,Dictionary 负责「让这份共享代码能针对具体类型做正确的事」。两者缺一不可。
Go 的官方设计文档明确将这种实现方式描述为 GC Shape Stenciling 与 Dictionary 的结合。
对比总结
| 问题 | Go | Rust | C++ |
|---|---|---|---|
| 是否以具体类型实例化为基础 | 是 | 是 | 是 |
| 是否通常针对不同具体类型生成不同实现 | 不一定 | 通常是 | 通常是 |
| 是否能够按某种形状共享代码 | 可以 | 不是其泛型实现的主要机制 | 编译器和链接器可能进行其他形式的去重 |
| 是否需要类型相关的额外信息 | 某些场景需要 Dictionary | 静态分派通常不需要额外运行时字典 | 普通模板实例化通常在编译期确定类型 |
| 核心目标 | 在性能、代码共享和实现复杂度间折中 | 高效静态分派与类型安全 | 强大的编译期抽象和代码生成 |
十二、把三者放到内存布局层面比较
这部分对理解底层系统尤其重要。
假设我们定义一个通用容器。
12.1 Go
type Box[T any] struct {
Value T
}
然后:
a := Box[int64]{Value: 10}
b := Box[*User]{Value: &User{}}
Box[int64] 和 Box[*User] 的字段类型不同。
它们的实际内存布局必须能够正确表示各自的字段。
对于 Box[int64]:
+----------------+
| int64 |
+----------------+
对于 Box[*User]:
+----------------+
| *User |
+----------------+
在常见的 64 位平台上,它们都可能占用 8 字节。
但二者的语义与 GC 指针布局不同。
泛型类型实例化不会让 Go 忽略这些差异。
12.2 Rust
struct Boxed<T> {
value: T,
}
然后:
let a = Boxed::<i64> { value: 10 };
let b = Boxed::<&User> {
value: &user,
};
Boxed<i64> 和 Boxed<&User> 同样是不同的具体类型。
编译器根据具体类型确定布局。
此外,Rust 的泛型类型还会受到 repr(Rust)、repr(C)、对齐规则、字段重排等因素影响。
不能把 Rust 泛型布局简单理解成「将 T 原样替换进去,其他所有规则都不变」。
12.3 C++
template<typename T>
struct Boxed {
T value;
};
然后:
Boxed<long long> a{10};
Boxed<User*> b{nullptr};
同样,两个模板特化具有各自的具体类型和布局。
关键结论
三者都必须保证具体实例化类型的内存表示符合各自的语言规则。
Go 的 GC Shape 主要用于编译器内部的代码生成与共享决策,不是说不同 Shape 或不同类型的容器在语言层面会变成同一个类型。
这点必须牢牢记住。
十三、Go 的 Dictionary 与 Rust 的 vtable 有什么区别?
这是一个很容易混淆的问题。
先看两种不同的概念。
Go Dictionary
Go 泛型的 Dictionary 是编译器为泛型代码生成的一组类型相关信息。
它服务于泛型实例化,帮助生成的代码正确处理具体类型。
它不是 Go Interface 的另一个名字。
Rust vtable
Rust 的 vtable 主要与 Trait Object 的动态分派相关。
当程序使用:
&dyn Worker
时,调用方可以通过 vtable 找到具体实现的函数入口。
为什么它们不能直接等同?
因为它们服务的目的不同。
| 对比 | Go Dictionary | Rust vtable |
|---|---|---|
| 主要用途 | 支持泛型代码的类型相关操作 | 支持 Trait Object 动态分派 |
| 是否等价于泛型类型参数 | 否 | 否 |
| 是否必然用于每次方法调用 | 否 | 动态分派调用通常需要使用 |
| 是否包含类型相关信息 | 是 | 与具体 Trait Object 实现有关 |
| 是否意味着动态分派 | 不意味着 | 是动态分派机制的一部分 |
换句话说:
Go 泛型可能通过 Dictionary 补充类型信息,而 Rust 的普通泛型通常通过单态化获得具体类型。
但当 Rust 使用 dyn Trait 时,它就进入了动态分派的世界。
Go Dictionary、Rust 泛型参数和 Rust vtable 是三个不同的概念。
十四、为什么 Go 不完全采用 Rust 的单态化方案?
这是一个非常值得思考的设计问题。
Go 团队为什么不直接给每种类型生成一份完整的机器码?
一种重要的考虑是代码生成规模。
假设你有一个复杂的 Agent Runtime。
内部有:
type Task[T any] struct {
Input T
Output T
}
func Execute[T any](task Task[T]) {
// 复杂调度逻辑
}
如果系统里存在大量不同的 T,完全按类型生成代码可能增加编译器需要处理的实例数量。
Go 选择通过 GC Shape 共享部分代码,避免纯粹按照所有具体类型生成一份独立代码。
但这也不是没有代价。
为了让共享代码正确工作,编译器必须通过 Dictionary 等机制提供它需要的类型相关信息。
于是,Go 的设计在两个方向之间进行折中:
完全单态化
│
├── 优点:具体类型已知,容易生成专门代码
└── 代价:可能产生更多实例化代码
GC Shape + Dictionary
│
├── 优点:某些实例能够共享生成代码
└── 代价:部分类型相关操作需要额外机制
这里也要强调,不能把 Go 泛型的性能简单概括成「比 Rust 慢」。
对于一个简单的泛型函数,Go 可能生成非常高效的机器码。
对于另一个复杂函数,Rust 的单态化也可能因为内联、优化或实例数量增加而产生不同的成本。
真正的差异取决于具体代码、编译器优化以及调用场景。
十五、为什么 Go 不直接采用 C++ 模板那样的机制?
这涉及语言设计哲学。
Go 的泛型是在语言已经发展多年后才加入的。
Go 需要尽量兼容既有语言设计,并维持相对简单的编译模型。
C++ 模板则经历了很长时间的发展,形成了相当复杂的编译期抽象体系。
例如 C++ 可以使用:
- 模板偏特化。
- 非类型模板参数。
- 模板模板参数。
- SFINAE。
- Concepts。
- 模板元编程。
Go 则更强调通过类型参数与约束表达通用算法。
例如:
func Map[T any, U any](
input []T,
transform func(T) U,
) []U {
result := make([]U, len(input))
for i, item := range input {
result[i] = transform(item)
}
return result
}
这里,Go 使用显式类型参数和函数参数表达转换逻辑。
而 C++ 可以通过模板参数、类型推导和其他编译期机制组合出更加复杂的抽象。
这并不是说哪种语言绝对更好。
而是三者对泛型的定位不同:
- Go: 让通用数据结构与算法更容易表达,同时维持语言和工具链的相对简单。
- Rust: 结合强类型系统、Trait 约束和所有权模型,以单态化为基础实现高效的静态抽象。
- C++: 提供非常强大的模板实例化与编译期编程能力,同时承担相应的语言和编译器复杂度。
十六、一个更有意义的例子:实现通用的 Agent Scheduler
现在把理论放回实际开发场景。
假设正在实现一个 Scheduler,需要从一组候选任务中选择一个任务。
我们定义一个通用的选择函数:
SelectBest<T>(items) -> T
不同语言会怎么处理?
16.1 Go
package main
type Scored interface {
~int | ~int64 | ~float64
}
func Max[T Scored](items []T) T {
best := items[0]
for _, item := range items[1:] {
if item > best {
best = item
}
}
return best
}
这个例子演示了通用数值比较。
如果你把它用于真实的 Scheduler,可能会将 T 替换成任务评分、优先级或其他可比较的值。
Go 编译器会检查类型约束,并根据具体实例化生成相应实现。
但如果任务评分依赖复杂的业务逻辑,例如动态策略、插件或者模型评估,那么仅仅使用泛型未必是最佳抽象。
16.2 Rust
fn max<T: PartialOrd + Copy>(items: &[T]) -> Option<T> {
items.iter().copied().max_by(|a, b| {
a.partial_cmp(b).unwrap()
})
}
这里为了让代码更短,使用了 Copy,但需要注意:
PartialOrd 并不保证所有值都能产生有序比较结果。
例如浮点数的 NaN 就会让 partial_cmp 返回 None。
所以,在生产代码里直接 unwrap() 需要谨慎处理。
这个例子还说明,Rust 的 Trait Bound 可以组合多个约束。
编译器会验证类型是否满足约束,并根据具体类型生成实现。
16.3 C++
#include <algorithm>
#include <optional>
#include <span>
template<typename T>
std::optional<T> max_value(std::span<const T> items) {
if (items.empty()) {
return std::nullopt;
}
return *std::max_element(items.begin(), items.end());
}
这个函数使用了模板参数 T,并通过标准库算法找到最大值。
它没有显式写出 T 的完整约束,但调用能否成立仍然取决于 std::max_element 对相关操作的要求,以及具体类型是否支持相应的比较。
在 C++20 中,可以通过 Concepts 更明确地表达约束。
例如:
#include <concepts>
template<std::totally_ordered T>
T max_value(T a, T b) {
return a < b ? b : a;
}
这里的 std::totally_ordered 表达了更强的有序性要求。
不过,Concepts 检查的是类型和表达式是否满足规定的约束,并不自动证明用户自定义的比较操作在数学意义上真的满足全序关系。
十七、如果我们深入到汇编层面,会发生什么?
前面一直在讲编译器策略,现在看更接近机器码的一层。
考虑:
func Add[T ~int64](a, b T) T {
return a + b
}
调用:
x := Add[int64](10, 20)
在 amd64 平台上,编译器最终可能将核心计算转换成类似:
ADDQ ...
这样的整数加法指令。
这里的汇编只是示意,不能当作 Go 编译器对这段代码的精确反汇编结果。
如果换成 Rust:
fn add<T>(a: T, b: T) -> T
where
T: std::ops::Add<Output = T>,
{
a + b
}
调用:
let x = add::<i64>(10, 20);
Rust 编译器可以针对 i64 生成具体实现。
经过优化之后,核心运算也可能变成一条或少量整数加法指令。
C++:
template<typename T>
T add(T a, T b) {
return a + b;
}
调用:
auto x = add<long long>(10, 20);
也可以产生类似的机器码。
因此,在这个简单的加法例子中,三种语言最终可能生成几乎一样的核心计算指令。
这就引出了一个非常重要的结论:
不同的泛型实现策略,并不必然意味着简单运算的最终机器码存在显著差异。
真正的差异通常要在更复杂的场景中观察,例如:
- 大量不同类型的实例化。
- 泛型容器与接口的组合。
- 动态分派。
- 需要运行时类型信息的操作。
- 复杂泛型函数的内联与优化。
- 编译时间与最终二进制体积。
十八、泛型与接口:Go 的另一条路线
如果你已经熟悉 Go,那么还必须理解一个问题:
Go 泛型与 Go Interface 是两套不同的机制,但它们可以组合使用。
例如:
type Worker interface {
Work()
}
我们可以定义:
func Execute[T Worker](worker T) {
worker.Work()
}
这里使用的是泛型类型参数。
与之不同:
func ExecuteInterface(worker Worker) {
worker.Work()
}
这里使用的是接口类型。
两者的区别不只是语法。
泛型版本
func Execute[T Worker](worker T)
编译器需要确定 T 的具体类型,并检查它满足 Worker 约束。
其代码生成可能使用具体类型实例化、GC Shape 共享以及 Dictionary 等机制。
接口版本
func ExecuteInterface(worker Worker)
接口值在 Go 的常见实现中携带动态类型相关的信息和数据表示。
通过接口调用方法时,运行时可以根据动态类型找到对应的方法实现。
接口值的具体内部表示属于 Go 实现细节;常见的非空接口实现包含类型相关信息和数据指针,但不应该把某种内部结构直接当作所有 Go 版本的语言规范。
两者怎么选择?
假设你在 ARES 中定义一个任务执行接口:
type Agent interface {
Execute(ctx context.Context, task Task) error
}
不同 Agent 可能具有完全不同的实现:
Planner
Executor
Verifier
Retriever
这些类型的行为不同,且可能在运行时由配置、插件或注册表决定具体使用哪个实现。
这时,接口往往很自然。
但如果你在编写一个通用的内存容器:
type RingBuffer[T any] struct {
data []T
}
泛型通常更适合,因为你希望在编译期确定元素类型,并获得类型安全的容器操作。
因此,不能简单地说:
泛型比接口快,所以应该尽可能使用泛型。
更合理的思路是:
- 数据结构的元素类型需要参数化:考虑泛型。
- 需要在运行时替换不同实现:考虑接口。
- 需要统一 API,同时又希望类型安全:可以组合使用两者。
十九、Go 泛型的性能开销到底在哪里?
现在我们从工程角度总结。
不要简单地把 Go 泛型理解为「运行时慢」,也不要把 Rust 泛型理解为「绝对零开销」。
需要具体分析。
19.1 Go 泛型可能产生的成本
第一类:类型相关操作
如果泛型函数需要具体类型信息,编译器可能通过 Dictionary 或运行时辅助机制支持这些操作。
但普通的整数运算并不意味着必然发生一次字典函数调用。
第二类:代码共享的优化边界
当多个类型共享一份泛型代码时,编译器不能总是像针对单一具体类型那样自由地进行所有专门化优化。
具体影响取决于共享方式和代码内容。
第三类:接口转换
如果泛型代码把值转换为接口类型,或者调用接口方法,就可能涉及接口表示、装箱或动态分派等额外工作。
不过,这属于具体操作的成本,不能一概归因于泛型本身。
19.2 Rust 泛型可能产生的成本
第一类:单态化产生的代码量
不同类型组合可能生成不同实例。
第二类:编译时间
更多实例意味着编译器可能需要处理更多函数和类型。
第三类:动态分派
如果使用 dyn Trait,调用路径可能包含 vtable 间接调用。
但如果使用静态泛型参数,通常不需要这条动态分派路径。
19.3 C++ 模板可能产生的成本
第一类:模板实例化
大量模板参数组合可能导致编译时间和代码量增加。
第二类:复杂的编译期计算
模板元编程、复杂约束和递归实例化可能增加编译器负担。
第三类:实例化与链接
跨翻译单元使用模板时,编译器可能生成重复实例化,随后由链接机制处理符合条件的重复实体。
总结
| 成本类型 | Go | Rust | C++ |
|---|---|---|---|
| 运行时类型信息 | 某些场景需要 | 普通静态泛型通常不需要 | 普通模板实例化通常不需要 |
| 运行时动态分派 | 使用接口等机制时可能发生 | 使用 dyn Trait 时可能发生 | 使用虚函数等机制时可能发生 |
| 泛型代码数量 | 通过 Shape 共享部分代码 | 单态化可能产生多个实例 | 模板实例化可能产生多个实例 |
| 编译时间 | 取决于实例化和优化规模 | 可能受单态化影响 | 可能受模板实例化和元编程影响 |
| 二进制体积 | 共享代码可能减少重复 | 多实例可能增加体积 | 多实例可能增加体积 |
二十、如果只记住一个底层模型,应该记住什么?
我建议你把三个语言的泛型分别理解为三种代码生成思路。
Go:共享一部分代码,再补充类型信息
泛型源码
│
▼
类型检查
│
▼
泛型实例化
│
▼
GC Shape 分组
│
▼
生成或复用代码
│
▼
Dictionary 等信息
│
▼
最终机器码
关键词:
GC Shape + Dictionary。
Go 希望在代码共享和运行效率之间取得平衡。
Rust:先确定具体类型,再生成专门实现
泛型源码
│
▼
Trait 检查
│
▼
泛型实例化
│
▼
单态化
│
▼
针对具体类型生成
│
▼
优化与链接
│
▼
最终机器码
关键词:
Monomorphization。
Rust 通过具体类型实例化,让编译器能够生成高效的静态调用路径。
C++:根据模板实参实例化模板实体
模板源码
│
▼
模板实参推导
│
▼
约束与语义检查
│
▼
模板实例化
│
▼
优化与链接
│
▼
最终机器码
关键词:
Template Instantiation。
C++ 的模板机制不仅用于生成不同类型的代码,还支持丰富的编译期编程能力。
二十一、实际建议:Go 和 Rust 应该怎么选?
结合我之前在开发 ARES、CodeScope 这类系统软件项目的情况,我建议不要单纯从泛型的理论性能来选择语言,而应该看任务的特点。
1. 如果是 Go Agent Runtime
例如:
- Task、Checkpoint、Event 等通用数据结构。
- 泛型队列、集合、缓存。
- 通用的数据转换函数。
- 类型安全的工具库。
- 需要与 Go 的接口体系自然结合的基础设施。
Go 泛型已经足够实用。
例如:
type Queue[T any] struct {
items []T
}
你不必为了避免 Dictionary 而放弃泛型。
对于大多数普通业务逻辑和基础数据结构,首先考虑代码清晰度、类型安全和可维护性。
如果性能敏感,再通过 benchmark、CPU profile 和内存 profile 确认瓶颈。
2. 如果是 Rust 底层分析器
例如:
- LLVM IR 分析。
- 复杂的数据流分析。
- 类型化的内存图。
- 所有权与借用关系建模。
- 高性能遍历和转换。
- 需要严格控制内存布局与生命周期的数据结构。
Rust 的泛型、Trait 和所有权系统可以形成很强的组合。
例如:
trait Analyzer {
type Input;
type Output;
fn analyze(
&self,
input: Self::Input,
) -> Self::Output;
}
这里使用了关联类型。
不同的分析器可以定义不同的输入和输出类型。
对于具体分析器,使用泛型参数通常可以获得静态分派。
如果你需要在运行时存储不同类型的分析器,也可以使用 Trait Object,或者通过枚举等方式实现统一管理。
3. 如果是 C++ 编译器与模板基础设施
例如:
- LLVM 周边工具。
- 编译器库。
- 高度通用的算法库。
- 复杂的编译期类型计算。
- 需要充分利用 C++ 模板生态的场景。
C++ 模板具有很强的表达能力。
但如果只是实现一个普通的泛型容器或简单算法,没有必要为了模板元编程而增加额外复杂度。
二十二、最后总结
我们把整个问题浓缩成几个关键结论。
第一,泛型是一种语言抽象,底层实现是另一回事
三种语言都能表达类型参数化的函数和数据结构,但它们并不使用完全相同的代码生成机制。
第二,Go 泛型不是简单的字典调用
Go 的核心实现是 GC Shape Stenciling 与 Dictionary 相结合。
有些实例可以共享代码,某些类型相关操作则需要额外信息。
不能把每一次泛型调用都理解为运行时反射或字典函数调用。
第三,Rust 泛型主要依赖单态化
编译器为使用到的具体类型实例化生成相应实现。
这样通常能够产生高效的静态调用路径,但也可能增加编译时间和二进制体积。
第四,C++ 模板不仅是泛型
C++ 模板的实例化机制与 Rust 单态化存在相似之处,但 C++ 还具有非类型模板参数、偏特化、Concepts 和复杂的模板元编程能力。
第五,不能简单地按语言给泛型性能排名
对简单的整数运算,三者可能生成非常接近的机器码。
真正的区别通常体现在代码生成规模、运行时类型信息、分派机制、优化机会和编译成本上。