GO [ 泛型 ]

泛型

前面我们已经学习了 Go 的变量、常量、数据类型、输入输出、条件控制、切片、字符串、映射表、指针、结构体、函数、方法、接口、类型、错误、文件和反射。接下来学习 Go 语言中一个很重要、也最容易被"写复杂"的能力:泛型(Generics)。

泛型解决的问题并不是"让 Go 变成 C++ 模板",而是让我们把算法和数据结构中真正与具体类型无关的部分复用起来。

例如,下面两个函数的业务逻辑完全一样,只是参数类型不同:

复制代码
func SumInts(values []int) int {
	var total int
	for _, value := range values {
		total += value
	}
	return total
}

func SumFloats(values []float64) float64 {
	var total float64
	for _, value := range values {
		total += value
	}
	return total
}

如果继续支持 int32、int64、float32,就需要继续复制函数。复制几次并不可怕,可怕的是后面修改算法时必须同步修改多份代码。

泛型允许我们把"元素类型暂时留空",把这个类型作为参数传给函数或数据结构:

复制代码
func Sum[T int | float64](values []T) T {
	var total T
	for _, value := range values {
		total += value
	}
	return total
}

T 不是一个运行时的值,而是一个类型参数 。调用 Sum([]int{1, 2, 3}) 时,编译器会把 T 推断为 int;调用 Sum([]float64{1.5, 2.5}) 时,T 就是 float64。

按照 Go 官方博客《An Introduction To Generics》 的概括,泛型给 Go 增加了三项核心能力:

  1. 函数和类型可以声明类型参数;
  2. 接口可以表示类型集合,而不仅仅是方法集合;
  3. 编译器可以在很多调用场景中自动推断类型参数。

本文按照"问题引入 → 官方定义 → 类型参数 → 类型约束 → 类型集合 → 类型推断 → 泛型类型 → 接口对比 → 实现数据结构 → 使用边界"的顺序展开,重点学习:

  • 泛型函数和泛型类型的声明方式;
  • 类型参数、类型实参和实例化;
  • any、comparable、联合约束和 ~;
  • 为什么约束决定了泛型代码可以执行哪些操作;
  • 显式传入类型参数与类型推断;
  • 使用泛型实现栈、队列、集合和单向链表;
  • 泛型与接口的分工;
  • 为什么"能用泛型"不等于"应该使用泛型"。

本文代码在 go1.27.0 darwin/arm64 环境中实际编译运行。不同 Go 版本对标准库 API、错误文本和编译器优化的输出可能略有不同。泛型基础语法从 Go 1.18 开始支持。

为什么需要泛型

没有泛型时的重复代码

先实现一个查找切片最大值的函数:

复制代码
package main

import "fmt"

func MaxInt(values []int) int {
	if len(values) == 0 {
		return 0
	}

	maximum := values[0]
	for _, value := range values[1:] {
		if value > maximum {
			maximum = value
		}
	}
	return maximum
}

func MaxString(values []string) string {
	if len(values) == 0 {
		return ""
	}

	maximum := values[0]
	for _, value := range values[1:] {
		if value > maximum {
			maximum = value
		}
	}
	return maximum
}

func main() {
	fmt.Println(MaxInt([]int{3, 8, 2}))
	fmt.Println(MaxString([]string{"go", "rust", "c"}))
}

运行结果:

复制代码
8
rust

这两段函数除了类型名和元素类型,其他代码没有任何区别。我们可以使用 any 把参数改成 []any,但那样会失去比较能力:

复制代码
func MaxAny(values []any) any {
	// if values[0] < values[1] { ... }
	// 编译错误:any 中的值不保证支持小于号比较
	return values[0]
}

any 只表示"可以接收任意类型",不表示"这些类型拥有相同的操作"。泛型的关键就在于:允许任意类型,但必须明确这些类型共同支持哪些操作。

接口能不能替代泛型

在 Go 1.18 之前,常见的通用写法是使用空接口:

复制代码
func PrintAll(values []interface{}) {
	for _, value := range values {
		fmt.Println(value)
	}
}

这种写法可以接收任何元素,但调用者取出元素后需要进行类型断言:

复制代码
func FirstInt(values []interface{}) (int, bool) {
	if len(values) == 0 {
		return 0, false
	}

	value, ok := values[0].(int)
	return value, ok
}

它有三个问题:

  • 元素类型信息在装入接口时被隐藏;
  • 错误可能推迟到运行时才发现;
  • 每次取值都要做类型断言或类型 switch。

泛型把类型检查放回编译期:

复制代码
func First[T any](values []T) (T, bool) {
	if len(values) == 0 {
		var zero T
		return zero, false
	}
	return values[0], true
}

