Go 中的泛型:通过示例学习类型参数
1. 概述
从 Go 1.18 开始,你可以使用 type parameter 编写函数和数据类型。这样就不必为 int、float64、string 等重复编写相同的逻辑,也不需要从 interface{} 进行类型断言。
本文从最初的问题出发,介绍泛型函数、constraint 和 ~ 符号。然后讲解泛型类型如 Stack[T] 和 Cache[K, V]、如何编写测试,以及常见的错误。读完本文后,你将了解何时应该使用泛型,何时使用普通的 interface 就足够了。
所有示例基于 Go 1.22 或更高版本。cmp 包从 Go 1.21 开始提供。最小化的 go.mod 文件:
module example.com/shop
go 1.22
无需外部库,本文中的所有内容都在标准库中。
2. 泛型出现前的问题
假设你需要计算商品数量的总和(int)和总金额(float64)。在 Go 1.18 之前,你通常需要编写两个完全相同的函数:
func SumInts(xs []int) int {
var total int
for _, x := range xs {
total += x
}
return total
}
func SumFloats(xs []float64) float64 {
var total float64
for _, x := range xs {
total += x
}
return total
}
另一种方法是接受 []interface{} 并使用 type switch。但这样做会失去类型安全,错误只会在运行时暴露,而且你需要在调用处进行类型断言。
泛型解决了这个问题:一次编写逻辑,编译器仍然为你检查类型。
3. 第一个泛型函数
3.1. type parameter 语法
type parameter 在方括号中声明,紧跟在函数名后面:
package main
import "fmt"
type Number interface {
~int | ~int64 | ~float64
}
func Sum[T Number](xs []T) T {
var total T
for _, x := range xs {
total += x
}
return total
}
func main() {
quantities := []int{2, 1, 5}
prices := []float64{120.5, 99.9, 15}
fmt.Println(Sum(quantities))
fmt.Println(Sum(prices))
}
逐部分解释:
[T Number]声明 type parameterT。T必须满足 constraintNumber。Number是一个包含 type set 的 interface:int、int64、float64以及从它们派生的类型(~的含义见第 4.3 节)。var total T创建T的零值,即0或0.0。+=操作符有效,因为 type set 中的所有类型都支持加法。
输出结果:
8
235.4
3.2. 类型推导
注意到我们调用 Sum(quantities) 而不是 Sum[int](quantities) 吗?编译器会从参数中自动推导 T。这叫做 type inference。
只有在编译器没有足够信息来推导时,才需要显式指定类型,例如当 type parameter 仅出现在返回类型中时:
func Zero[T any]() T {
var z T
return z
}
func main() {
fmt.Println(Zero[string]() == "") // true
}
3.3. 多个 type parameter:Map 和 Filter
以下是两个最常见的实用函数。我们将它们应用于 Order 列表:
package main
import "fmt"
type Order struct {
ID string
Customer string
Total float64
}
func Map[T, U any](xs []T, f func(T) U) []U {
out := make([]U, 0, len(xs))
for _, x := range xs {
out = append(out, f(x))
}
return out
}
func Filter[T any](xs []T, keep func(T) bool) []T {
var out []T
for _, x := range xs {
if keep(x) {
out = append(out, x)
}
}
return out
}
func main() {
orders := []Order{
{"A01", "Lan", 250_000},
{"A02", "Minh", 1_200_000},
{"A03", "Lan", 890_000},
}
big := Filter(orders, func(o Order) bool { return o.Total >= 500_000 })
ids := Map(big, func(o Order) string { return o.ID })
fmt.Println(ids)
}
Map[T, U any]有两个 type parameter,所以输入和输出类型可以不同。any是interface{}的别名。它允许任何类型,但除了赋值和传递外,你无法对该值调用任何操作符。- 编译器从函数
func(o Order) string推导出T = Order和U = string。
输出结果:
[A02 A03]
4. Constraint:限制 T 能做什么
Constraint 决定了你在函数内对 T 类型的值可以进行什么操作。
4.1. comparable
comparable 是一个内置 constraint。它允许使用 == 和 !=,也是 T 作为 map key 的必要条件:
func Unique[T comparable](xs []T) []T {
seen := make(map[T]struct{}, len(xs))
var out []T
for _, x := range xs {
if _, ok := seen[x]; !ok {
seen[x] = struct{}{}
out = append(out, x)
}
}
return out
}
// Unique([]string{"Lan", "Minh", "Lan"}) => [Lan Minh]
前面的 Order 结构体也是 comparable,因为它的所有字段都可以比较。包含 slice、map 或 func 的结构体则不满足 comparable,编译器会报错。
4.2. cmp.Ordered
当需要进行大小比较时,使用标准库中的 cmp.Ordered:
import "cmp"
func MaxBy[T any, K cmp.Ordered](xs []T, key func(T) K) (T, bool) {
var best T
if len(xs) == 0 {
return best, false
}
best = xs[0]
for _, x := range xs[1:] {
if key(x) > key(best) {
best = x
}
}
return best, true
}
// top, _ := MaxBy(orders, func(o Order) float64 { return o.Total })
// top.ID => "A02"
K cmp.Ordered允许在键值上使用>。- 函数返回
(T, bool)来处理空切片,避免 panic。
提示: 对于简单的数字切片,你不需要自己编写。Go 1.21 提供了内置的
max/min函数,slices包也提供了slices.Max、slices.Contains、slices.Sort。在编写泛型工具函数前,请先查看slices和maps包。
4.3. ~(tilde)符号
在实际项目中,你通常会为货币定义自己的类型:
type VND int64
func main() {
payments := []VND{150_000, 350_000}
fmt.Println(Sum(payments)) // 500000
}
这行代码能编译,是因为 Number 中有 ~int64。符号 ~int64 表示“任何 underlying type 为 int64 的类型”。如果你写的是没有 ~ 的 int64,那么 VND 就不满足 constraint,编译器会报错:
VND does not satisfy Number (possibly missing ~ for int64 in Number)
简单规则:编写基本类型的 constraint 时,几乎总是应该使用 ~。
4.4. 结合方法的 Constraint
Constraint 也可以要求方法,就像普通 interface 一样:
type Identifiable interface {
comparable
Key() string
}
任何要满足 Identifiable 的类型必须既能进行比较,又必须有 Key() string 方法。
5. 泛型类型
5.1. Stack[T]
Type parameter 不仅用于函数,也可以用于结构体:
type Stack[T any] struct {
items []T
}
func (s *Stack[T]) Push(v T) {
s.items = append(s.items, v)
}
func (s *Stack[T]) Pop() (T, bool) {
var zero T
if len(s.items) == 0 {
return zero, false
}
last := len(s.items) - 1
v := s.items[last]
s.items = s.items[:last]
return v, true
}
func (s *Stack[T]) Len() int { return len(s.items) }
使用方式:
var history Stack[Order]
history.Push(Order{ID: "A01"})
history.Push(Order{ID: "A02"})
o, _ := history.Pop()
fmt.Println(o.ID, history.Len()) // A02 1
- receiver 写作
*Stack[T],必须包含[T]。 - 声明变量时,你必须明确指定类型:
Stack[Order]。这里没有类型推导。 Stack[Order]的零值可以直接使用,因为append在 nil 切片上工作良好。
5.2. 线程安全的 Cache[K, V]
一个更贴近实际的例子:一个线程安全的内存缓存,可以用于 Customer 和 Order。
package cache
import "sync"
type Cache[K comparable, V any] struct {
mu sync.RWMutex
data map[K]V
}
func New[K comparable, V any]() *Cache[K, V] {
return &Cache[K, V]{data: make(map[K]V)}
}
func (c *Cache[K, V]) Put(key K, value V) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}
func (c *Cache[K, V]) Get(key K) (V, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.data[key]
return v, ok
}
type Customer struct {
ID int
Name string
}
customers := cache.New[int, Customer]()
customers.Put(1, Customer{ID: 1, Name: "Lan"})
orders := cache.New[string, Order]()
orders.Put("A01", Order{ID: "A01", Total: 250_000})
c, ok := customers.Get(1)
fmt.Println(c.Name, ok) // Lan true
一个 implementation 服务两种数据类型。customers.Get 返回正确的 Customer 类型,无需类型断言。如果你不小心将 Order 放入 customers,代码将无法编译。
6. 为泛型代码编写测试
Go 使用标准的 testing 包。表驱动测试仍然适用于泛型函数。每个测试表对应一种具体的类型:
package main
import "testing"
func TestSum(t *testing.T) {
ints := []struct {
name string
in []int
want int
}{
{"empty", nil, 0},
{"positive", []int{2, 1, 5}, 8},
{"mixed", []int{10, -3}, 7},
}
for _, tc := range ints {
t.Run(tc.name, func(t *testing.T) {
if got := Sum(tc.in); got != tc.want {
t.Errorf("Sum(%v) = %d, want %d", tc.in, got, tc.want)
}
})
}
if got := Sum([]VND{150_000, 350_000}); got != 500_000 {
t.Errorf("Sum VND = %d, want 500000", got)
}
}
func TestStackPopEmpty(t *testing.T) {
var s Stack[string]
v, ok := s.Pop()
if ok || v != "" {
t.Fatalf("Pop() on empty = (%q, %v), want (\"\", false)", v, ok)
}
}
运行测试:
go test ./... -v
=== RUN TestSum
=== RUN TestSum/empty
=== RUN TestSum/positive
=== RUN TestSum/mixed
--- PASS: TestSum (0.00s)
=== RUN TestStackPopEmpty
--- PASS: TestStackPopEmpty (0.00s)
PASS
至少要测试一个派生类型,例如 VND。这是快速发现 constraint 中缺少 ~ 的方法。
7. 常见错误和何时不该使用泛型
方法不能有自己的 type parameter。 下面的代码无法编译:
// 错误:methods cannot have type parameters
func (s *Stack[T]) Map[U any](f func(T) U) *Stack[U] { ... }
改为写成顶级函数:func MapStack[T, U any](s *Stack[T], f func(T) U) *Stack[U]。
不能直接对 T 进行 type switch。 如果要根据类型进行分支判断,你必须先转换为 any:switch v := any(x).(type) { ... }。如果你发现自己经常这样做,泛型可能不是合适的工具。
包含 type set 的 constraint 不能用作变量类型。 var n Number 会报错。包含 | 或 ~ 的 interface 只能在 constraint 位置使用。
comparable 与 interface 类型可能 panic。 如果 K 是 interface 类型,其动态值是 slice,那么进行比较或用作 map key 会在运行时 panic。
提示: 泛型不会自动比 interface 更快。Go 编译器使用“GC shape stenciling”和 dictionary。不同的指针类型可能共享一份代码,通过 constraint 调用方法仍然有间接开销。如果性能很关键,用 benchmark 衡量而不是猜测。
何时不应该使用泛型:
- 当逻辑取决于行为(方法),而不是类型时。例如
PaymentService接受一个有Send方法的Notifier。Interface 通常更简洁易读。 - 当只有一种具体类型会使用该函数时。不要为了“以后可能需要”而进行泛型化。
- 当
slices、maps、cmp包已经提供了你需要的函数时。
一个容易记住的规则:如果你在为多个类型编写相同的代码,只是类型名称不同,那就是使用泛型的时候。
8. 总结
本文介绍了 Go 泛型的主要内容:
- 声明 type parameter
[T C]并让编译器自动推导类型。 - 使用
any、comparable、cmp.Ordered等 constraint,以及用~定义自己的 constraint 来支持VND这样的派生类型。 - 构建线程安全的泛型类型
Stack[T]和Cache[K, V]。 - 为泛型函数编写表驱动测试。
- 认识限制:方法不能有自己的 type parameter、constraint 不能作为变量类型,以及泛型不能替代 interface。
从你项目中真正重复的代码开始。以最小规模编写泛型,优先使用标准库,并始终为至少两种不同的类型编写测试。