0%

从 C++ 到 Go(五):泛型

本文是「从 C++ 到 Go」系列的第五篇,承接上一篇对包管理与测试的讨论,进入 Go 1.18 引入的泛型系统。对于 C++ 程序员来说,模板是最强大也最复杂的语言特性之一;Go 的泛型是一个有趣的对照——它花了十多年才加入语言,设计上刻意做了大量简化。理解这些取舍,比学会语法本身更有价值。

泛型函数与类型参数

没有泛型时,想写一个通用的 Max 函数,要么为每种类型写一遍,要么用 interface{} 丢掉类型安全:

1
2
3
func MaxInt(a, b int) int { ... }
func MaxFloat(a, b float64) float64 { ... }
func MaxString(a, b string) string { ... }

有了泛型,一个函数搞定所有可比较的类型:

1
2
3
4
5
6
func Max[T cmp.Ordered](a, b T) T {
if a > b {
return a
}
return b
}

[T cmp.Ordered] 声明了一个类型参数 T,约束为 cmp.Ordered——支持 <><=>= 的类型集合。调用时编译器自动推断类型参数:

1
2
3
4
Max(3, 5)             // T = int
Max(3.14, 2.71) // T = float64
Max("go", "cpp") // T = string
Max[int](10, 20) // 也可以显式指定(通常不需要)

对比 C++ 模板,语法上几乎一一对应:

1
2
template<typename T>
T Max(T a, T b) { return a > b ? a : b; }

但关键区别在于:C++ 模板是鸭子类型——编译器在实例化时才检查 T 是否支持 >,如果不支持,错误信息可能极其冗长(尤其在嵌套模板中)。Go 的约束是提前声明的契约:如果 T 不满足 cmp.Ordered,编译器直接在调用点报错,信息清晰。这正是 C++20 Concepts 试图解决的问题:

1
2
template<std::totally_ordered T>
T Max(T a, T b) { return a > b ? a : b; }

不同的是,Go 从一开始就强制使用约束,没有「不带约束的模板」这个选项。

多个类型参数

类型参数可以有多个,用逗号分隔。下面的 Zip 函数把两个不同类型的切片配对:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func Zip[T any, U any](ts []T, us []U) []struct {
First T
Second U
} {
minLen := len(ts)
if len(us) < minLen {
minLen = len(us)
}
result := make([]struct {
First T
Second U
}, minLen)
for i := 0; i < minLen; i++ {
result[i].First = ts[i]
result[i].Second = us[i]
}
return result
}
1
2
3
names := []string{"Go", "C++", "Rust"}
years := []int{2009, 1985, 2010}
pairs := Zip(names, years) // T = string, U = int,自动推断

这里 TU 各自独立,可以是不同类型。值得一提的是,Go 允许连续多个类型参数共享同一个约束时省略写法——[T, U any] 等价于 [T any, U any]。这和函数参数的 func foo(a, b int) 是同一套规则。但要注意:共享约束不意味着类型相同,TU 在实例化时可以是完全不同的类型。

约束

Go 的约束(constraint)就是接口。三种最常用的内置约束:

约束 含义 C++ 对应
any 任意类型(= interface{} 无约束模板
comparable 支持 ==!= 的类型 std::equality_comparable
cmp.Ordered 支持 < > <= >= 的类型(数值 + 字符串) std::totally_ordered

comparable 约束

Contains 在切片中查找元素。因为需要用 == 比较,所以约束是 comparable 而非 any

1
2
3
4
5
6
7
8
9
10
11
func Contains[T comparable](slice []T, target T) bool {
for _, v := range slice {
if v == target {
return true
}
}
return false
}

Contains([]int{1, 2, 3}, 3) // true
Contains([]string{"Go", "C++"}, "Go") // true

如果把约束改成 any,编译器会拒绝 == 操作——因为不是所有类型都支持相等比较(比如包含切片字段的结构体)。

自定义类型集约束

Go 扩展了接口语法,允许用 | 列出允许的具体类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type Number interface {
int | int8 | int16 | int32 | int64 |
float32 | float64
}

func Sum[T Number](nums []T) T {
var total T // 零值初始化
for _, n := range nums {
total += n
}
return total
}

Sum([]int{1, 2, 3, 4, 5}) // 15
Sum([]float64{1.1, 2.2, 3.3}) // 6.6

Number 接口不声明任何方法,而是用 | 枚举了一组类型——只有这些类型(及其底层类型匹配的自定义类型)才能满足约束。这在 C++ 里没有直接对应——C++ 模板可以接受任何支持 += 的类型,Go 必须显式列出。更安全,但也更死板。

方法约束

约束也可以要求方法——和普通接口完全一样:

1
2
3
4
5
6
7
8
9
10
11
type Stringer interface {
String() string
}

func JoinStrings[T Stringer](items []T, sep string) string {
parts := make([]string, len(items))
for i, item := range items {
parts[i] = item.String()
}
return strings.Join(parts, sep)
}

任何实现了 String() 方法的类型都能传入 JoinStrings

1
2
3
4
5
6
7
8
9
10
11
type City struct {
Name string
Population int
}

func (c City) String() string {
return fmt.Sprintf("%s(%d万)", c.Name, c.Population/10000)
}

cities := []City{{"上海", 24870000}, {"北京", 21540000}, {"深圳", 17560000}}
JoinStrings(cities, " | ") // "上海(2487万) | 北京(2154万) | 深圳(1756万)"

类型集和方法要求还可以组合在同一个接口里——既限定类型范围,又要求实现某些方法。

泛型类型

泛型不仅可以用于函数,也可以用于结构体定义。下面是一个泛型栈:

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
27
28
29
type Stack[T any] struct {
items []T
}

func (s *Stack[T]) Push(val T) {
s.items = append(s.items, val)
}

func (s *Stack[T]) Pop() (T, bool) {
if len(s.items) == 0 {
var zero T
return zero, false
}
last := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return last, true
}

func (s *Stack[T]) Peek() (T, bool) {
if len(s.items) == 0 {
var zero T
return zero, false
}
return s.items[len(s.items)-1], true
}

func (s *Stack[T]) Len() int {
return len(s.items)
}

使用时指定具体类型,和 C++ 的 Stack<int> 对应(方括号换成了尖括号):

1
2
3
4
5
6
7
8
9
var intStack Stack[int]
intStack.Push(10)
intStack.Push(20)
val, ok := intStack.Pop() // val = 20, ok = true

var strStack Stack[string]
strStack.Push("hello")
strStack.Push("world")
top, _ := strStack.Peek() // top = "world"

注意 Pop 中的 var zero T——当需要返回零值时,声明一个未初始化的变量即可(Go 的零值保证)。C++ 模板里对应的是 T{}

同样的思路可以做出 Pair,类似 C++ 的 std::pair<T, U>

1
2
3
4
5
6
7
8
9
10
11
type Pair[T, U any] struct {
First T
Second U
}

func NewPair[T, U any](first T, second U) Pair[T, U] {
return Pair[T, U]{First: first, Second: second}
}

p := NewPair("Go", 2009) // Pair[string, int]
p2 := NewPair(3.14, true) // Pair[float64, bool]

Map、Filter、Reduce

这三个在 C++ 里是 <algorithm>std::transformstd::copy_ifstd::accumulate。Go 在 1.18 之前做不到类型安全的通用版本——只能用 interface{} 加类型断言,或者为每种类型手写。泛型解决了这个问题:

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
func Map[T, U any](slice []T, fn func(T) U) []U {
result := make([]U, len(slice))
for i, v := range slice {
result[i] = fn(v)
}
return result
}

func Filter[T any](slice []T, predicate func(T) bool) []T {
var result []T
for _, v := range slice {
if predicate(v) {
result = append(result, v)
}
}
return result
}

func Reduce[T, U any](slice []T, initial U, fn func(U, T) U) U {
acc := initial
for _, v := range slice {
acc = fn(acc, v)
}
return acc
}

用起来:

1
2
3
4
5
numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}