First([]int{10, 20}) 返回 int,First([]string{"Go"}) 返回 string。调用者不需要断言,编译器也会检查类型是否正确。

这并不意味着泛型会取代接口。接口描述的是运行时行为 ,泛型描述的是编译期类型关系。后文会专门对比两者。

泛型的三个基本概念

泛型代码里经常出现三个词:类型参数、类型约束、类型实参。

类型参数

类型参数写在函数名或类型名后面的方括号中:

复制代码
func Identity[T any](value T) T {
	return value
}

这里的 T 就是类型参数。它在函数声明时还没有具体类型,调用函数时才会被替换成某个实际类型。

类型参数和普通参数的区别如下:

对比项 普通参数 类型参数
写法 func f(value int) func f[T any](value T)
表示 一个运行时值 一个编译期类型占位符
传入位置 小括号 方括号
作用 决定本次调用的输入数据 决定本次实例化的具体类型

类型约束

any 是 interface{} 的别名,表示允许所有类型:

复制代码
func Identity[T any](value T) T {
	return value
}

但 any 没有告诉编译器 T 支持哪些运算。下面的代码不能编译:

复制代码
func Add[T any](left, right T) T {
	return left + right
}

编译器会拒绝 left + right,因为 any 的类型集合中包含结构体、切片、函数等不能使用 + 的类型。

要执行加法,约束必须缩小到支持加法的类型集合:

复制代码
type Number interface {
	int | int64 | float32 | float64
}

func Add[T Number](left, right T) T {
	return left + right
}

约束既决定"调用者可以传什么类型",也决定"函数体里可以对类型参数做什么操作"。

类型实参

调用泛型函数时,方括号中的具体类型叫类型实参:

复制代码
result := Add[int64](10, 20)

这里:

  • T 是类型参数;
  • int64 是类型实参;
  • Add[int64] 表示把 T 实例化为 int64 的泛型函数。

如果编译器能够从普通参数推断出类型实参,可以省略方括号:

复制代码
result := Add(10, 20)

这两种调用的含义相同。

泛型函数

一个最小的泛型函数

先实现一个返回切片长度的泛型函数:

复制代码
package main

import "fmt"

func Length[T any](values []T) int {
	return len(values)
}

func main() {
	fmt.Println(Length([]int{10, 20, 30}))
	fmt.Println(Length([]string{"Go", "泛型"}))
}

运行结果:

复制代码
3
2

这里 T 只出现在 []T 中,函数不需要对元素做任何操作,所以约束使用 any 就足够了。

显式指定类型实参

如果希望让代码更直观,可以显式写出类型:

复制代码
numbers := []int{1, 2, 3}
length := Length[int](numbers)
fmt.Println(length)

显式类型实参常见于以下场景:

  • 函数参数中没有直接出现类型参数;
  • 编译器无法从参数推断类型;
  • 代码需要强调某个类型转换或实例化结果;
  • 教学代码需要展示类型参数和类型实参的对应关系。

类型推断

官方入门教程使用下面的求和函数说明了类型推断:

复制代码
func SumIntsOrFloats[K comparable, V int64 | float64](values map[K]V) V {
	var total V
	for _, value := range values {
		total += value
	}
	return total
}

K 必须是 comparable,因为 map 的 key 必须能够使用 == 和 !=;V 只能是 int64 或 float64,因为函数体使用了加法。

调用时可以显式指定类型:

复制代码
ints := map[string]int64{"a": 10, "b": 20}
floats := map[string]float64{"a": 1.5, "b": 2.5}

fmt.Println(SumIntsOrFloats[string, int64](ints))
fmt.Println(SumIntsOrFloats[string, float64](floats))

也可以让编译器从 map[K]V 参数中推断:

复制代码
fmt.Println(SumIntsOrFloats(ints))
fmt.Println(SumIntsOrFloats(floats))

运行结果:

复制代码
30
4
30
4

官方教程在 Getting started with generics 中明确指出:编译器主要根据普通函数参数和形参之间的类型关系推断类型实参;如果函数没有参数可以提供类型信息,就通常需要显式写出类型参数。

没有参数时不能推断

下面这个函数没有普通参数,编译器没有任何输入可以用来推断 T:

复制代码
func Zero[T any]() T {
	var zero T
	return zero
}

func main() {
	value := Zero[int]()
	fmt.Println(value)
}

不能写成:

复制代码
// value := Zero() // 编译错误:无法推断 T

因为"返回值接收变量的类型"不会反向参与普通的类型参数推断。需要显式写成 Zero[int]()。

类型约束

