Skip to content

Go 泛型:类型参数、约束与可维护的抽象

面向有 Java 经验的开发者,基于 Go 1.26。

Go 泛型要解决的问题很具体:一套逻辑需要服务于一族类型,同时还要保留静态类型检查。它并不是把 Java 风格的泛型框架原样搬进 Go。真正好用的 Go 泛型 API,往往只有少量类型参数和短而明确的约束,并且不会排斥接口、组合与具体类型。

学习泛型时,类型参数、约束和类型集很容易混在一起。类型参数是声明中的占位类型;约束决定哪些类型可以传入,以及函数体能做哪些操作;类型集则是约束接口背后的模型。先把这三层分开,后面的类型推断、泛型类型、方法限制、泛型别名、性能与工程取舍才容易串起来。

目录

1. 先判断是否真的需要泛型

最适合泛型的代码通常有两个特征:

  1. 算法与元素具体类型无关;
  2. 不使用泛型就要复制多份代码、丢失静态类型,或迫使调用方做断言。

例如:

go
func Index[T comparable](values []T, target T) int {
	for i, value := range values {
		if value == target {
			return i
		}
	}
	return -1
}

同一算法适用于 []string[]int[]UserID,并且比较操作由约束保证。

不需要泛型的例子:

go
func WriteJSON(w io.Writer, v any) error

这里只把值交给编码器,没有在类型 T 上执行静态操作,也没有需要保留输入输出类型关系。把它改成 WriteJSON[T any] 不增加安全性,反而让 API 更复杂。

碰到重复代码时,可以按这个顺序判断:

  1. 一个具体函数够不够?
  2. 通过行为抽象时,一个普通接口够不够?
  3. 标准库 slicesmapscmp 是否已经提供?
  4. 只有确实需要表达“类型之间的静态关系”时,再引入类型参数。

2. 类型参数与实例化

2.1 声明

类型参数列表写在函数名后:

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

T 是类型参数,any 是约束。调用时:

go
n, ok := First([]int{10, 20})
name, ok := First[string]([]string{"Ada", "Rob"})

第一种由编译器推断 T=int;第二种显式提供类型实参。

2.2 实例化分两步

概念上,实例化会:

  1. 用类型实参替换声明中的类型参数;
  2. 检查每个类型实参是否满足相应约束。
go
Index([]string{"a", "b"}, "b") // T = string,满足 comparable

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

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

var ints Stack[int]
// var invalid Stack // 缺少类型实参

实例化后的 Stack[int]Stack[string] 是不同类型。

2.3 多个类型参数

go
func Transform[S ~[]E, E, R any](src S, fn func(E) R) []R {
	dst := make([]R, len(src))
	for i, value := range src {
		dst[i] = fn(value)
	}
	return dst
}

参数名应短但有含义:

  • TE:单个类型、元素;
  • KV:键和值;
  • S:切片或集合;
  • R:结果。

一旦类型参数开始增多,约束之间又互相引用,可读性通常会迅速下降。看到这个信号,就该考虑拆分设计了。

3. 约束决定可用操作

约束既规定“什么类型能传入”,也规定“函数体里能对该值做什么”。

go
type Stringer interface {
	String() string
}

func JoinStrings[T Stringer](values []T) string {
	var b strings.Builder
	for i, value := range values {
		if i > 0 {
			b.WriteString(", ")
		}
		b.WriteString(value.String())
	}
	return b.String()
}

T Stringer 允许调用 String。若约束只是 any,编译器不知道 T 有这个方法。

约束接口可以组合方法和类型元素:

go
type Text interface {
	~string
	Len() int
}

这表示底层类型是 string 且方法集中有 Len() int 的类型。注意:这种包含类型元素的非基本接口只能用作约束,不能用作普通变量类型。

约束不要比算法所需更严格。只需要 == 就用 comparable,不要要求一组无关方法;过严约束会无意义地排除合法类型。

4. 类型集:理解约束的钥匙

普通接口可以理解为一组方法,也可以更一般地理解为一组类型:所有实现该接口的非接口类型组成它的类型集。

go
type Reader interface {
	Read([]byte) (int, error)
}

它的类型集包含所有拥有相应 Read 方法的类型。

