本文是「从 C++ 到 Go」系列的第五篇,承接上一篇对包管理与测试的讨论,进入 Go 1.18 引入的泛型系统。对于 C++ 程序员来说,模板是最强大也最复杂的语言特性之一;Go 的泛型是一个有趣的对照——它花了十多年才加入语言,设计上刻意做了大量简化。理解这些取舍,比学会语法本身更有价值。
泛型函数与类型参数
没有泛型时,想写一个通用的 Max 函数,要么为每种类型写一遍,要么用 interface{} 丢掉类型安全:
1 | func MaxInt(a, b int) int { ... } |
有了泛型,一个函数搞定所有可比较的类型:
1 | func Max[T cmp.Ordered](a, b T) T { |
[T cmp.Ordered] 声明了一个类型参数 T,约束为 cmp.Ordered——支持 <、>、<=、>= 的类型集合。调用时编译器自动推断类型参数:
1 | Max(3, 5) // T = int |
对比 C++ 模板,语法上几乎一一对应:
1 | template<typename T> |
但关键区别在于:C++ 模板是鸭子类型——编译器在实例化时才检查 T 是否支持 >,如果不支持,错误信息可能极其冗长(尤其在嵌套模板中)。Go 的约束是提前声明的契约:如果 T 不满足 cmp.Ordered,编译器直接在调用点报错,信息清晰。这正是 C++20 Concepts 试图解决的问题:
1 | template<std::totally_ordered T> |
不同的是,Go 从一开始就强制使用约束,没有「不带约束的模板」这个选项。
多个类型参数
类型参数可以有多个,用逗号分隔。下面的 Zip 函数把两个不同类型的切片配对:
1 | func Zip[T any, U any](ts []T, us []U) []struct { |
1 | names := []string{"Go", "C++", "Rust"} |
这里 T 和 U 各自独立,可以是不同类型。值得一提的是,Go 允许连续多个类型参数共享同一个约束时省略写法——[T, U any] 等价于 [T any, U any]。这和函数参数的 func foo(a, b int) 是同一套规则。但要注意:共享约束不意味着类型相同,T 和 U 在实例化时可以是完全不同的类型。
约束
Go 的约束(constraint)就是接口。三种最常用的内置约束:
| 约束 | 含义 | C++ 对应 |
|---|---|---|
any |
任意类型(= interface{}) |
无约束模板 |
comparable |
支持 == 和 != 的类型 |
std::equality_comparable |
cmp.Ordered |
支持 < > <= >= 的类型(数值 + 字符串) |
std::totally_ordered |
comparable 约束
Contains 在切片中查找元素。因为需要用 == 比较,所以约束是 comparable 而非 any:
1 | func Contains[T comparable](slice []T, target T) bool { |
如果把约束改成 any,编译器会拒绝 == 操作——因为不是所有类型都支持相等比较(比如包含切片字段的结构体)。
自定义类型集约束
Go 扩展了接口语法,允许用 | 列出允许的具体类型:
1 | type Number interface { |
Number 接口不声明任何方法,而是用 | 枚举了一组类型——只有这些类型(及其底层类型匹配的自定义类型)才能满足约束。这在 C++ 里没有直接对应——C++ 模板可以接受任何支持 += 的类型,Go 必须显式列出。更安全,但也更死板。
方法约束
约束也可以要求方法——和普通接口完全一样:
1 | type Stringer interface { |
任何实现了 String() 方法的类型都能传入 JoinStrings:
1 | type City struct { |
类型集和方法要求还可以组合在同一个接口里——既限定类型范围,又要求实现某些方法。
泛型类型
泛型不仅可以用于函数,也可以用于结构体定义。下面是一个泛型栈:
1 | type Stack[T any] struct { |
使用时指定具体类型,和 C++ 的 Stack<int> 对应(方括号换成了尖括号):
1 | var intStack Stack[int] |
注意 Pop 中的 var zero T——当需要返回零值时,声明一个未初始化的变量即可(Go 的零值保证)。C++ 模板里对应的是 T{}。
同样的思路可以做出 Pair,类似 C++ 的 std::pair<T, U>:
1 | type Pair[T, U any] struct { |
Map、Filter、Reduce
这三个在 C++ 里是 <algorithm> 的 std::transform、std::copy_if、std::accumulate。Go 在 1.18 之前做不到类型安全的通用版本——只能用 interface{} 加类型断言,或者为每种类型手写。泛型解决了这个问题:
1 | func Map[T, U any](slice []T, fn func(T) U) []U { |
用起来:
1 | numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} |
这里传入的匿名函数就是 Go 的闭包——语法上类似 C++ 的 lambda,但没有显式的捕获列表。Go 闭包自动捕获外围作用域的变量,且捕获的是变量本身(引用语义),类似 C++ 的 [&]。
注意 Reduce 的签名:T 和 U 可以是不同类型。如果切片元素是 string 而初始值是 int(比如统计总长度),传入的 fn 需要自己处理类型转换——泛型保证的是类型安全,而非类型自动转换。
也可以组合使用——偶数的平方和:
1 | sumOfEvenSquares := Reduce( |
能用,但不如 C++ ranges(numbers | filter(...) | transform(...))或函数式语言的链式调用优雅。Go 社区的态度是:嵌套太深就拆成几行,可读性优先。
为什么需要泛型:Future 模式
泛型的价值不仅是少写重复代码。更重要的是在编译期保留类型信息。用一个类似 C++ std::future<T> / std::async 的例子来展示。
没有泛型时,只能用 interface{} 传递异步结果,调用方每次取值都要做类型断言:
1 | type FutureUntyped struct { |
使用时:
1 | f1 := NewFutureUntyped(func() interface{} { |
有泛型的版本,类型信息完整保留:
1 | type Future[T any] struct { |
使用时:
1 | f2 := NewFuture(func() *User { |
f3.Get() 返回 float64,如果你写 f3.Get().Name 会直接编译错误——类型安全在编译期就得到了保证,而非运行时 panic。这就是泛型最核心的价值:把运行时的类型断言错误,变成编译期的类型检查错误。
Go 泛型的刻意限制
和 C++ 模板相比,Go 泛型故意不支持很多东西:
| 能力 | C++ 模板 | Go 泛型 |
|---|---|---|
| 特化(specialization) | 支持 | 不支持 |
编译期计算(constexpr) |
支持 | 不支持 |
| 模板模板参数 | 支持 | 不支持 |
| 变参模板(variadic) | 支持 | 不支持 |
| 方法级类型参数 | 支持 | 不支持 |
SFINAE / if constexpr |
支持 | 不支持 |
这是有意为之——Go 团队花了十多年才加入泛型,就是为了避免引入 C++ 模板那样的复杂性。C++ 模板是图灵完备的编译期计算引擎,能力极强但也导致了编译时间膨胀和令人绝望的错误信息。Go 的泛型够用,但不追求万能。如果你发现自己在 Go 里想要模板特化或编译期计算,通常意味着需要换一种设计思路——比如用接口多态替代特化,用 go generate 替代编译期代码生成。
反过来看,Go 泛型也有 C++ 模板不容易做到的事:约束是一等公民,错误信息清晰;类型集约束可以精确控制允许的类型,不需要 SFINAE 的间接手段。
小结
Go 泛型的设计哲学可以概括为「够用就好」。C++ 模板从 1990 年代发展至今,经历了特化、SFINAE、constexpr、变参模板、Concepts 等多轮演进,已经成为一套图灵完备的编译期元编程系统——能力极其强大,但学习曲线和心智负担同样惊人。Go 选择了另一条路:只提供最核心的能力——类型参数化和约束——然后到此为止。
这种克制意味着你不能在 Go 里写出 std::tuple 或 boost::hana 那样的编译期魔法,但也意味着任何 Go 程序员都能在五分钟内读懂一个泛型函数的签名。Go 社区的共识是:泛型是用来消除重复代码的工具,而不是用来构建抽象层的框架。如果一段代码只有三种具体类型需要处理,写三个函数可能比引入泛型更清晰。泛型解决的是「必须为每种类型复制粘贴同一段逻辑」的真实痛点,而不是「让代码看起来更高级」的审美偏好。