约束是接口

Go 语言规范把类型约束定义为一种接口,它规定类型参数允许使用的类型集合,并控制类型参数值支持的操作。

最简单的约束是单个类型:

复制代码
func Double[T int](value T) T {
	return value * 2
}

这个函数只能接收 int,不能接收 int64:

复制代码
fmt.Println(Double(10))
// fmt.Println(Double(int64(10))) // 编译错误

如果希望接收多种类型,可以使用联合元素:

复制代码
type Integer interface {
	int | int8 | int16 | int32 | int64 |
		uint | uint8 | uint16 | uint32 | uint64 | uintptr
}

然后:

复制代码
func Double[T Integer](value T) T {
	return value * 2
}

此时 T 可以是这些整数类型中的任意一种,但一次具体调用中仍然只有一个确定类型。T 不是"同时拥有所有类型",而是"在编译时被替换成其中一个类型"。

~ 表示底层类型

直接写 int 只允许类型实参是 int;写 ~int 则允许所有底层类型为 int 的定义类型:

复制代码
type UserID int

type Integer interface {
	~int | ~int8 | ~int16 | ~int32 | ~int64 |
		~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}

func Double[T Integer](value T) T {
	return value * 2
}

func main() {
	var id UserID = 7
	fmt.Println(Double(id))
}

运行结果:

复制代码
14

如果约束写成 int:

复制代码
type OnlyInt interface {
	int
}

那么 UserID 不满足约束,因为 UserID 是一个新的定义类型。~int 的意思可以理解为"底层类型是 int 的类型集合"。

这也是泛型约束和普通接口最容易混淆的地方:

  • 普通接口常用方法描述行为;
  • 泛型约束可以用类型元素描述一组底层类型。

comparable

comparable 是 Go 预声明的约束,表示类型可以使用 == 和 != 进行比较。

它常用于 map 的 key、去重集合和查找:

复制代码
func Contains[T comparable](values []T, target T) bool {
	for _, value := range values {
		if value == target {
			return true
		}
	}
	return false
}

可以传入整数、字符串、数组和字段都可比较的结构体:

复制代码
fmt.Println(Contains([]int{1, 2, 3}, 2))
fmt.Println(Contains([]string{"Go", "Rust"}, "Python"))
fmt.Println(Contains([][2]int{{1, 2}}, [2]int{1, 2}))

不能传入切片:

复制代码
// Contains([][]int{{1, 2}}, []int{1, 2})
// 编译错误:[]int 不满足 comparable

切片虽然可以和 nil 比较,但不能和另一个切片使用 == 比较,所以不满足 comparable。

方法约束

约束也可以要求类型实现某个方法:

复制代码
type Stringer interface {
	String() string
}

func JoinWithComma[T Stringer](values []T) string {
	result := ""
	for i, value := range values {
		if i > 0 {
			result += ","
		}
		result += value.String()
	}
	return result
}

定义一个实现 String() 的类型:

复制代码
type User struct {
	Name string
}

func (user User) String() string {
	return user.Name
}

调用:

复制代码
users := []User{{Name: "Alice"}, {Name: "Bob"}}
fmt.Println(JoinWithComma(users))

运行结果:

复制代码
Alice,Bob

这里的 Stringer 既可以被普通接口使用,也可以作为泛型约束使用。区别在于:作为约束时,它同时限制类型参数的可用类型和函数体可以调用的方法。

组合方法和类型集合

约束可以同时包含方法和类型元素,但带有类型元素的接口只能作为类型约束,不能声明普通变量:

复制代码
type SignedNumber interface {
	~int | ~int64 | ~float32 | ~float64
}

下面这样是合法的:

复制代码
func Abs[T SignedNumber](value T) T {
	if value < 0 {
		return -value
	}
	return value
}

下面这样不合法:

复制代码
// var value SignedNumber
// 编译错误:包含类型元素的接口只能作为约束使用

这套设计来自 Go 1.18 的泛型方案。类型集合是给编译器检查类型参数用的,不是一个可以在运行时保存任意成员的接口值。

泛型类型

泛型不仅可以用于函数,还可以用于定义结构体、切片类型、链表节点和容器。

泛型结构体

定义一个可以保存任意类型的盒子:

复制代码
type Box[T any] struct {
	value T
}

func (box Box[T]) Value() T {
	return box.value
}

使用时必须实例化泛型类型:

复制代码
intBox := Box[int]{value: 100}
stringBox := Box[string]{value: "Go"}

fmt.Println(intBox.Value())
fmt.Println(stringBox.Value())

运行结果:

复制代码
100
Go