泛型约束扩展了接口元素:

  • 方法元素:类型必须实现这些方法;
  • 具体类型项:类型必须就是该类型;
  • ~T:类型的底层类型必须是 T
  • A | B:类型属于联合任一分支;
  • 嵌入接口:取类型集交集。
go
type Signed interface {
	~int | ~int8 | ~int16 | ~int32 | ~int64
}

type ID interface {
	Signed
	fmt.Stringer
}

ID 是“底层类型属于有符号整数族”与“实现 String”的交集。

函数体允许的操作,必须对约束类型集里的每个类型都有效。联合里只有一部分类型支持的操作不能使用。

5. 联合、~ 与 underlying type

5.1 没有 ~ 时只接受精确类型

go
type ExactInt interface {
	int
}

type UserID int

// UserID 不满足 ExactInt,因为它不是预声明的 int。

~

go
type Integer interface {
	~int | ~int32 | ~int64
}

~int 表示所有底层类型为 int 的类型,因此 intUserID 都满足。

5.2 为什么库算法常写 S ~[]E

go
func Clone[S ~[]E, E any](src S) S {
	dst := make(S, len(src))
	copy(dst, src)
	return dst
}

若只写:

go
func Clone[E any](src []E) []E

传入命名切片:

go
type Path []Point

返回类型会退化为 []PointS ~[]E 同时推断完整切片类型和元素类型,返回 S,保留 Path

5.3 联合项限制

联合项必须有可判定且合理的类型集。非接口联合项的类型集不能重叠:

go
// 非法:~int 已经包含 int。
// interface{ int | ~int }

带方法的接口、comparable 等不能随意作为多项联合的成员。遇到复杂约束时应回到规范验证,不要凭语法猜测。

5.4 可用操作是交集

go
type Number interface {
	~int | ~int64 | ~float64
}

func Sum[T Number](values []T) T {
	var total T
	for _, value := range values {
		total += value
	}
	return total
}

这些类型都支持 +,所以合法。若联合加入 ~string+ 仍对全部类型合法;但除法等语义可能不再满足算法要求。语法允许不代表抽象合理。

6. any 与 comparable

6.1 any

anyinterface{} 的别名,约束类型集包含所有非接口类型:

go
func Identity[T any](v T) T { return v }

T any 不表示值本身是动态接口。函数实例化后 T 是确定类型,输入输出关系仍由编译器保留。

6.2 comparable

comparable 表示支持 ==!= 的类型,是 map 键约束的基础:

go
func Keys[K comparable, V any](m map[K]V) []K {
	keys := make([]K, 0, len(m))
	for key := range m {
		keys = append(keys, key)
	}
	return keys
}

可比较类型包括布尔、数字、字符串、指针、channel、接口,以及所有字段都可比较的数组和结构体。切片、map、函数不可比较(只能与 nil 做受限比较),不能作为 map 键。

一个细节是 any 可以满足 comparable 约束,但接口动态值若装入不可比较类型,实际做比较仍可能 panic:

go
var a any = []int{1}
var b any = []int{1}
// _ = a == b // 运行时 panic

因此 map[any]V 静态合法,不代表任意动态值都能安全当键。

7. 类型推断

7.1 从实参推断

go
func Pair[A, B any](a A, b B) struct {
	First  A
	Second B
} {
	return struct {
		First  A
		Second B
	}{a, b}
}

func examplePair() {
	p := Pair(1, "one") // A=int, B=string
	_ = p
}

通常应让调用点省略显而易见的类型实参。

7.2 部分显式类型实参

类型实参按声明顺序填写,可以只提供前缀,让其余参数推断:

go
result := Transform[MyInts](
	MyInts{1, 2},
	strconv.Itoa,
)

这也是为什么把最可能需要显式指定的类型参数放前面很重要。

7.3 推断不使用返回值完成所有猜测

下面不能只靠接收变量推断传统调用:

go
func Zero[T any]() T {
	var zero T
	return zero
}

func exampleZero() {
	n := Zero[int]()
	_ = n
}

函数没有普通实参提供 T。不过现代 Go 的类型推断支持把泛型函数赋给已知函数类型、作为参数传递或返回:

go
var parse func(string) (int, error) = strconv.Atoi
_ = parse

