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 parameter T。T 必须满足 constraint Number。
  • 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。

从你项目中真正重复的代码开始。以最小规模编写泛型,优先使用标准库,并始终为至少两种不同的类型编写测试。

golanggenericstype parameterconstraint