Box[int] 和 Box[string] 是两个不同的具体类型。它们共享同一个泛型定义,但不能互相赋值。

泛型类型的方法

泛型类型的方法接收者需要声明对应的类型参数:

复制代码
type Pair[A, B any] struct {
	First  A
	Second B
}

func (pair Pair[A, B]) Swap() Pair[B, A] {
	return Pair[B, A]{
		First:  pair.Second,
		Second: pair.First,
	}
}

调用:

复制代码
pair := Pair[string, int]{First: "age", Second: 18}
swapped := pair.Swap()

fmt.Printf("%#v\n", swapped)

运行结果:

复制代码
main.Pair[int,string]{First:18, Second:"age"}

方法接收者中的 A、B 对应 Pair 定义里的类型参数。方法不能凭空使用没有声明的类型参数。

Go 1.27 的泛型方法

如果你使用的是 Go 1.27,具体类型的方法本身也可以声明新的类型参数。例如,把一个 List[E] 的元素转换成另一种类型:

复制代码
type List[E any] []E

func (list List[E]) Map[R any](transform func(E) R) List[R] {
	result := make(List[R], len(list))
	for i, value := range list {
		result[i] = transform(value)
	}
	return result
}

func main() {
	words := List[int]{1, 2, 3}.Map(func(value int) string {
		return fmt.Sprintf("item-%d", value)
	})
	fmt.Println(words)
}

运行结果:

复制代码
[item-1 item-2 item-3]

这里 E 是 List 自己的类型参数,R 是 Map 方法新增的类型参数,所以 List[int] 可以转换成 List[string]。Go 官方在 Generic Methods 中说明了这项能力:它让类型相关的转换操作可以留在类型的方法上,调用链也更自然。

但泛型方法仍然不能凭空让接口拥有泛型方法。下面的接口声明仍然是不允许的:

复制代码
// type Transformer interface {
//	Map[R any](func(int) R) []R
// }

接口方法不能声明自己的类型参数。需要跨类型复用算法时,可以使用泛型函数;需要把操作组织在具体类型上时,可以使用 Go 1.27 的泛型方法。

泛型类型不能被直接使用

下面的写法缺少类型实参:

复制代码
// var box Box // 编译错误:泛型类型必须实例化

正确写法是:

复制代码
var box Box[int]

泛型类型的实例化和泛型函数调用有一个重要区别:函数调用经常可以依靠参数推断,而声明变量时通常必须明确写出类型参数。

使用泛型实现数据结构

接下来不只停留在语法层面,我们实现一个小型的泛型容器包。这个练习包含四种数据结构:

  • Stack[T]:后进先出栈;
  • Queue[T]:先进先出队列;
  • Set[T comparable]:去重集合;
  • LinkedList[T]:泛型单向链表。

这些数据结构的共同特点是:算法不依赖具体元素类型,但容器必须保证类型安全。

泛型栈 Stack

栈只需要保存一个 []T。使用切片作为底层存储,Push 追加元素,Pop 删除最后一个元素。

复制代码
type Stack[T any] struct {
	items []T
}

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

func (stack *Stack[T]) Pop() (T, bool) {
	if len(stack.items) == 0 {
		var zero T
		return zero, false
	}

	last := len(stack.items) - 1
	value := stack.items[last]

	// 释放被弹出元素的引用,避免 T 是指针时长期占用对象。
	var zero T
	stack.items[last] = zero
	stack.items = stack.items[:last]
	return value, true
}

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

完整测试:

复制代码
package main

import "fmt"

type Stack[T any] struct {
	items []T
}

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

func (stack *Stack[T]) Pop() (T, bool) {
	if len(stack.items) == 0 {
		var zero T
		return zero, false
	}

	last := len(stack.items) - 1
	value := stack.items[last]
	var zero T
	stack.items[last] = zero
	stack.items = stack.items[:last]
	return value, true
}

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

func main() {
	var numbers Stack[int]
	numbers.Push(10)
	numbers.Push(20)

	value, ok := numbers.Pop()
	fmt.Println(value, ok, numbers.Len())

	var names Stack[string]
	names.Push("Go")
	name, ok := names.Pop()
	fmt.Println(name, ok)
}

运行结果:

复制代码
20 true 1
Go true

这里的 Stack[int] 和 Stack[string] 使用相同的栈算法,但每个实例的元素类型在编译期已经确定。向 Stack[int] 压入字符串会直接编译失败,而不是等到运行时才发现。

泛型队列 Queue

队列可以用切片实现。为了避免每次出队都复制剩余元素,我们维护一个 head 下标:

复制代码
type Queue[T any] struct {
	items []T
	head  int
}