对自定义泛型函数同样可以从目标函数类型建立方程;但调用点如果让读者难以看出类型,显式写出更好。

7.4 无类型常量

无类型常量参与推断时最终采用对应默认类型:

go
func Max[T ~int | ~float64](a, b T) T {
	if a > b {
		return a
	}
	return b
}

func exampleMax() {
	_ = Max(1, 2)     // T=int
	_ = Max(1.0, 2.0) // T=float64
}

混合常量的种类冲突可能推断失败,此时显式转换或指定类型实参。

8. 保留命名切片和 map 类型

Go 标准库泛型算法常使用两级参数:

go
func DeleteFunc[S ~[]E, E any](s S, del func(E) bool) S

这让自定义类型保持不变:

go
type Users []User

func activeUsers(users Users) Users {
	active := Filter(users, func(u User) bool {
		return u.Active
	})
	return active // 仍是 Users
}

map 同理:

go
func CloneMap[M ~map[K]V, K comparable, V any](src M) M {
	dst := make(M, len(src))
	for k, v := range src {
		dst[k] = v
	}
	return dst
}

注意这只是浅复制;若 V 含切片、map 或指针,底层对象仍共享。

9. 泛型类型

9.1 Stack

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

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

func (s *Stack[T]) Pop() (T, bool) {
	if len(s.items) == 0 {
		var zero T
		return zero, false
	}
	last := len(s.items) - 1
	value := s.items[last]
	var zero T
	s.items[last] = zero // 尽早释放可能被引用的对象
	s.items = s.items[:last]
	return value, true
}

Stack[T] 的零值可直接使用,这是很有价值的 API 属性。

9.2 Set

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

func NewSet[T comparable](values ...T) Set[T] {
	set := make(Set[T], len(values))
	for _, value := range values {
		set[value] = struct{}{}
	}
	return set
}

func (s Set[T]) Contains(value T) bool {
	_, ok := s[value]
	return ok
}

Set[T] 的 nil 零值可读但不能写,因此若希望零值支持 Add,需要用结构体包装并惰性初始化,或明确要求构造函数。

9.3 泛型类型的递归

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

递归引用必须实例化为 Node[T],不能省略类型参数。

10. 泛型类型的方法与方法限制

10.1 接收者重新声明类型参数

go
type Pair[A, B any] struct {
	First  A
	Second B
}

func (p Pair[A, B]) Values() (A, B) {
	return p.First, p.Second
}

方法接收者中的 [A, B] 声明供方法体使用的接收者类型参数,约束由 Pair 的定义隐含。名字可以不同:

go
func (p Pair[Left, Right]) Values() (Left, Right)

但通常保持一致更易读。

10.2 方法不能声明自己的额外类型参数

Go 不允许:

go
// 非法
// func (s Stack[T]) Map[R any](fn func(T) R) Stack[R]

把它写成泛型函数:

go
func MapStack[T, R any](src Stack[T], fn func(T) R) Stack[R] {
	dst := Stack[R]{items: make([]R, len(src.items))}
	for i, value := range src.items {
		dst.items[i] = fn(value)
	}
	return dst
}

这是 Go 与 Java/C# 泛型 API 设计的重要差异:不要规划一条长长的泛型流式方法链。

10.3 不能给实例化类型增加方法

方法接收者基类型必须是当前包定义的类型,不能给 Pair[int, string] 单独声明方法,也不能给其他包的泛型类型加方法。

10.4 某些实例化才有意义怎么办

如果一个方法只对有序元素有意义,应在类型定义上约束:

go
type Sorted[T cmp.Ordered] []T

或者写独立函数:

go
func Min[T cmp.Ordered](values Stack[T]) (T, bool)

不能在单个方法上追加 T cmp.Ordered

11. 泛型接口与自引用约束

11.1 参数化行为

go
type Equaler[T any] interface {
	Equal(T) bool
}

func Contains[T Equaler[T]](values []T, target T) bool {
	for _, value := range values {
		if value.Equal(target) {
			return true
		}
	}
	return false
}

这里表达“类型能和同类型值比较”的静态关系。

11.2 Go 1.26 的自引用约束

Go 1.26 放宽了泛型类型在自身类型参数列表中引用自己的限制,因此可以表达 F-bounded 风格:

go
type Adder[A Adder[A]] interface {
	Add(A) A
}

func AddAll[A Adder[A]](values []A) A {
	var result A
	for _, value := range values {
		result = result.Add(value)
	}
	return result
}

这类约束表达能力很强,也很容易变得晦涩。只有领域模型确实要求“操作返回自身具体类型”时再用;能用普通接口或函数参数说明的,不必追求类型体操。

12. 泛型 alias 的最新规则

Go 1.24 起,别名声明可以有类型参数:

go
type Set[K comparable] = map[K]bool
type IntSet = Set[int]

别名不创建新类型,Set[string]map[string]bool 是同一类型。它主要用于大型代码库迁移、跨包移动泛型 API,而不是给类型增加语义边界。

12.1 必须显式声明参数

不能省略并“继承”右侧泛型类型的参数:

go
// 非法
// type Alias = SomeGeneric

type Alias[T any] = SomeGeneric[T]

别名自己的参数数量和映射方式不必与右侧相同:

go
type StringMap[V any] = map[string]V
type PairMap[K comparable, V any] = map[K]struct {
	First  V
	Second V
}

12.2 右侧不能就是本声明的类型参数

go
// 非法
// type Identity[T any] = T

但右侧可以使用它组成其他类型,如 []Tmap[string]T 或已实例化泛型类型。

12.3 别名不能作为增加方法的后门

方法接收者若由别名表示,该别名不能是泛型别名,也不能直接或间接表示某个实例化泛型类型:

go
type IntStack = Stack[int]

// 非法:不能给实例化泛型类型的别名声明方法。
// func (IntStack) Sum() int

需要新方法和新语义时定义新类型,而不是别名:

go
type IntStack Stack[int]

这会创建不同类型,但原类型的方法不会自动成为新类型的方法,需要自行设计转换和方法集。

13. 常用算法模式

标准库已经提供 slicesmapscmp,动手写工具函数之前先查一遍。下面这些模式主要用来理解泛型算法的设计方式。

13.1 Map

go
func Map[S ~[]E, E, R any](src S, fn func(E) R) []R {
	dst := make([]R, len(src))
	for i, value := range src {
		dst[i] = fn(value)
	}
	return dst
}

返回 []R 是因为输入命名切片类型未必有合理的“对应结果命名类型”。

13.2 Filter

go
func Filter[S ~[]E, E any](src S, keep func(E) bool) S {
	dst := make(S, 0, len(src))
	for _, value := range src {
		if keep(value) {
			dst = append(dst, value)
		}
	}
	return dst
}

这会分配新底层数组,避免结果与输入共享存储。若提供原地版本,应在命名中明确并清理尾部引用。

13.3 Reduce

go
func Reduce[S ~[]E, E, A any](
	src S,
	initial A,
	fn func(A, E) A,
) A {
	acc := initial
	for _, value := range src {
		acc = fn(acc, value)
	}
	return acc
}

Go 中直接 for range 往往比组合多层 Map/Filter/Reduce 更清楚,也更容易控制分配和错误处理。泛型工具不是强迫业务代码函数式化。

13.4 Unique

go
func Unique[S ~[]E, E comparable](src S) S {
	seen := make(map[E]struct{}, len(src))
	dst := make(S, 0, len(src))
	for _, value := range src {
		if _, ok := seen[value]; ok {
			continue
		}
		seen[value] = struct{}{}
		dst = append(dst, value)
	}
	return dst
}

复杂度期望为 O(n),空间 O(n),并保留第一次出现的顺序。

13.5 MinBy

无法通过字段约束表达“任何拥有某字段的结构体”,所以用投影函数:

go
func MinBy[S ~[]E, E any, K cmp.Ordered](
	values S,
	key func(E) K,
) (E, bool) {
	if len(values) == 0 {
		var zero E
		return zero, false
	}
	best := values[0]
	bestKey := key(best)
	for _, value := range values[1:] {
		if k := key(value); k < bestKey {
			best = value
			bestKey = k
		}
	}
	return best, true
}

13.6 泛型并发安全容器

go
type SafeMap[K comparable, V any] struct {
	mu sync.RWMutex
	m  map[K]V
}

