Go 从 0 到精通 · 第 34 课:泛型入门
Go 从 0 到精通 · 第 34 课:泛型入门
学习定位:这是整套 Go 教程的第 34 课,也是阶段六(高级进阶阶段)的第一课。
前置要求:已经完成第 33 课,理解 Go 的项目结构、包组织、接口、错误处理与测试基础。
本课目标:理解 Go 泛型要解决的核心问题,掌握类型参数、约束、泛型函数和泛型结构体的基本写法,学会any、comparable、类型集合和~的用法,并知道泛型适合解决什么问题、不适合解决什么问题。
1. 本课你要解决的核心问题
你前面已经写过很多“长得很像”的函数了。
比如:
- 求两个
int的较大值 - 求两个
float64的较大值 - 判断一个
[]int里有没有某个元素 - 判断一个
[]string里有没有某个元素 - 写一个整数栈
- 又想写一个字符串栈
如果没有泛型,你通常只有三种办法:
- 为每种类型各写一份
- 用
interface{}接收一切,再做类型断言 - 硬写死只支持一种类型
这三种办法都不够理想:
- 重复写多份代码,维护成本高
interface{}丢失类型信息,不安全,还容易出错- 写死类型后,可复用性又很差
泛型的目标就是:让你把“算法或数据结构的通用部分”抽出来,同时保留类型安全。
也就是说:
- 你不用为
int、float64、string各写一份同样逻辑 - 调用者在编译期就能得到类型检查
- 不需要到处做类型断言
这一课的重点就是把这个能力讲清楚。
2. 先理解:为什么 Go 很晚才加入泛型
很多语言很早就有泛型,为什么 Go 到后面才加?
因为 Go 的设计哲学一直比较克制:
- 优先简单
- 优先可读
- 优先少而稳的语言特性
Go 团队不是不知道泛型有用,而是担心两个问题:
- 语法太复杂,破坏 Go 一贯的简洁性
- 滥用泛型后,代码会变得抽象、绕、难读
所以 Go 直到 1.18 才正式加入泛型,而且设计得相对克制。
这也决定了一个非常重要的使用原则:
Go 泛型不是让你把所有代码都改成“高度抽象模板”,而是让你在真正需要复用通用逻辑时,有一个比
interface{}更安全的工具。
你学泛型时,必须一直记着这个原则。
3. 没有泛型时,问题到底出在哪
3.1 重复代码
先看一个最典型的例子:求最大值。
1 | func MaxInt(a, b int) int { |
你会发现逻辑完全一样,只有类型不同。
如果以后再来:
int64uintstring(按字典序比较)
你还得继续复制。
3.2 interface{} 虽然通用,但不安全
另一种常见写法是:
1 | func Contains(items []interface{}, target interface{}) bool { |
看起来很通用,实际上问题很多:
- 调用者必须把数据都装箱成
interface{} - 容易混入错误类型
- 编译器很难帮你检查
- 代码可读性差
比如你完全可以这样传:
1 | items := []interface{}{1, 2, 3} |
代码能编译,但逻辑根本没意义。
3.3 泛型的价值就在这里
如果你想表达的是:
- “这是一段对很多类型都适用的逻辑”
- “但调用时必须保持类型一致”
那泛型就非常合适。
4. 泛型最核心的两个概念
学泛型,只要先吃透两个词:
- 类型参数
- 约束
4.1 类型参数是什么
先看一个泛型函数:
1 | func PrintValue[T any](v T) { |
这里的 [T any] 就是类型参数列表。
可以把它理解为:
T是一个“类型变量”- 具体是什么类型,调用时再决定
any表示它目前没有额外限制
所以:
1 | PrintValue(10) // T 是 int |
4.2 约束是什么
约束就是告诉编译器:
这个类型参数不是什么都能来,它必须满足某些条件。
例如:
1 | func Equal[T comparable](a, b T) bool { |
这里的 comparable 就是约束,意思是:
T必须是可比较的类型- 否则就不能用
==
如果没有这个约束,编译器就不能保证 a == b 一定合法。
所以你可以先把泛型读成一句人话:
1 | func Equal[T comparable](a, b T) bool |
等于:
定义一个函数
Equal,它适用于任意可比较类型T。
这就是泛型最核心的阅读方式。
5. 第一个真正有意义的泛型函数
5.1 写一个通用的 Contains
1 | package main |
5.2 这段代码为什么比 interface{} 好
因为它同时满足了三件事:
- 一套代码复用多个类型
- 调用时类型必须一致
- 编译器能提前发现错误
例如:
1 | nums := []int{1, 2, 3} |
这段代码会直接编译失败,因为:
items是[]inttarget却是string
这就是泛型相比 interface{} 最大的工程价值:通用,但仍然有强类型约束。
6. 类型推断:很多时候不用手动写类型
刚看到泛型时,很多人会以为每次都要这样调用:
1 | Contains[int]([]int{1, 2, 3}, 2) |
其实大多数时候,Go 编译器可以自动推断类型参数:
1 | Contains([]int{1, 2, 3}, 2) |
只有在某些类型信息不够明确的场景下,才需要显式写:
1 | var nums []int |
实战里你会发现:
- 定义泛型时要会写类型参数
- 调用泛型时多数情况不用手写
所以不要把泛型想得太重。
7. any 到底是什么
7.1 any 本质上是 interface{}
Go 里:
1 | any |
只是:
1 | interface{} |
的别名。
所以:
1 | func PrintValue[T any](v T) { |
意思不是“运行时随便什么都行”,而是:
这个类型参数当前没有额外约束。
7.2 any 不代表你能对 T 做任何操作
这是初学者最容易误解的地方。
例如:
1 | func AddOne[T any](v T) T { |
上面这段代码是错的。
因为虽然 T 可以是任意类型,但并不是任意类型都支持 + 1。
所以:
any表示“接收范围广”- 不表示“可做任意操作”
你能对 T 做什么,取决于它的约束允许什么。
8. comparable 是最常用的入门约束
8.1 它表示“可用 == 和 != 比较”
例如:
1 | func IsSame[T comparable](a, b T) bool { |
这时 T 可以是:
- 整数
- 浮点数
- 字符串
- 布尔值
- 指针
- 可比较的结构体
- 元素可比较的数组
8.2 哪些类型不满足 comparable
比如:
slicemapfunc
这些都不能直接用 == 比较。
例如:
1 | func IsSame[T comparable](a, b T) bool { |
这就是约束在编译期保护你的地方。
8.3 一个很实用的泛型 Set
1 | type Set[T comparable] map[T]struct{} |
这里必须使用 comparable,因为 map 的键本身就要求可比较。
9. 自定义约束:让泛型真正可控
如果 any 太宽、comparable 又不够,你就需要自定义约束。
9.1 最简单的自定义约束
1 | type Number interface { |
这个约束表示:
T必须是这些底层类型之一- 这样你就可以安全使用
+、-、*、/
9.2 用它写一个求和函数
1 | func Sum[T Number](items []T) T { |
调用:
1 | fmt.Println(Sum([]int{1, 2, 3})) // 6 |
9.3 为什么不直接用 any
因为如果写成:
1 | func Sum[T any](items []T) T |
编译器根本不知道:
T能不能做加法T有没有零值可用于累加
所以泛型不是“偷懒少写类型”,而是“把可用类型的边界写清楚”。
10. ~ 是什么意思
这是泛型里一个很重要、也很容易一开始看懵的符号。
10.1 ~int 的含义
~int 表示:
底层类型是
int的所有类型
例如:
1 | type MyInt int |
MyInt 的底层类型就是 int,所以它满足 ~int。
10.2 为什么需要 ~
如果你写:
1 | type IntOnly interface { |
那它只接受真正的 int,不接受:
1 | type MyInt int |
而如果你写:
1 | type IntLike interface { |
那么 int 和 MyInt 都可以。
10.3 一个例子看区别
1 | package main |
如果没有 ~,第二个调用就不一定能通过。
10.4 实际理解方式
初学时你可以把 ~ 记成:
“允许底层类型是某种类型的自定义类型也参与进来”
这就够用了。
11. 写一个泛型 Max:把约束和操作结合起来
下面写一个真正常用的泛型函数:求较大值。
11.1 先定义有序类型约束
1 | type Ordered interface { |
这里把字符串也放进来了,因为字符串可以比较大小。
11.2 定义泛型函数
1 | func Max[T Ordered](a, b T) T { |
调用:
1 | fmt.Println(Max(10, 20)) // 20 |
11.3 这就是“既通用又受限”
它不是任意类型都支持,而是:
- 只支持有序类型
- 一旦支持,就可以安全使用
>
这就是泛型设计的核心模式:
- 先定义“哪些类型可以来”
- 再在这个边界内写通用逻辑
12. 泛型结构体:通用数据结构终于好写了
泛型不仅能修饰函数,也能修饰类型。
12.1 写一个泛型栈
1 | package main |
12.2 var zero T 是什么技巧
因为 T 是类型参数,你没法直接写:
1 | return "", false |
或者:
1 | return 0, false |
因为你不知道 T 到底是 string 还是 int。
这时最常见写法就是:
1 | var zero T |
意思是返回 T 的零值。
这是写泛型容器时非常常见的技巧。
13. 泛型结构体的方法怎么写
很多人第一次看到这个语法会别扭:
1 | func (s *Stack[T]) Push(v T) |
你可以把它拆开理解:
Stack[T]是一个泛型类型Push是这个类型的方法- 方法里也可以继续使用
T
13.1 再看一个队列例子
1 | type Queue[T any] struct { |
调用时:
1 | var q Queue[string] |
本质上和普通结构体方法没什么不同,只是这个结构体多了一个类型参数。
14. 泛型和接口的关系:不是替代,而是互补
很多人学到泛型后会问:
有了泛型,是不是接口就不重要了?
不是。两者解决的问题不同。
14.1 接口解决“行为抽象”
比如:
1 | type Reader interface { |
这里抽象的是:
- “谁能读取数据”
- 重点是行为一致
14.2 泛型解决“类型通用”
比如:
1 | func Contains[T comparable](items []T, target T) bool |
这里抽象的是:
- “同一种逻辑适用于多种类型”
- 重点是算法复用
14.3 一个非常实用的判断
如果你在抽象:
- “它能做什么” -> 更像接口
- “它是什么类型,但逻辑相同” -> 更像泛型
14.4 两者可以一起用
例如:
1 | type Stringer interface { |
这里就是:
- 泛型负责“切片元素类型可变化”
- 接口负责“元素必须有
String()方法”
这说明泛型和接口不是对立关系,而是可以配合使用。
15. 什么时候适合用泛型
这节很重要,因为泛型最容易学会语法后乱用。
15.1 适合场景一:通用数据结构
例如:
- 栈
Stack[T] - 队列
Queue[T] - 集合
Set[T] - 堆、环形缓冲区等
这些结构的行为逻辑相同,只是元素类型不同,非常适合用泛型。
15.2 适合场景二:通用算法
例如:
ContainsFilterMapReduceMin/Max- 查找、排序辅助函数
前提是:这些算法对多种类型都成立。
15.3 适合场景三:基础工具库
如果你在写一个可复用组件,例如:
- 通用缓存
- 结果封装类型
- 分页结构
- Optional / Result 类结构
泛型也会很自然。
16. 什么时候不适合用泛型
这部分比“什么时候该用”更重要。
16.1 业务逻辑通常不需要为了泛型而泛型
比如你有:
- 用户服务
- 订单服务
- 支付服务
不要为了“高级”写成:
1 | type Service[T any] struct { ... } |
除非它真的有强烈的通用性,否则只是让代码更绕。
16.2 只有两三个地方重复,不一定值得抽象成泛型
例如你只有:
- 一个
MaxInt - 一个
MaxFloat
而且项目里只会用到这两处,那其实未必有必要上泛型。
Go 的哲学一直是:
为真实重复抽象,不为想象中的未来抽象。
16.3 如果接口更自然,就不要硬上泛型
比如你关心的是:
- 文件、网络连接、内存缓冲都能“读取”
这时候最自然的是 io.Reader 这种接口抽象,而不是泛型。
16.4 如果用了泛型反而更难读,就停下来
这是最实用的一条判断标准。
如果你写完后出现:
- 类型参数三四个
- 约束很长
- 调用点也不好理解
那就要反思:
这份抽象到底是在减少复杂度,还是在转移复杂度。
17. 常见坑总结
17.1 以为 any 就能做任意操作
错误示例:
1 | func AddOne[T any](v T) T { |
原因:
any没有限制类型能力- 编译器无法确认
+是否合法
正确思路:
- 使用数值约束
- 或者重新思考是否真的需要泛型
17.2 约束写得太宽,函数体里却用了受限操作
例如:
1 | func Max[T any](a, b T) T { |
这里 T any 太宽了,和函数体需求不匹配。
记住一句话:
约束必须和函数内部使用的操作一致。
17.3 为业务代码滥用泛型
例如:
1 | type Repository[T any] struct { |
如果你的项目只有一种明确实体,强行写成泛型,通常只会让代码更难看。
泛型的价值在“通用复用”,不是“所有结构都必须参数化”。
17.4 忘了 comparable 才能做相等比较
例如:
1 | func Contains[T any](items []T, target T) bool { |
这是错的,因为 T any 不能保证支持 ==。
应该改成:
1 | func Contains[T comparable](items []T, target T) bool |
17.5 看见 ~ 就慌
其实初学阶段只要记住:
- 不加
~:只接受精确列出的类型 - 加了
~:允许底层类型相同的自定义类型
先记住这个实用层理解就够了。
17.6 过早设计特别复杂的约束体系
例如刚学泛型,就写出非常长的嵌套约束、多个类型参数、复杂接口组合。
结果是:
- 自己两周后都看不懂
- 别人更不敢改
建议:
- 先学会
any - 再学
comparable - 再学简单自定义约束
一步一步来。
18. 一个完整的综合示例
下面把这节课几个核心点串起来。
1 | package main |
这个例子对应了三类最典型的泛型用途:
Max:通用算法 + 有序约束Contains:通用算法 + 可比较约束Stack[T]:通用数据结构
如果你能把这三种写法看明白,说明泛型入门已经掌握住主干了。
19. 本课练习
练习 1:写一个泛型 Min
仿照 Max,写一个 Min[T Ordered](a, b T) T,要求支持:
intfloat64string
练习 2:写一个泛型切片反转函数
实现:
1 | func ReverseSlice[T any](items []T) []T |
要求:
- 返回一个新的切片
- 不修改原切片
- 测试
[]int、[]string、空切片三种情况
练习 3:写一个泛型 Set
实现:
1 | type Set[T comparable] map[T]struct{} |
并补充方法:
AddRemoveHasLen
然后分别用 string 和 int 测试。
练习 4:写一个泛型栈
实现:
PushPopPeekLenIsEmpty
要求:
Pop和Peek在空栈时返回零值和false- 分别用
int、string做示例
练习 5:接口和泛型配合练习
定义一个接口:
1 | type Stringer interface { |
再写一个泛型函数:
1 | func JoinStrings[T Stringer](items []T) string |
要求:
- 把所有元素的
String()结果用逗号拼起来 - 至少定义两个实现了
String()的结构体类型进行测试
20. 自测题
20.1 概念题
- Go 泛型主要是为了解决什么问题?
- 类型参数和约束分别是什么?它们的角色有什么不同?
any和interface{}的关系是什么?- 为什么
Contains这类函数通常要用comparable约束? ~int中的~表示什么含义?- 泛型和接口的核心区别是什么?各自更适合解决什么问题?
- 为什么说业务代码不应该为了“高级感”而滥用泛型?
var zero T这个写法通常用来解决什么问题?
20.2 代码阅读题
下面这段代码有两个明显问题,试着找出来:
1 | func Max[T any](a, b T) T { |
点击查看答案
问题 1:Max 的约束写错了。
T any太宽,不能保证>操作合法- 应该把
T约束为有序类型,例如Ordered
问题 2:Contains 的约束写错了。
T any不能保证==合法- 应该改成
T comparable
修正后:
1 | type Ordered interface { |
21. 本课总结
这一课你已经进入了 Go 高级能力的入口。
| 知识点 | 要点 |
|---|---|
| 泛型目标 | 复用通用逻辑,同时保持类型安全 |
| 类型参数 | 用 [T ...] 表示类型变量 |
| 约束 | 限定 T 能参与哪些操作 |
| 常见约束 | any、comparable、自定义类型集合 |
~ |
允许底层类型相同的自定义类型参与 |
| 典型用途 | 通用算法、通用数据结构、基础工具库 |
最重要的四件事:
- 泛型不是替代接口,而是补充接口
- 约束必须和函数体里要做的操作匹配
- 泛型最适合解决“同一逻辑适用于多种类型”的问题
- 如果用了泛型反而更绕、更难读,就说明这次抽象大概率不值得
22. 下一课预告
你已经掌握了 Go 泛型的入门思路。下一步,我们来学另一个非常强大但必须谨慎使用的能力:反射。
下一课:反射入门
会重点讲:
reflect.Type和reflect.Value是什么- 怎么在运行时查看变量的类型和值
- 反射可以做什么,代价是什么
- 常见反射代码该怎么读
- 为什么很多框架喜欢用反射,又为什么业务代码里要慎用
学完下一课,你就能开始读懂很多框架和高级库里的“动态处理”代码了。