func (queue *Queue[T]) Enqueue(value T) {
	queue.items = append(queue.items, value)
}

func (queue *Queue[T]) Dequeue() (T, bool) {
	if queue.head >= len(queue.items) {
		var zero T
		return zero, false
	}

	value := queue.items[queue.head]
	var zero T
	queue.items[queue.head] = zero
	queue.head++

	// 所有元素都出队后,恢复零值状态。
	if queue.head == len(queue.items) {
		queue.items = nil
		queue.head = 0
	}
	return value, true
}

func (queue *Queue[T]) Len() int {
	return len(queue.items) - queue.head
}

测试:

复制代码
func main() {
	var queue Queue[string]
	queue.Enqueue("first")
	queue.Enqueue("second")

	first, _ := queue.Dequeue()
	second, _ := queue.Dequeue()
	_, ok := queue.Dequeue()

	fmt.Println(first)
	fmt.Println(second)
	fmt.Println("empty:", !ok, "length:", queue.Len())
}

运行结果:

复制代码
first
second
empty: true length: 0

Queue[T] 的关键不是泛型语法,而是让数据结构的存储类型、入队参数和出队返回值始终保持同一个 T。如果不用泛型,通常只能为每种元素类型复制一个队列,或者把元素装进 interface{} 后不断断言。

泛型集合 Set

集合需要判断元素是否相等,因此类型参数必须满足 comparable:

复制代码
type Set[T comparable] struct {
	items map[T]struct{}
}

func NewSet[T comparable]() *Set[T] {
	return &Set[T]{items: make(map[T]struct{})}
}

func (set *Set[T]) Add(value T) {
	set.items[value] = struct{}{}
}

func (set *Set[T]) Remove(value T) {
	delete(set.items, value)
}

func (set *Set[T]) Contains(value T) bool {
	_, ok := set.items[value]
	return ok
}

func (set *Set[T]) Len() int {
	return len(set.items)
}

测试:

复制代码
func main() {
	set := NewSet[string]()
	set.Add("Go")
	set.Add("泛型")
	set.Add("Go")

	fmt.Println(set.Contains("Go"))
	fmt.Println(set.Contains("Rust"))
	fmt.Println(set.Len())

	set.Remove("Go")
	fmt.Println(set.Contains("Go"))
}

运行结果:

复制代码
true
false
2
false

NewSet 没有普通参数,因此无法根据参数推断 T,调用时需要显式写成 NewSet[string]()。这正好说明了泛型函数类型推断的边界。

如果尝试创建 Set[[]int]:

复制代码
// invalid := NewSet[[]int]()

编译器会报错,因为切片不能作为 map key,也不满足 comparable。

泛型单向链表

链表节点保存一个元素和下一个节点指针:

复制代码
type Node[T any] struct {
	Value T
	Next  *Node[T]
}

type LinkedList[T any] struct {
	head *Node[T]
	len  int
}

func (list *LinkedList[T]) PushFront(value T) {
	list.head = &Node[T]{
		Value: value,
		Next:  list.head,
	}
	list.len++
}

func (list *LinkedList[T]) PopFront() (T, bool) {
	if list.head == nil {
		var zero T
		return zero, false
	}

	value := list.head.Value
	list.head = list.head.Next
	list.len--
	return value, true
}

func (list *LinkedList[T]) Len() int {
	return list.len
}

遍历链表:

复制代码
func (list *LinkedList[T]) Values() []T {
	values := make([]T, 0, list.len)
	for node := list.head; node != nil; node = node.Next {
		values = append(values, node.Value)
	}
	return values
}

测试:

复制代码
func main() {
	var list LinkedList[int]
	list.PushFront(1)
	list.PushFront(2)
	list.PushFront(3)

	fmt.Println(list.Values())
	value, ok := list.PopFront()
	fmt.Println(value, ok, list.Values())
}

运行结果:

复制代码
[3 2 1]
3 true [2 1]

这里 Node[T] 的 Next 必须写成 *Node[T],不能写成不带类型参数的 *Node。泛型类型被使用时必须实例化,这条规则同样适用于递归结构。

一个完整的泛型工具示例

前面的代码分别演示了语法。接下来把常见的泛型函数组合起来,实现一个小型工具文件:

复制代码
package main

import "fmt"

type Ordered interface {
	~int | ~int8 | ~int16 | ~int32 | ~int64 |
		~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
		~float32 | ~float64 | ~string
}

func Min[T Ordered](left, right T) T {
	if left < right {
		return left
	}
	return right
}

func Max[T Ordered](left, right T) T {
	if left > right {
		return left
	}
	return right
}