func (m *SafeMap[K, V]) Load(key K) (V, bool) {
	m.mu.RLock()
	defer m.mu.RUnlock()
	value, ok := m.m[key]
	return value, ok
}

泛型只提供类型安全,不自动解决所有权。若 V 是 map、切片或指针,返回后仍可能暴露内部可变状态。

14. 零值、new 和类型断言

14.1 得到 T 的零值

最通用写法:

go
var zero T
return zero

也可以 *new(T),但通常不如声明清楚。Go 1.26 的内建 new 也允许以表达式形式更方便地取得指针,但泛型 API 仍应先考虑是否真的需要指针。

14.2 不能直接对类型参数做类型断言

类型断言和 type switch 的操作数必须是接口类型。若确实要按动态具体类型分支:

go
func Describe[T any](value T) string {
	switch v := any(value).(type) {
	case int:
		return strconv.Itoa(v)
	case string:
		return v
	default:
		return fmt.Sprintf("%v", v)
	}
}

但频繁这样做说明泛型抽象可能不对。既然算法依赖运行时类型,普通接口或显式策略函数可能更合适。

14.3 转换必须对整个类型集有效

不能因为“当前调用时 T 恰好是 int”就在函数体写只对 int 有效的转换。编译器检查声明对约束中的每个类型都成立。

15. 泛型与接口如何分工

接口和泛型并不是互相替代的关系,可以先按下面的边界来判断:

  • 接口抽象行为,让不同具体类型在运行时以统一方式被调用;
  • 泛型保留类型关系,让编译器在编译期检查输入、输出和容器元素的一致性;
  • 函数值注入单个策略,常常比定义接口更轻。

例子:

go
func CopyAll[T io.Reader](readers []T, dst io.Writer) error

这里是否需要泛型要看返回关系。若只是逐个调用 Read[]io.Reader 已经够用;泛型可能增加实例化而没有收益。

反之:

go
func Clone[S ~[]E, E any](src S) S

接口无法表达“返回与输入完全相同的命名切片类型”,泛型很合适。

不要定义只被一个类型实现、只为迁就泛型而存在的约束。约束应该来自算法真正需要的操作。

16. 性能、代码生成与字典化

16.1 语言规范不承诺实现策略

Go 规范定义类型检查和运行语义,不承诺编译器必须为每个实例化生成一份完整机器码,也不承诺像 Java 一样统一擦除。当前编译器可能:

  • 为一组具有相同“形状”的类型复用实现;
  • 通过隐藏字典传递类型相关操作;
  • 对部分实例化做专门化和内联;
  • 随版本改变策略。

所以,不能把某个版本的编译细节当成 API 保证。“泛型一定零成本”和“泛型一定走反射”这两种说法都不准确。

16.2 与 interface{} 的差异

泛型通常避免调用方手工装箱和断言,能保留静态类型,也给编译器更多优化机会。但具体分配、间接调用和二进制体积取决于代码形态。

比较时要测:

bash
go test -bench=. -benchmem
go test -gcflags=-m=2 ./...
go tool nm -size ./app

关注:

  • 热循环能否内联;
  • 接口转换和逃逸;
  • 实例化数量导致的二进制体积;
  • 泛型辅助函数是否制造额外切片;
  • 实际数据规模和缓存局部性。

16.3 算法设计通常比实例化策略重要

一次不必要的 O(n²) 搜索、每步都分配新切片,远比字典传递更昂贵。先选正确复杂度和数据结构,再关注生成代码。

16.4 约束方法可能有间接成本

调用约束中的方法有时需要通过字典或间接机制;是否内联要看编译器。数值运算、内建操作也可能有不同优化。只有 profile 指向这里才值得改成具体函数或代码生成。

17. API 设计与兼容性

17.1 类型参数是公共 API 的一部分

调整约束可能破坏调用方:

  • any 收紧为 comparable 会让已有类型无法实例化;
  • 改变返回类型是否保留命名类型,会影响赋值;
  • 改变类型参数顺序会破坏显式实例化;
  • 把普通类型改成泛型类型会改变所有使用点。

17.2 给约束起名的时机

仅用一次且很短:

go
func Sum[T ~int | ~int64](values []T) T

跨多个 API 复用或本身有领域语义时命名:

go
type MetricNumber interface {
	~int64 | ~float64
}

不要为了“整洁”导出大量只有实现意义的约束。导出约束同样形成兼容性承诺。

17.3 构造函数帮助推断

go
func NewSet[T comparable](values ...T) Set[T]

调用 NewSet("a", "b") 可推断;空集合必须 NewSet[string]()。若零值可用,则调用方甚至不一定需要构造函数。

17.4 文档写清所有权与复杂度

泛型签名只表达类型,不表达:

  • 是否修改输入;
  • 是否共享底层数组;
  • 是否浅复制;
  • 遍历是否稳定;
  • 比较器是否允许有副作用;
  • 时间和空间复杂度;
  • 并发是否安全。

这些仍要在文档中说明。

18. 何时不要使用泛型

以下情况通常不值得:

  1. 只有一个具体类型,没有真实复用需求;
  2. 逻辑只调用共同方法,普通接口更自然;
  3. T 只出现一次,没有表达任何类型关系;
  4. 函数内部立刻 any(v).(type) 大量分支;
  5. 约束需要十几个联合项才能勉强表达领域;
  6. 反射仍不可避免,泛型只包了一层;
  7. 标准库已经有等价算法;
  8. 为模拟 Java Stream 而制造大量短命中间集合;
  9. 导出抽象只是为了少写几十行稳定、清晰的具体代码。

少量重复不一定坏。过早抽象会把不同业务规则绑在一个难演进的类型参数系统里。

19. 与 Java 泛型的对照

GoJava关键差异
[T any]<T>Go 约束由接口类型集表达
~int | ~string无直接对应Go 可约束底层类型和运算符
comparable无直接对应静态保证可用于 ==/map 键,但接口动态值仍需注意
S ~[]E集合泛型可保留命名切片类型
泛型函数static generic methodGo 方法不能声明额外类型参数
实例化实现Java 类型擦除Go 规范不承诺擦除,编译器可形状复用/字典化/专门化
无通配符语法? extends / ? superGo 通过约束、接口和函数参数重新组织 API
泛型 aliasJava type alias 缺失Go 1.24 起支持参数化别名,但不创建新类型

Go 没有协变、逆变标注。[]Dog 不能赋给 []Animal,因为若允许,接收方就可能向其中写入 Cat。需要统一处理时,显式转换、使用迭代函数,或重新设计为行为接口。

20. 常见误区

误区 1:any 和 Java Object 完全相同

作为约束时,T any 仍保留静态具体类型关系;any 变量才是动态接口值。

误区 2:~int 表示“可以转换成 int”

它表示底层类型就是 int,不是所有可转换类型。

误区 3:约束接口都能声明变量

包含类型项、~、联合或 comparable 的非基本接口只能作为约束。

误区 4:comparable 保证接口动态比较永不 panic

接口本身可满足约束,但装入切片等不可比较动态值后,运行时比较仍会 panic。

误区 5:泛型方法可以有自己的 R

Go 方法不能声明额外类型参数,改成包级泛型函数。

误区 6:泛型 alias 是新类型

别名左右两侧类型相同;要建立新语义和方法集,使用类型定义。

误区 7:所有实例化一定复制机器码

规范不承诺,实际编译器可能复用形状和传递字典。

误区 8:使用泛型一定比接口快

性能取决于内联、逃逸、间接调用、数据布局和算法;必须测量。

误区 9:约束越精确越专业

过度约束降低复用性,也让错误信息和 API 难懂。只表达算法所需。

误区 10:泛型容器自动线程安全

泛型只处理静态类型,所有权与同步是另一套问题。

21. 速查表

目标形式
任意类型[T any]
可比较、可作 map 键[K comparable]
底层类型为 int[T ~int]
数值类型联合[T ~int | ~int64 | ~float64]
保留命名切片类型[S ~[]E, E any]
保留命名 map 类型[M ~map[K]V, K comparable, V any]
要求方法[T interface{ Method() }]
类型与自身行为相关[T Equaler[T]]
得到 T 的零值var zero T
需要运行时类型分支any(v).(type),并重新检查抽象是否合理
给泛型类型声明方法func (x Type[T]) M()
方法需要新结果类型 R写包级泛型函数
跨包迁移同一泛型类型泛型 alias(Go 1.24+)

