2.4 泛型:通用工牌


2.4 泛型:通用工牌

本节摘要:泛型(1.18 起)允许函数与类型携带类型参数,用约束声明"接受哪些类型"。本节讲类型参数语法、约束的两种形态,以及泛型与接口的选型边界。学完本节,你能把"逻辑相同、类型不同"的重复代码收拢成一份,同时知道什么场景不该用泛型。

上一节的接口解决了"能力抽象",但留下一类重复没治:求和函数要为 int 写一份、为 float64 再写一份,逻辑一字不差。泛型就是给这类代码发的通用工牌——工牌上写清楚"持此牌者须具备哪些条件",条件之内通用,条件之外拒绝入内。

一个迟到的特性

泛型在 Go 社区争论了多年,早期版本只有两条替代路线:一是用空接口加类型断言,运行时才知道类型对不对;二是由工具生成各类型的重复代码。前者牺牲类型安全,后者牺牲可读性。2022 年的 1.18 版本正式引入类型参数,编译期检查、零重复代码,两条老路线才逐渐退役。今天读老项目还会大量遇到它们,认得出、改得动,是本节的实用目标。

类型参数:把类型写成参数

package main import "fmt" // Max 返回两值中较大者。方括号里声明类型参数 T, // 约束 cmp.Ordered 表示"支持小于号比较的类型集合" func Max[T int | float64 | string](a, b T) T { if a > b { return a } return b } func main() { fmt.Println(Max(3, 7)) fmt.Println(Max(2.5, 1.5)) fmt.Println(Max("值班", "休假")) } // 运行输出: // 7 // 2.5 // 值班

调用时方括号可写可不写——编译器从实参推断类型。注意约束 int | float64 | string类型集合的并集写法,T 只能取集合内的类型;传一个结构体进来,编译期直接报错。这与空接口方案的本质区别就在这里:错误在编译期暴露,而不是运行时断言失败。

约束:接口的进阶形态

约束本质上是接口,但比普通接口多了"类型集合"表达能力。除了竖线并集,还可以声明方法要求与底层类型:

package main import ( "fmt" "sort" ) // Summable 要求:要么是数值类型,要么实现了加法语义的底层类型 type Number interface { ~int | ~int64 | ~float64 } // Sum 对任意数值切片求和,一份实现通吃 func Sum[T Number](xs []T) T { var total T // 零值:数值类型的零值恰好是 0 for _, x := range xs { total += x } return total } // SortAny 对任意可比较元素排序:用标准库约束表达"可用小于号" func SortAny[T int | float64 | string](xs []T) { sort.Slice(xs, func(i, j int) bool { return xs[i] < xs[j] }) } func main() { fmt.Println(Sum([]int{1, 2, 3})) fmt.Println(Sum([]float64{1.5, 2.5})) fmt.Println(Sum([]int64{10, 20})) words := []string{"夜班", "白班", "轮休"} SortAny(words) fmt.Println(words) } // 运行输出: // 6 // 4 // 30 // [轮休 夜班 白班]

波浪号前缀 ~int 表示"底层类型是 int 的所有类型",包括 type MyInt int 这类自定义类型——没有波浪号时,自定义类型进不了集合。这是从教程示例走向生产代码的关键细节。

泛型还是接口:一张决策表

场景 选择 理由
函数逻辑与具体类型无关,只依赖操作(加、比较) 泛型 编译期实例化,无装箱开销
需要在运行时替换不同实现(策略、插件) 接口 动态分派正是接口的舞台
容器类型(栈、队列、缓存) 泛型 元素类型参数化,取值免断言
只有一个实现、为测试留口子 接口 泛型是编译期多态,运行时换不了
类型间有共同行为要抽象 接口 行为集合用方法表达最自然

一句话总结分工:泛型在编译期对类型做抽象,接口在运行时对行为做分派。两者不竞争,常常协作——泛型约束本身就可以是接口。

完整案例:通用数据流水线

背景:值班报表要处理多种数据:工单要过滤后再转换,日志条目要清洗后再统计。逐类型写 Map、Filter 函数已经写到第三遍。

操作:用泛型实现一次 Map 与 Filter,分别喂给工单与日志两类数据。

package main import ( "fmt" "strings" ) func Map[T, R any](xs []T, f func(T) R) []R { out := make([]R, 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 { out := xs[:0:0] // 新底层数组,避免复用入参内存 for _, x := range xs { if keep(x) { out = append(out, x) } } return out } type Ticket struct { Title string Minutes int } func main() { tickets := []Ticket{{"磁盘告警", 150}, {"文档更新", 20}, {"库延迟告警", 90}} urgent := Filter(tickets, func(t Ticket) bool { return t.Minutes > 60 }) titles := Map(urgent, func(t Ticket) string { return strings.ToUpper(t.Title) }) fmt.Println(titles) logs := []string{"INFO ok", "ERR timeout", "INFO done"} errs := Filter(logs, func(l string) bool { return strings.HasPrefix(l, "ERR") }) fmt.Println(len(errs), "条错误日志") } // 运行输出: // [磁盘告警 库延迟告警] // 1 条错误日志

结果:两套截然不同的数据复用了同一对 Map 与 Filter,主流程只剩业务语义。

解读:Map 需要两个类型参数(输入 T、输出 R),约束用 any 放到最宽——因为转换逻辑完全由调用方闭包决定,函数本身不需要任何类型能力。这正是选型表的活例:与类型无关的逻辑用 any,依赖操作的逻辑才收紧约束。

变式:把 Filter 改成带缓冲通道的流式版本,元素不用先攒成切片——写的时候你会发现泛型与 channel 组合毫无障碍,Filter[T any](in chan T, keep func(T) bool) chan T 一气呵成。这个变式就是第 3 章 3.3 节的正式内容,泛型在这里完成了向并发章的引路。

收班要点

  • 类型参数写在方括号里,约束限定类型集合,错误在编译期暴露。
  • 约束是接口的进阶形态:竖线并集、波浪号底层类型、方法要求三种表达可组合。
  • 泛型管编译期抽象,接口管运行时分派,按"逻辑无关类型"还是"运行时换实现"选型。
  • 容器与函数式工具(Map、Filter、Sum)是泛型的主场;单一实现的策略类需求老实用接口。
  • 波浪号决定自定义底层类型能否入集,生产约束别忘写。

泛型已经把类型在编译期的可能性讲完了,下一节反射调转方向:运行时才确定类型信息时,程序如何自省。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U