squares := Map(numbers, func(n int) int { return n * n })
evens := Filter(numbers, func(n int) bool { return n%2 == 0 })
sum := Reduce(numbers, 0, func(acc, n int) int { return acc + n })

这里传入的匿名函数就是 Go 的闭包——语法上类似 C++ 的 lambda,但没有显式的捕获列表。Go 闭包自动捕获外围作用域的变量,且捕获的是变量本身(引用语义),类似 C++ 的 [&]

注意 Reduce 的签名:TU 可以是不同类型。如果切片元素是 string 而初始值是 int(比如统计总长度),传入的 fn 需要自己处理类型转换——泛型保证的是类型安全,而非类型自动转换。

也可以组合使用——偶数的平方和:

1
2
3
4
5
6
7
8
sumOfEvenSquares := Reduce(
Map(
Filter(numbers, func(n int) bool { return n%2 == 0 }),
func(n int) int { return n * n },
),
0,
func(acc, n int) int { return acc + n },
)

能用,但不如 C++ ranges(numbers | filter(...) | transform(...))或函数式语言的链式调用优雅。Go 社区的态度是:嵌套太深就拆成几行,可读性优先。

为什么需要泛型:Future 模式

泛型的价值不仅是少写重复代码。更重要的是在编译期保留类型信息。用一个类似 C++ std::future<T> / std::async 的例子来展示。

没有泛型时,只能用 interface{} 传递异步结果,调用方每次取值都要做类型断言:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type FutureUntyped struct {
ch chan interface{}
}

func NewFutureUntyped(fn func() interface{}) *FutureUntyped {
f := &FutureUntyped{ch: make(chan interface{}, 1)}
go func() {
f.ch <- fn()
}()
return f
}

func (f *FutureUntyped) Get() interface{} {
return <-f.ch
}

使用时:

1
2
3
4
5
6
f1 := NewFutureUntyped(func() interface{} {
return fetchUser(1) // *User 被装箱为 interface{}
})

result := f1.Get() // 类型是 interface{},不知道里面是什么
user := result.(*User) // 必须类型断言,断言错了就 panic

有泛型的版本,类型信息完整保留:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type Future[T any] struct {
ch chan T
}

func NewFuture[T any](fn func() T) *Future[T] {
f := &Future[T]{ch: make(chan T, 1)}
go func() {
f.ch <- fn()
}()
return f
}

func (f *Future[T]) Get() T {
return <-f.ch
}

使用时:

1
2
3
4
5
6
7
8
9
f2 := NewFuture(func() *User {
return fetchUser(2)
})
user2 := f2.Get() // 直接是 *User,编译器保证

f3 := NewFuture(func() float64 {
return fetchScore("Liam")
})
score := f3.Get() // 直接是 float64,不需要断言

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::tupleboost::hana 那样的编译期魔法,但也意味着任何 Go 程序员都能在五分钟内读懂一个泛型函数的签名。Go 社区的共识是:泛型是用来消除重复代码的工具,而不是用来构建抽象层的框架。如果一段代码只有三种具体类型需要处理,写三个函数可能比引入泛型更清晰。泛型解决的是「必须为每种类型复制粘贴同一段逻辑」的真实痛点,而不是「让代码看起来更高级」的审美偏好。

俗话说,投资效率是最好的投资。 如果您感觉我的文章质量不错,读后收获很大,预计能为您提高 10% 的工作效率,不妨小额捐助我一下,让我有动力继续写出更多好文章。