设计检查清单:

  • 类型参数是否表达真实的输入输出关系?
  • 普通接口或函数值是否更简单?
  • 约束是否恰好够用?
  • 是否保留命名集合类型?
  • 零值是否可用?
  • 是否泄露共享底层存储?
  • 标准库是否已经提供?
  • 基准是否包含分配与二进制体积?

可运行示例

下面三个程序都是仓库中可以直接编译的完整源码。建议先运行,再回到代码里找出类型推断真正发生的位置。

示例一:组合 Map 与 Filter

第一个程序从整数切片中筛出偶数,再把结果转换成平方。这里正好能看出两种类型关系:Filter 保持元素类型,Map 则允许输入与输出使用不同类型。

go
package main

import "fmt"

// Filter 返回所有满足 keep 条件的元素。
//
// 这里预先把容量设为 len(src),是因为最极端的情况是全部元素都保留;
// 长度仍从 0 开始,只有真正命中的元素才会通过 append 写入结果。
// 函数不修改 src,因此调用方可以安全地继续使用原切片。
func Filter[T any](src []T, keep func(T) bool) []T {
	dst := make([]T, 0, len(src))
	for _, value := range src {
		if keep(value) {
			dst = append(dst, value)
		}
	}
	return dst
}

// Map 把 []T 中的每个元素转换成 R。
//
// 输入、输出元素类型可以不同,例如把 []User 转成 []string。
// 结果长度与输入相同,所以直接一次性分配最终长度,避免循环中扩容。
func Map[T, R any](src []T, transform func(T) R) []R {
	dst := make([]R, len(src))
	for i, value := range src {
		dst[i] = transform(value)
	}
	return dst
}

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

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

	fmt.Println("偶数:", evens)
	fmt.Println("平方:", squares)
	fmt.Println("原值:", numbers)
}

运行:

bash
go run ./examples/ch15/map-filter

预期输出:

text
偶数: [2 4 6]
平方: [4 16 36]
原值: [1 2 3 4 5 6]

拆解:

  • Filter[T any] 不需要比较或运算元素,所以最小约束就是 any
  • Map[T, R any] 用两个类型参数表达“输入元素和输出元素可以不同”。
  • 两个函数都创建新切片,最后一行输出证明原切片没有被修改。是否原地修改必须成为 API 契约的一部分,不能只靠函数名猜测。
  • 回调是同步执行的。若回调访问共享状态,其并发安全仍由调用方负责;泛型不会自动提供同步。

修改实验:把 Map 的回调改为 strconv.Itoa,观察编译器如何把 R 推断成 string;再尝试让 Filter 原地复用 src[:0],比较少一次分配与修改底层数组之间的取舍。

示例二:用约束限定可用操作

第二个程序实现 Clamp。它既要接受整数、浮点数、字符串及其命名类型,又要保留返回值的领域类型,关键就在约束的写法。

go
package main

import "fmt"

// Ordered 是本例算法真正需要的最小约束:这些类型都支持 < 和 >。
// ~ 很重要,它让底层类型相同的命名类型(例如 Temperature)也能使用 Clamp。
// 生产代码还可以直接使用标准库 cmp.Ordered;这里显式声明是为了展示类型集。
type Ordered interface {
	~int | ~int64 | ~float64 | ~string
}

// Clamp 把 value 限制到闭区间 [min, max]。
// 返回类型仍然是 T,不会把 Temperature 悄悄降级成 float64。
func Clamp[T Ordered](value, min, max T) T {
	if min > max {
		// 区间写反属于程序设计错误,而不是可恢复的业务错误。
		// 在公共库中也可以选择返回 (T, error),由 API 契约决定。
		panic("Clamp: min 不能大于 max")
	}
	if value < min {
		return min
	}
	if value > max {
		return max
	}
	return value
}

type Temperature float64

func main() {
	fmt.Println("整数:", Clamp(120, 0, 100))

	var current Temperature = -8.5
	limited := Clamp(current, Temperature(-5), Temperature(40))
	fmt.Printf("温度: %.1f(类型 %T\n", limited, limited)
}

运行:

bash
go run ./examples/ch15/constraints