func Map[T any, R any](values []T, transform func(T) R) []R {
	result := make([]R, len(values))
	for i, value := range values {
		result[i] = transform(value)
	}
	return result
}

func Filter[T any](values []T, keep func(T) bool) []T {
	result := make([]T, 0, len(values))
	for _, value := range values {
		if keep(value) {
			result = append(result, value)
		}
	}
	return result
}

func Reduce[T any](values []T, initial T, combine func(T, T) T) T {
	result := initial
	for _, value := range values {
		result = combine(result, value)
	}
	return result
}

func main() {
	numbers := []int{1, 2, 3, 4, 5}

	squares := Map(numbers, func(value int) int {
		return value * value
	})
	even := Filter(numbers, func(value int) bool {
		return value%2 == 0
	})
	total := Reduce(numbers, 0, func(left, right int) int {
		return left + right
	})

	fmt.Println("min:", Min(10, 3))
	fmt.Println("max:", Max(10, 3))
	fmt.Println("squares:", squares)
	fmt.Println("even:", even)
	fmt.Println("total:", total)
}

运行结果:

复制代码
min: 3
max: 10
squares: [1 4 9 16 25]
even: [2 4]
total: 15

Map 使用两个类型参数:输入元素类型 T 和输出元素类型 R。这说明泛型并不要求输入和输出是同一种类型。Map 可以把 []int 转换成 []string:

复制代码
labels := Map([]int{1, 2, 3}, func(value int) string {
	return fmt.Sprintf("item-%d", value)
})
fmt.Println(labels)

运行结果:

复制代码
[item-1 item-2 item-3]

Filter 的输入输出类型相同,所以只需要一个类型参数。Reduce 使用一个初始值和二元合并函数,把一组值折叠成一个结果。

泛型与接口的区别

泛型和接口都能帮助我们写"通用代码",但抽象的方向不同。

泛型关注类型关系

下面的函数接收一个切片,并返回同类型的第一个元素:

复制代码
func First[T any](values []T) (T, bool) {
	if len(values) == 0 {
		var zero T
		return zero, false
	}
	return values[0], true
}

输入是 []User,输出就是 User;输入是 []int,输出就是 int。类型之间的关系在编译期保留了下来。

接口关注行为替换

下面的函数只关心参数能不能写入:

复制代码
func Save(writer interface{ Write([]byte) (int, error) }, data []byte) error {
	_, err := writer.Write(data)
	return err
}

调用者可以传入文件、网络连接、内存缓冲区或测试替身,只要它们实现 Write 方法即可。这里并不关心具体类型之间的关系,而是关心运行时对象提供的行为。

什么时候使用泛型

参考 Go 官方博客 When To Use Generics,可以先遵循下面几条经验:

  • 函数操作的是切片、map、channel 等语言内置容器,并且算法不依赖元素的具体类型;
  • 多个类型实现完全相同的算法,复制代码会造成维护负担;
  • 需要构建类型安全的容器,例如栈、队列、集合和树;
  • 输入和输出之间存在明确的类型关系,例如 Map[T, R]。

什么时候优先使用接口

下面这些场景通常优先考虑接口:

  • 调用者需要替换不同实现,例如数据库、文件系统和消息队列;
  • 类型之间的共同点是方法行为,而不是底层类型;
  • 需要把不同具体类型放进同一个运行时集合;
  • 代码本身就是依赖注入、插件系统或测试替身。

不要为了泛型而泛型

下面这个函数没有必要泛型化:

复制代码
func PrintUser(user User) {
	fmt.Println(user.Name)
}

如果写成:

复制代码
func PrintValue[T any](value T) {
	fmt.Println(value)
}

反而丢失了 User 的业务语义,也没有获得有价值的复用。

官方建议是先写清楚普通代码,等重复模式和类型关系真的出现后,再引入类型参数。泛型不是设计工作的起点,通常是从重复代码中提炼出的结果。

泛型中的零值

泛型代码无法直接写出一个适用于所有 T 的字面量零值:

复制代码
func Zero[T any]() T {
	var zero T
	return zero
}

var zero T 是泛型代码获取零值的标准写法。

也可以使用命名返回值:

复制代码
func Find[T any](values []T, predicate func(T) bool) (result T, ok bool) {
	for _, value := range values {
		if predicate(value) {
			return value, true
		}
	}
	return result, false
}

不能把 nil 返回给任意 T:

复制代码
func Invalid[T any]() T {
	// return nil // 编译错误:T 不一定是指针、切片、map 或接口
	var zero T
	return zero
}

如果函数的语义确实需要空值,通常返回 (T, bool) 或 (T, error),不要试图把所有类型都强行当成指针处理。

