Go 泛型:类型参数、约束与可维护的抽象
面向有 Java 经验的开发者,基于 Go 1.26。
Go 泛型要解决的问题很具体:一套逻辑需要服务于一族类型,同时还要保留静态类型检查。它并不是把 Java 风格的泛型框架原样搬进 Go。真正好用的 Go 泛型 API,往往只有少量类型参数和短而明确的约束,并且不会排斥接口、组合与具体类型。
学习泛型时,类型参数、约束和类型集很容易混在一起。类型参数是声明中的占位类型;约束决定哪些类型可以传入,以及函数体能做哪些操作;类型集则是约束接口背后的模型。先把这三层分开,后面的类型推断、泛型类型、方法限制、泛型别名、性能与工程取舍才容易串起来。
目录
1. 先判断是否真的需要泛型
最适合泛型的代码通常有两个特征:
- 算法与元素具体类型无关;
- 不使用泛型就要复制多份代码、丢失静态类型,或迫使调用方做断言。
例如:
func Index[T comparable](values []T, target T) int {
for i, value := range values {
if value == target {
return i
}
}
return -1
}同一算法适用于 []string、[]int、[]UserID,并且比较操作由约束保证。
不需要泛型的例子:
func WriteJSON(w io.Writer, v any) error这里只把值交给编码器,没有在类型 T 上执行静态操作,也没有需要保留输入输出类型关系。把它改成 WriteJSON[T any] 不增加安全性,反而让 API 更复杂。
碰到重复代码时,可以按这个顺序判断:
- 一个具体函数够不够?
- 通过行为抽象时,一个普通接口够不够?
- 标准库
slices、maps、cmp是否已经提供? - 只有确实需要表达“类型之间的静态关系”时,再引入类型参数。
2. 类型参数与实例化
2.1 声明
类型参数列表写在函数名后:
func First[T any](values []T) (T, bool) {
if len(values) == 0 {
var zero T
return zero, false
}
return values[0], true
}T 是类型参数,any 是约束。调用时:
n, ok := First([]int{10, 20})
name, ok := First[string]([]string{"Ada", "Rob"})第一种由编译器推断 T=int;第二种显式提供类型实参。
2.2 实例化分两步
概念上,实例化会:
- 用类型实参替换声明中的类型参数;
- 检查每个类型实参是否满足相应约束。
Index([]string{"a", "b"}, "b") // T = string,满足 comparable泛型类型必须实例化后使用:
type Stack[T any] struct {
items []T
}
var ints Stack[int]
// var invalid Stack // 缺少类型实参实例化后的 Stack[int] 和 Stack[string] 是不同类型。
2.3 多个类型参数
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
}参数名应短但有含义:
T、E:单个类型、元素;K、V:键和值;S:切片或集合;R:结果。
一旦类型参数开始增多,约束之间又互相引用,可读性通常会迅速下降。看到这个信号,就该考虑拆分设计了。
3. 约束决定可用操作
约束既规定“什么类型能传入”,也规定“函数体里能对该值做什么”。
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 有这个方法。
约束接口可以组合方法和类型元素:
type Text interface {
~string
Len() int
}这表示底层类型是 string 且方法集中有 Len() int 的类型。注意:这种包含类型元素的非基本接口只能用作约束,不能用作普通变量类型。
约束不要比算法所需更严格。只需要 == 就用 comparable,不要要求一组无关方法;过严约束会无意义地排除合法类型。
4. 类型集:理解约束的钥匙
普通接口可以理解为一组方法,也可以更一般地理解为一组类型:所有实现该接口的非接口类型组成它的类型集。
type Reader interface {
Read([]byte) (int, error)
}它的类型集包含所有拥有相应 Read 方法的类型。
泛型约束扩展了接口元素:
- 方法元素:类型必须实现这些方法;
- 具体类型项:类型必须就是该类型;
~T:类型的底层类型必须是T;A | B:类型属于联合任一分支;- 嵌入接口:取类型集交集。
type Signed interface {
~int | ~int8 | ~int16 | ~int32 | ~int64
}
type ID interface {
Signed
fmt.Stringer
}ID 是“底层类型属于有符号整数族”与“实现 String”的交集。
函数体允许的操作,必须对约束类型集里的每个类型都有效。联合里只有一部分类型支持的操作不能使用。
5. 联合、~ 与 underlying type
5.1 没有 ~ 时只接受精确类型
type ExactInt interface {
int
}
type UserID int
// UserID 不满足 ExactInt,因为它不是预声明的 int。加 ~:
type Integer interface {
~int | ~int32 | ~int64
}~int 表示所有底层类型为 int 的类型,因此 int 和 UserID 都满足。
5.2 为什么库算法常写 S ~[]E
func Clone[S ~[]E, E any](src S) S {
dst := make(S, len(src))
copy(dst, src)
return dst
}若只写:
func Clone[E any](src []E) []E传入命名切片:
type Path []Point返回类型会退化为 []Point。S ~[]E 同时推断完整切片类型和元素类型,返回 S,保留 Path。
5.3 联合项限制
联合项必须有可判定且合理的类型集。非接口联合项的类型集不能重叠:
// 非法:~int 已经包含 int。
// interface{ int | ~int }带方法的接口、comparable 等不能随意作为多项联合的成员。遇到复杂约束时应回到规范验证,不要凭语法猜测。
5.4 可用操作是交集
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
any 是 interface{} 的别名,约束类型集包含所有非接口类型:
func Identity[T any](v T) T { return v }T any 不表示值本身是动态接口。函数实例化后 T 是确定类型,输入输出关系仍由编译器保留。
6.2 comparable
comparable 表示支持 == 和 != 的类型,是 map 键约束的基础:
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:
var a any = []int{1}
var b any = []int{1}
// _ = a == b // 运行时 panic因此 map[any]V 静态合法,不代表任意动态值都能安全当键。
7. 类型推断
7.1 从实参推断
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 部分显式类型实参
类型实参按声明顺序填写,可以只提供前缀,让其余参数推断:
result := Transform[MyInts](
MyInts{1, 2},
strconv.Itoa,
)这也是为什么把最可能需要显式指定的类型参数放前面很重要。
7.3 推断不使用返回值完成所有猜测
下面不能只靠接收变量推断传统调用:
func Zero[T any]() T {
var zero T
return zero
}
func exampleZero() {
n := Zero[int]()
_ = n
}函数没有普通实参提供 T。不过现代 Go 的类型推断支持把泛型函数赋给已知函数类型、作为参数传递或返回:
var parse func(string) (int, error) = strconv.Atoi
_ = parse对自定义泛型函数同样可以从目标函数类型建立方程;但调用点如果让读者难以看出类型,显式写出更好。
7.4 无类型常量
无类型常量参与推断时最终采用对应默认类型:
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 标准库泛型算法常使用两级参数:
func DeleteFunc[S ~[]E, E any](s S, del func(E) bool) S这让自定义类型保持不变:
type Users []User
func activeUsers(users Users) Users {
active := Filter(users, func(u User) bool {
return u.Active
})
return active // 仍是 Users
}map 同理:
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
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
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 泛型类型的递归
type Node[T any] struct {
Value T
Next *Node[T]
}递归引用必须实例化为 Node[T],不能省略类型参数。
10. 泛型类型的方法与方法限制
10.1 接收者重新声明类型参数
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 的定义隐含。名字可以不同:
func (p Pair[Left, Right]) Values() (Left, Right)但通常保持一致更易读。
10.2 方法不能声明自己的额外类型参数
Go 不允许:
// 非法
// func (s Stack[T]) Map[R any](fn func(T) R) Stack[R]把它写成泛型函数:
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 某些实例化才有意义怎么办
如果一个方法只对有序元素有意义,应在类型定义上约束:
type Sorted[T cmp.Ordered] []T或者写独立函数:
func Min[T cmp.Ordered](values Stack[T]) (T, bool)不能在单个方法上追加 T cmp.Ordered。
11. 泛型接口与自引用约束
11.1 参数化行为
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 风格:
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 起,别名声明可以有类型参数:
type Set[K comparable] = map[K]bool
type IntSet = Set[int]别名不创建新类型,Set[string] 与 map[string]bool 是同一类型。它主要用于大型代码库迁移、跨包移动泛型 API,而不是给类型增加语义边界。
12.1 必须显式声明参数
不能省略并“继承”右侧泛型类型的参数:
// 非法
// type Alias = SomeGeneric
type Alias[T any] = SomeGeneric[T]别名自己的参数数量和映射方式不必与右侧相同:
type StringMap[V any] = map[string]V
type PairMap[K comparable, V any] = map[K]struct {
First V
Second V
}12.2 右侧不能就是本声明的类型参数
// 非法
// type Identity[T any] = T但右侧可以使用它组成其他类型,如 []T、map[string]T 或已实例化泛型类型。
12.3 别名不能作为增加方法的后门
方法接收者若由别名表示,该别名不能是泛型别名,也不能直接或间接表示某个实例化泛型类型:
type IntStack = Stack[int]
// 非法:不能给实例化泛型类型的别名声明方法。
// func (IntStack) Sum() int需要新方法和新语义时定义新类型,而不是别名:
type IntStack Stack[int]这会创建不同类型,但原类型的方法不会自动成为新类型的方法,需要自行设计转换和方法集。
13. 常用算法模式
标准库已经提供 slices、maps、cmp,动手写工具函数之前先查一遍。下面这些模式主要用来理解泛型算法的设计方式。
13.1 Map
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
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
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
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
无法通过字段约束表达“任何拥有某字段的结构体”,所以用投影函数:
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 泛型并发安全容器
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 的零值
最通用写法:
var zero T
return zero也可以 *new(T),但通常不如声明清楚。Go 1.26 的内建 new 也允许以表达式形式更方便地取得指针,但泛型 API 仍应先考虑是否真的需要指针。
14.2 不能直接对类型参数做类型断言
类型断言和 type switch 的操作数必须是接口类型。若确实要按动态具体类型分支:
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. 泛型与接口如何分工
接口和泛型并不是互相替代的关系,可以先按下面的边界来判断:
- 接口抽象行为,让不同具体类型在运行时以统一方式被调用;
- 泛型保留类型关系,让编译器在编译期检查输入、输出和容器元素的一致性;
- 函数值注入单个策略,常常比定义接口更轻。
例子:
func CopyAll[T io.Reader](readers []T, dst io.Writer) error这里是否需要泛型要看返回关系。若只是逐个调用 Read,[]io.Reader 已经够用;泛型可能增加实例化而没有收益。
反之:
func Clone[S ~[]E, E any](src S) S接口无法表达“返回与输入完全相同的命名切片类型”,泛型很合适。
不要定义只被一个类型实现、只为迁就泛型而存在的约束。约束应该来自算法真正需要的操作。
16. 性能、代码生成与字典化
16.1 语言规范不承诺实现策略
Go 规范定义类型检查和运行语义,不承诺编译器必须为每个实例化生成一份完整机器码,也不承诺像 Java 一样统一擦除。当前编译器可能:
- 为一组具有相同“形状”的类型复用实现;
- 通过隐藏字典传递类型相关操作;
- 对部分实例化做专门化和内联;
- 随版本改变策略。
所以,不能把某个版本的编译细节当成 API 保证。“泛型一定零成本”和“泛型一定走反射”这两种说法都不准确。
16.2 与 interface{} 的差异
泛型通常避免调用方手工装箱和断言,能保留静态类型,也给编译器更多优化机会。但具体分配、间接调用和二进制体积取决于代码形态。
比较时要测:
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 给约束起名的时机
仅用一次且很短:
func Sum[T ~int | ~int64](values []T) T跨多个 API 复用或本身有领域语义时命名:
type MetricNumber interface {
~int64 | ~float64
}不要为了“整洁”导出大量只有实现意义的约束。导出约束同样形成兼容性承诺。
17.3 构造函数帮助推断
func NewSet[T comparable](values ...T) Set[T]调用 NewSet("a", "b") 可推断;空集合必须 NewSet[string]()。若零值可用,则调用方甚至不一定需要构造函数。
17.4 文档写清所有权与复杂度
泛型签名只表达类型,不表达:
- 是否修改输入;
- 是否共享底层数组;
- 是否浅复制;
- 遍历是否稳定;
- 比较器是否允许有副作用;
- 时间和空间复杂度;
- 并发是否安全。
这些仍要在文档中说明。
18. 何时不要使用泛型
以下情况通常不值得:
- 只有一个具体类型,没有真实复用需求;
- 逻辑只调用共同方法,普通接口更自然;
T只出现一次,没有表达任何类型关系;- 函数内部立刻
any(v).(type)大量分支; - 约束需要十几个联合项才能勉强表达领域;
- 反射仍不可避免,泛型只包了一层;
- 标准库已经有等价算法;
- 为模拟 Java Stream 而制造大量短命中间集合;
- 导出抽象只是为了少写几十行稳定、清晰的具体代码。
少量重复不一定坏。过早抽象会把不同业务规则绑在一个难演进的类型参数系统里。
19. 与 Java 泛型的对照
| Go | Java | 关键差异 |
|---|---|---|
[T any] | <T> | Go 约束由接口类型集表达 |
~int | ~string | 无直接对应 | Go 可约束底层类型和运算符 |
comparable | 无直接对应 | 静态保证可用于 ==/map 键,但接口动态值仍需注意 |
S ~[]E | 集合泛型 | 可保留命名切片类型 |
| 泛型函数 | static generic method | Go 方法不能声明额外类型参数 |
| 实例化实现 | Java 类型擦除 | Go 规范不承诺擦除,编译器可形状复用/字典化/专门化 |
| 无通配符语法 | ? extends / ? super | Go 通过约束、接口和函数参数重新组织 API |
| 泛型 alias | Java 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 则允许输入与输出使用不同类型。
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)
}运行:
go run ./examples/ch15/map-filter预期输出:
偶数: [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。它既要接受整数、浮点数、字符串及其命名类型,又要保留返回值的领域类型,关键就在约束的写法。
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)
}运行:
go run ./examples/ch15/constraints预期输出:
整数: 100
温度: -5.0(类型 main.Temperature)拆解:
Ordered的联合类型集决定函数体内可以安全使用<和>。~float64接纳Temperature;若删掉~,命名类型会在编译期被拒绝。- 三个参数和返回值都是同一个
T,所以结果仍是Temperature,不会退化为底层的float64。 min > max是契约被破坏。本例选择 panic 以突出“不变量错误”;面向不可信输入的公共 API 更适合返回error。
修改实验:删除约束中的 ~ 后重新编译;再增加 type Percent int 并调用 Clamp,确认命名整数类型同样被保留。
示例三:保留命名切片与 map 类型
最后来看命名集合类型。通用集合函数如果返回 []E 或 map[K]V,会丢掉 Scores、Labels 这样的命名类型及其方法。这个例子把集合自身也声明为类型参数,以此保留调用方传入的精确类型。
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)
}运行:
go run ./examples/ch15/named-types预期输出:
分数: [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. 练习
- 实现保留命名切片类型的
Filter、Reverse、Unique,说明哪些函数会修改输入。 - 实现
GroupBy[S ~[]E, E any, K comparable],分析时间和空间复杂度。 - 写一个
Set[T comparable],让零值可写;比较 map 别名、定义类型和结构体包装三种设计。 - 写一个错误示例说明
comparable与接口动态值 panic 的区别。 - 把带额外类型参数的非法泛型方法改成包级函数,并比较调用可读性。
- 用泛型 alias 模拟把
Stack[T]从旧包迁移到新包,验证两边赋值兼容。 - 分别用接口、泛型、代码生成实现同一数值聚合,使用基准和
-gcflags=-m=2比较。 - 设计一个 Go 1.26 自引用约束的有意义例子,再写一个普通接口替代方案,判断哪一个更易维护。
- 审查项目中的泛型函数:找出
T只出现一次或立刻转成any的抽象,并尝试简化。