预期输出:

text
整数: 100
温度: -5.0(类型 main.Temperature)

拆解:

  • Ordered 的联合类型集决定函数体内可以安全使用 <>
  • ~float64 接纳 Temperature;若删掉 ~,命名类型会在编译期被拒绝。
  • 三个参数和返回值都是同一个 T,所以结果仍是 Temperature,不会退化为底层的 float64
  • min > max 是契约被破坏。本例选择 panic 以突出“不变量错误”;面向不可信输入的公共 API 更适合返回 error

修改实验:删除约束中的 ~ 后重新编译;再增加 type Percent int 并调用 Clamp,确认命名整数类型同样被保留。

示例三:保留命名切片与 map 类型

最后来看命名集合类型。通用集合函数如果返回 []Emap[K]V,会丢掉 ScoresLabels 这样的命名类型及其方法。这个例子把集合自身也声明为类型参数,以此保留调用方传入的精确类型。

go
package main

import "fmt"

type Scores []int
type Labels map[string]string

// Double 的 S ~[]E 约束不仅说明 S 的底层类型是 []E,
// 还允许函数把结果转换回 S。因此传入 Scores,返回值仍是 Scores,
// 它携带的方法和领域含义都不会丢失。
func Double[S ~[]E, E ~int](src S) S {
	dst := make(S, len(src))
	for i, value := range src {
		dst[i] = value * 2
	}
	return dst
}

// CloneMap 同时保留命名 map 类型 M、键类型 K 和值类型 V。
// 不能只返回 map[K]V,否则 Labels 会退化成普通 map[string]string。
func CloneMap[M ~map[K]V, K comparable, V any](src M) M {
	dst := make(M, len(src))
	for key, value := range src {
		dst[key] = value
	}
	return dst
}

func main() {
	scores := Scores{60, 75, 90}
	doubled := Double(scores)

	labels := Labels{"env": "test"}
	cloned := CloneMap(labels)
	cloned["owner"] = "team-a"

	fmt.Printf("分数: %v(类型 %T\n", doubled, doubled)
	fmt.Printf("原标签: %v\n", labels)
	fmt.Printf("克隆标签: %v(类型 %T\n", cloned, cloned)
}

运行:

bash
go run ./examples/ch15/named-types

预期输出:

text
分数: [120 150 180](类型 main.Scores)
原标签: map[env:test]
克隆标签: map[env:test owner:team-a](类型 main.Labels)

拆解:

  • S ~[]E 表达“任意底层类型为 []E 的切片”,返回 S 因而保留输入的精确命名类型。
  • M ~map[K]V 对 map 使用相同技巧;K comparable 是 map 键的语言级要求,不是任意追加的限制。
  • CloneMap 分配新 map。修改克隆结果不会改动原 map;若只做 dst := src,两者会共享同一张哈希表。
  • 元素若包含指针、切片或 map,当前克隆仍是浅克隆。泛型函数无法凭空推断深拷贝策略。

修改实验:给 Scores 增加一个 Average 方法并对 doubled 调用它;再把 CloneMap 的返回类型改成 map[K]V,观察领域类型在何处丢失。

22. 练习

  1. 实现保留命名切片类型的 FilterReverseUnique,说明哪些函数会修改输入。
  2. 实现 GroupBy[S ~[]E, E any, K comparable],分析时间和空间复杂度。
  3. 写一个 Set[T comparable],让零值可写;比较 map 别名、定义类型和结构体包装三种设计。
  4. 写一个错误示例说明 comparable 与接口动态值 panic 的区别。
  5. 把带额外类型参数的非法泛型方法改成包级函数,并比较调用可读性。
  6. 用泛型 alias 模拟把 Stack[T] 从旧包迁移到新包,验证两边赋值兼容。
  7. 分别用接口、泛型、代码生成实现同一数值聚合,使用基准和 -gcflags=-m=2 比较。
  8. 设计一个 Go 1.26 自引用约束的有意义例子,再写一个普通接口替代方案,判断哪一个更易维护。
  9. 审查项目中的泛型函数:找出 T 只出现一次或立刻转成 any 的抽象,并尝试简化。

23. 官方资料

以 Go 官方规范与标准库文档为准,示例面向 Go 1.26。