泛型代码中的常见错误

把 any 当成可比较类型

复制代码
func Equal[T any](left, right T) bool {
	// return left == right // 编译错误
	return false
}

正确写法:

复制代码
func Equal[T comparable](left, right T) bool {
	return left == right
}

约束没有覆盖函数体中的操作

复制代码
type Number interface {
	~int | ~float64
}

func Multiply[T Number](left, right T) T {
	return left * right
}

如果把约束换成 any,* 就不能使用。编写约束时应先看函数体真正使用了哪些操作,再决定类型集合。

把类型集合当成运行时接口

复制代码
type TextOrNumber interface {
	~string | ~int
}

// var value TextOrNumber // 编译错误:只能作为约束

类型集合用于编译期约束,不是用于运行时保存值。需要运行时保存不同类型时,应使用普通接口或 any,并明确处理类型断言。

误以为 ~int 等于 int

复制代码
type UserID int

type OnlyInt interface {
	int
}

type AnyInt interface {
	~int
}

UserID 满足 AnyInt,但不满足 OnlyInt。如果库希望支持调用者自定义的业务类型,通常应考虑使用 ~。

在不需要时声明复杂约束

下面这种约束看起来很专业,但如果函数只是遍历切片,就没有必要:

复制代码
type HugeConstraint interface {
	~int | ~int8 | ~int16 | ~int32 | ~int64 |
		~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
		~float32 | ~float64 | ~string | ~bool
}

正确的约束应该尽可能小。只读元素就使用 any,需要比较就使用 comparable,需要排序才定义有序类型集合。约束越复杂,调用者越难理解,编译错误也越难读。

忽略并发安全

泛型只解决类型复用,不会自动给容器增加并发安全。下面的 Stack[T] 仍然不能被多个 goroutine 同时写入:

复制代码
// stack.Push(1) 和 stack.Pop() 并发执行仍然可能产生数据竞争

如果需要并发容器,应当在结构体中加入 sync.Mutex 或使用专门的并发设计,并通过 go test -race 验证。

官方实现与设计理解

Go 官方在 Go 1.18 Release Notes 中把泛型描述为一次较大的语言变化,同时提醒开发者只在合适的场景使用泛型。这个提醒的重点不是"泛型不可靠",而是泛型会增加语言和 API 的复杂度,应该让复用收益覆盖复杂度成本。

从语言机制看,一次泛型调用大致经历下面的过程:

复制代码
泛型声明
    |
    |  类型参数 + 类型约束
    v
调用泛型函数或实例化泛型类型
    |
    |  显式类型实参,或由编译器推断
    v
检查类型实参是否满足约束
    |
    |  检查函数体中的运算是否合法
    v
生成/使用具体类型实例

这里有三个容易混淆的层次:

  • 类型约束描述一组可能的类型;
  • 一次具体调用中的类型参数代表其中一个确定类型;
  • 泛型代码可以使用的操作,是所有候选类型共同允许的操作。

例如:

复制代码
type Number interface {
	~int | ~float64
}

func Add[T Number](left, right T) T {
	return left + right
}

Number 的类型集合包含 int 和 float64 的定义类型,但 Add 的一次调用不允许让 left 是 int、right 是 float64。两个参数都必须是同一个推断出来的 T。

复制代码
fmt.Println(Add(1, 2))
fmt.Println(Add(1.5, 2.5))
// Add(1, 2.5) // 编译器无法让两个参数同时满足同一个 T

泛型的安全性来自编译期约束,而不是运行时检查。类型不满足约束时,程序不会进入运行阶段。

一个可运行的完整示例

下面把栈、队列、集合和链表放在同一个文件中,作为本章最终练习。保存为 generics_demo.go 后执行 go run generics_demo.go:

复制代码
package main

import "fmt"

type Stack[T any] struct {
	items []T
}

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

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

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

type Queue[T any] struct {
	items []T
	head  int
}

func (queue *Queue[T]) Enqueue(value T) {
	queue.items = append(queue.items, value)
}

func (queue *Queue[T]) Dequeue() (T, bool) {
	if queue.head >= len(queue.items) {
		var zero T
		return zero, false
	}
	value := queue.items[queue.head]
	var zero T
	queue.items[queue.head] = zero
	queue.head++
	if queue.head == len(queue.items) {
		queue.items = nil
		queue.head = 0
	}
	return value, true
}

func (queue *Queue[T]) Len() int {
	return len(queue.items) - queue.head
}

type Set[T comparable] struct {
	items map[T]struct{}
}

func NewSet[T comparable]() *Set[T] {
	return &Set[T]{items: make(map[T]struct{})}
}

func (set *Set[T]) Add(value T) {
	set.items[value] = struct{}{}
}

func (set *Set[T]) Contains(value T) bool {
	_, ok := set.items[value]
	return ok
}

func (set *Set[T]) Len() int {
	return len(set.items)
}

type Node[T any] struct {
	Value T
	Next  *Node[T]
}

type LinkedList[T any] struct {
	head *Node[T]
	len  int
}

func (list *LinkedList[T]) PushFront(value T) {
	list.head = &Node[T]{Value: value, Next: list.head}
	list.len++
}

func (list *LinkedList[T]) Values() []T {
	values := make([]T, 0, list.len)
	for node := list.head; node != nil; node = node.Next {
		values = append(values, node.Value)
	}
	return values
}

func main() {
	var stack Stack[int]
	stack.Push(1)
	stack.Push(2)
	value, ok := stack.Pop()
	fmt.Println("stack:", value, ok, stack.Len())

	var queue Queue[string]
	queue.Enqueue("A")
	queue.Enqueue("B")
	first, _ := queue.Dequeue()
	fmt.Println("queue:", first, queue.Len())

	set := NewSet[string]()
	set.Add("Go")
	set.Add("Go")
	set.Add("泛型")
	fmt.Println("set:", set.Contains("Go"), set.Len())

	var list LinkedList[int]
	list.PushFront(10)
	list.PushFront(20)
	fmt.Println("list:", list.Values())
}

运行结果:

复制代码
stack: 2 true 1
queue: A 1
set: true 2
list: [20 10]

这个示例里没有使用反射,也没有使用 any 保存所有元素。每个容器都在实例化时确定元素类型:

  • Stack[int] 只保存 int;
  • Queue[string] 只保存 string;
  • Set[string] 只保存可比较的 string;
  • LinkedList[int] 的每个节点都保存 int。

这就是泛型最实际的价值:复用数据结构实现,同时保留编译期类型安全。

泛型速查表

写法 含义
func F[T any](value T) 泛型函数,T 可以是任意类型
type Box[T any] struct{ value T } 泛型结构体
F[int](10) 显式指定类型实参
F(10) 尝试让编译器推断类型实参
T comparable T 必须支持 == 和 !=
T ~int T 的底层类型必须是 int
`T int string`
var zero T 获取泛型类型 T 的零值
map[K]V K 的约束通常必须是 comparable
Stack[T] 实例化泛型类型

总结

泛型的核心不是方括号,而是把"算法"和"具体类型"分开:

  • 类型参数让函数和类型可以延迟确定具体元素类型;
  • 类型约束规定允许哪些类型,以及泛型代码可以使用哪些操作;
  • any 适合不需要操作元素的通用代码;
  • comparable 适合比较、去重和 map key;
  • 联合约束和 ~ 适合表达一组底层类型;
  • 类型推断可以简化调用,但没有普通参数提供线索时仍需显式指定类型;
  • 泛型类型适合实现类型安全的栈、队列、集合、链表等数据结构;
  • 接口负责运行时行为抽象,泛型负责编译期类型关系抽象;
  • 泛型不是所有重复代码的答案,约束过度会让 API 变复杂。

如果把本文内容压缩成一个实践判断,可以记住下面这句话:

当多个类型需要执行同一套算法,而且类型之间的关系可以在编译期表达时,优先考虑泛型;当调用方只需要替换不同实现时,优先考虑接口。

官方资料

相关推荐
念何架构之路11 小时前
go-grpc服务端调用
开发语言·后端·golang
茉莉玫瑰花茶13 小时前
GO [ 单元测试 ]
golang·单元测试·log4j
喵了几个咪14 小时前
Go 写业务,Rust 扛底盘:一套可落地的混合架构
微服务·架构·golang·rust·多租户·gowind·rushwind
ttwuai14 小时前
Golang Web后台管理框架推荐:Gin、GoFrame与数据面板项目怎么分
前端·golang·gin
魏码不凡14 小时前
Go 编译报错:undefined: webp.Encode 问题排查与解决
开发语言·后端·golang
tachibana216 小时前
与其他语言相比,使用 Go 有什么好处?
开发语言·后端·golang·go
hasty1 天前
限制写了却没生效:OpenTelemetry Go 的 Unicode 截断边界
开发语言·后端·golang
EatFan1 天前
Go语言全栈实战:基于 Gin + Vue + JWT + RBAC 从零搭建前后端分离权限管理系统
vue.js·golang·go·vue·gin·jwt·rbac
ZealSinger1 天前
Go slog生产落地LevelVar与共存
开发语言·后端·golang·go