Go 从 0 到精通 · 第 34 课:泛型入门

学习定位:这是整套 Go 教程的第 34 课,也是阶段六(高级进阶阶段)的第一课。
前置要求:已经完成第 33 课,理解 Go 的项目结构、包组织、接口、错误处理与测试基础。
本课目标:理解 Go 泛型要解决的核心问题,掌握类型参数、约束、泛型函数和泛型结构体的基本写法,学会 anycomparable、类型集合和 ~ 的用法,并知道泛型适合解决什么问题、不适合解决什么问题。


1. 本课你要解决的核心问题

你前面已经写过很多“长得很像”的函数了。

比如:

  • 求两个 int 的较大值
  • 求两个 float64 的较大值
  • 判断一个 []int 里有没有某个元素
  • 判断一个 []string 里有没有某个元素
  • 写一个整数栈
  • 又想写一个字符串栈

如果没有泛型,你通常只有三种办法:

  1. 为每种类型各写一份
  2. interface{} 接收一切,再做类型断言
  3. 硬写死只支持一种类型

这三种办法都不够理想:

  • 重复写多份代码,维护成本高
  • interface{} 丢失类型信息,不安全,还容易出错
  • 写死类型后,可复用性又很差

泛型的目标就是:让你把“算法或数据结构的通用部分”抽出来,同时保留类型安全。

也就是说:

  • 你不用为 intfloat64string 各写一份同样逻辑
  • 调用者在编译期就能得到类型检查
  • 不需要到处做类型断言

这一课的重点就是把这个能力讲清楚。


2. 先理解:为什么 Go 很晚才加入泛型

很多语言很早就有泛型,为什么 Go 到后面才加?

因为 Go 的设计哲学一直比较克制:

  • 优先简单
  • 优先可读
  • 优先少而稳的语言特性

Go 团队不是不知道泛型有用,而是担心两个问题:

  1. 语法太复杂,破坏 Go 一贯的简洁性
  2. 滥用泛型后,代码会变得抽象、绕、难读

所以 Go 直到 1.18 才正式加入泛型,而且设计得相对克制。

这也决定了一个非常重要的使用原则:

Go 泛型不是让你把所有代码都改成“高度抽象模板”,而是让你在真正需要复用通用逻辑时,有一个比 interface{} 更安全的工具。

你学泛型时,必须一直记着这个原则。


3. 没有泛型时,问题到底出在哪

3.1 重复代码

先看一个最典型的例子:求最大值。

1
2
3
4
5
6
7
8
9
10
11
12
13
func MaxInt(a, b int) int {
if a > b {
return a
}
return b
}

func MaxFloat(a, b float64) float64 {
if a > b {
return a
}
return b
}

你会发现逻辑完全一样,只有类型不同。

如果以后再来:

  • int64
  • uint
  • string(按字典序比较)

你还得继续复制。

3.2 interface{} 虽然通用,但不安全

另一种常见写法是:

1
2
3
4
5
6
7
8
func Contains(items []interface{}, target interface{}) bool {
for _, item := range items {
if item == target {
return true
}
}
return false
}

看起来很通用,实际上问题很多:

  • 调用者必须把数据都装箱成 interface{}
  • 容易混入错误类型
  • 编译器很难帮你检查
  • 代码可读性差

比如你完全可以这样传:

1
2
items := []interface{}{1, 2, 3}
Contains(items, "2")

代码能编译,但逻辑根本没意义。

3.3 泛型的价值就在这里

如果你想表达的是:

  • “这是一段对很多类型都适用的逻辑”
  • “但调用时必须保持类型一致”

那泛型就非常合适。


4. 泛型最核心的两个概念

学泛型,只要先吃透两个词:

  1. 类型参数
  2. 约束

4.1 类型参数是什么

先看一个泛型函数:

1
2
3
func PrintValue[T any](v T) {
fmt.Println(v)
}

这里的 [T any] 就是类型参数列表。

可以把它理解为:

  • T 是一个“类型变量”
  • 具体是什么类型,调用时再决定
  • any 表示它目前没有额外限制

所以:

1
2
3
PrintValue(10)        // T 是 int
PrintValue("hello") // T 是 string
PrintValue(true) // T 是 bool

4.2 约束是什么

约束就是告诉编译器:

这个类型参数不是什么都能来,它必须满足某些条件。

例如:

1
2
3
func Equal[T comparable](a, b T) bool {
return a == b
}

这里的 comparable 就是约束,意思是:

  • T 必须是可比较的类型
  • 否则就不能用 ==

如果没有这个约束,编译器就不能保证 a == b 一定合法。

所以你可以先把泛型读成一句人话:

1
func Equal[T comparable](a, b T) bool

等于:

定义一个函数 Equal,它适用于任意可比较类型 T

这就是泛型最核心的阅读方式。


5. 第一个真正有意义的泛型函数

5.1 写一个通用的 Contains

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package main

import "fmt"

func Contains[T comparable](items []T, target T) bool {
for _, item := range items {
if item == target {
return true
}
}
return false
}

func main() {
nums := []int{10, 20, 30}
fmt.Println(Contains(nums, 20)) // true

names := []string{"张三", "李四", "王五"}
fmt.Println(Contains(names, "赵六")) // false
}

5.2 这段代码为什么比 interface{}

因为它同时满足了三件事:

  1. 一套代码复用多个类型
  2. 调用时类型必须一致
  3. 编译器能提前发现错误

例如:

1
2
nums := []int{1, 2, 3}
Contains(nums, "2")

这段代码会直接编译失败,因为:

  • items[]int
  • target 却是 string

这就是泛型相比 interface{} 最大的工程价值:通用,但仍然有强类型约束。


6. 类型推断:很多时候不用手动写类型

刚看到泛型时,很多人会以为每次都要这样调用:

1
Contains[int]([]int{1, 2, 3}, 2)

其实大多数时候,Go 编译器可以自动推断类型参数:

1
2
Contains([]int{1, 2, 3}, 2)
Contains([]string{"a", "b"}, "a")

只有在某些类型信息不够明确的场景下,才需要显式写:

1
2
var nums []int
_ = Contains[int](nums, 10)

实战里你会发现:

  • 定义泛型时要会写类型参数
  • 调用泛型时多数情况不用手写

所以不要把泛型想得太重。


7. any 到底是什么

7.1 any 本质上是 interface{}

Go 里:

1
any

只是:

1
interface{}

的别名。

所以:

1
2
3
func PrintValue[T any](v T) {
fmt.Println(v)
}

意思不是“运行时随便什么都行”,而是:

这个类型参数当前没有额外约束。

7.2 any 不代表你能对 T 做任何操作

这是初学者最容易误解的地方。

例如:

1
2
3
func AddOne[T any](v T) T {
return v + 1
}

上面这段代码是错的。

因为虽然 T 可以是任意类型,但并不是任意类型都支持 + 1

所以:

  • any 表示“接收范围广”
  • 不表示“可做任意操作”

你能对 T 做什么,取决于它的约束允许什么。


8. comparable 是最常用的入门约束

8.1 它表示“可用 == 和 != 比较”

例如:

1
2
3
func IsSame[T comparable](a, b T) bool {
return a == b
}

这时 T 可以是:

  • 整数
  • 浮点数
  • 字符串
  • 布尔值
  • 指针
  • 可比较的结构体
  • 元素可比较的数组

8.2 哪些类型不满足 comparable

比如:

  • slice
  • map
  • func

这些都不能直接用 == 比较。

例如:

1
2
3
4
5
6
7
8
9
10
11
func IsSame[T comparable](a, b T) bool {
return a == b
}

func main() {
a := []int{1, 2}
b := []int{1, 2}

// 编译错误:[]int 不满足 comparable
fmt.Println(IsSame(a, b))
}

这就是约束在编译期保护你的地方。

8.3 一个很实用的泛型 Set

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Set[T comparable] map[T]struct{}

func NewSet[T comparable]() Set[T] {
return make(Set[T])
}

func (s Set[T]) Add(v T) {
s[v] = struct{}{}
}

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

这里必须使用 comparable,因为 map 的键本身就要求可比较。


9. 自定义约束:让泛型真正可控

如果 any 太宽、comparable 又不够,你就需要自定义约束。

9.1 最简单的自定义约束

1
2
3
4
5
type Number interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
~float32 | ~float64
}

这个约束表示:

  • T 必须是这些底层类型之一
  • 这样你就可以安全使用 +-*/

9.2 用它写一个求和函数

1
2
3
4
5
6
7
func Sum[T Number](items []T) T {
var total T
for _, item := range items {
total += item
}
return total
}

调用:

1
2
fmt.Println(Sum([]int{1, 2, 3}))           // 6
fmt.Println(Sum([]float64{1.5, 2.5, 3.0})) // 7

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
2
3
type IntOnly interface {
int
}

那它只接受真正的 int,不接受:

1
type MyInt int

而如果你写:

1
2
3
type IntLike interface {
~int
}

那么 intMyInt 都可以。

10.3 一个例子看区别

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import "fmt"

type MyInt int

type IntLike interface {
~int
}

func Double[T IntLike](v T) T {
return v * 2
}

func main() {
var a int = 10
var b MyInt = 20

fmt.Println(Double(a)) // 20
fmt.Println(Double(b)) // 40
}

如果没有 ~,第二个调用就不一定能通过。

10.4 实际理解方式

初学时你可以把 ~ 记成:

“允许底层类型是某种类型的自定义类型也参与进来”

这就够用了。


11. 写一个泛型 Max:把约束和操作结合起来

下面写一个真正常用的泛型函数:求较大值。

11.1 先定义有序类型约束

1
2
3
4
5
6
type Ordered interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
~float32 | ~float64 |
~string
}

这里把字符串也放进来了,因为字符串可以比较大小。

11.2 定义泛型函数

1
2
3
4
5
6
func Max[T Ordered](a, b T) T {
if a > b {
return a
}
return b
}

调用:

1
2
3
fmt.Println(Max(10, 20))         // 20
fmt.Println(Max(3.14, 2.71)) // 3.14
fmt.Println(Max("apple", "zoo")) // zoo

11.3 这就是“既通用又受限”

它不是任意类型都支持,而是:

  • 只支持有序类型
  • 一旦支持,就可以安全使用 >

这就是泛型设计的核心模式:

  1. 先定义“哪些类型可以来”
  2. 再在这个边界内写通用逻辑

12. 泛型结构体:通用数据结构终于好写了

泛型不仅能修饰函数,也能修饰类型。

12.1 写一个泛型栈

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package main

import "fmt"

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

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

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

idx := len(s.items) - 1
value := s.items[idx]
s.items = s.items[:idx]
return value, true
}

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

func main() {
var intStack Stack[int]
intStack.Push(10)
intStack.Push(20)
v, ok := intStack.Pop()
fmt.Println(v, ok) // 20 true

var strStack Stack[string]
strStack.Push("Go")
strStack.Push("泛型")
s, ok := strStack.Pop()
fmt.Println(s, ok) // 泛型 true
}

12.2 var zero T 是什么技巧

因为 T 是类型参数,你没法直接写:

1
return "", false

或者:

1
return 0, false

因为你不知道 T 到底是 string 还是 int

这时最常见写法就是:

1
2
var zero T
return zero, false

意思是返回 T 的零值。

这是写泛型容器时非常常见的技巧。


13. 泛型结构体的方法怎么写

很多人第一次看到这个语法会别扭:

1
func (s *Stack[T]) Push(v T)

你可以把它拆开理解:

  • Stack[T] 是一个泛型类型
  • Push 是这个类型的方法
  • 方法里也可以继续使用 T

13.1 再看一个队列例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type Queue[T any] struct {
items []T
}

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

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

value := q.items[0]
q.items = q.items[1:]
return value, true
}

调用时:

1
2
3
var q Queue[string]
q.Enqueue("A")
q.Enqueue("B")

本质上和普通结构体方法没什么不同,只是这个结构体多了一个类型参数。


14. 泛型和接口的关系:不是替代,而是互补

很多人学到泛型后会问:

有了泛型,是不是接口就不重要了?

不是。两者解决的问题不同。

14.1 接口解决“行为抽象”

比如:

1
2
3
type Reader interface {
Read(p []byte) (int, error)
}

这里抽象的是:

  • “谁能读取数据”
  • 重点是行为一致

14.2 泛型解决“类型通用”

比如:

1
func Contains[T comparable](items []T, target T) bool

这里抽象的是:

  • “同一种逻辑适用于多种类型”
  • 重点是算法复用

14.3 一个非常实用的判断

如果你在抽象:

  • “它能做什么” -> 更像接口
  • “它是什么类型,但逻辑相同” -> 更像泛型

14.4 两者可以一起用

例如:

1
2
3
4
5
6
7
8
9
type Stringer interface {
String() string
}

func PrintAll[T Stringer](items []T) {
for _, item := range items {
fmt.Println(item.String())
}
}

这里就是:

  • 泛型负责“切片元素类型可变化”
  • 接口负责“元素必须有 String() 方法”

这说明泛型和接口不是对立关系,而是可以配合使用。


15. 什么时候适合用泛型

这节很重要,因为泛型最容易学会语法后乱用。

15.1 适合场景一:通用数据结构

例如:

  • Stack[T]
  • 队列 Queue[T]
  • 集合 Set[T]
  • 堆、环形缓冲区等

这些结构的行为逻辑相同,只是元素类型不同,非常适合用泛型。

15.2 适合场景二:通用算法

例如:

  • Contains
  • Filter
  • Map
  • Reduce
  • Min / 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
2
3
func AddOne[T any](v T) T {
return v + 1
}

原因:

  • any 没有限制类型能力
  • 编译器无法确认 + 是否合法

正确思路:

  • 使用数值约束
  • 或者重新思考是否真的需要泛型

17.2 约束写得太宽,函数体里却用了受限操作

例如:

1
2
3
4
5
6
func Max[T any](a, b T) T {
if a > b {
return a
}
return b
}

这里 T any 太宽了,和函数体需求不匹配。

记住一句话:

约束必须和函数内部使用的操作一致。

17.3 为业务代码滥用泛型

例如:

1
2
3
type Repository[T any] struct {
items []T
}

如果你的项目只有一种明确实体,强行写成泛型,通常只会让代码更难看。

泛型的价值在“通用复用”,不是“所有结构都必须参数化”。

17.4 忘了 comparable 才能做相等比较

例如:

1
2
3
4
5
6
7
8
func Contains[T any](items []T, target T) bool {
for _, item := range items {
if item == target {
return true
}
}
return false
}

这是错的,因为 T any 不能保证支持 ==

应该改成:

1
func Contains[T comparable](items []T, target T) bool

17.5 看见 ~ 就慌

其实初学阶段只要记住:

  • 不加 ~:只接受精确列出的类型
  • 加了 ~:允许底层类型相同的自定义类型

先记住这个实用层理解就够了。

17.6 过早设计特别复杂的约束体系

例如刚学泛型,就写出非常长的嵌套约束、多个类型参数、复杂接口组合。

结果是:

  • 自己两周后都看不懂
  • 别人更不敢改

建议:

  • 先学会 any
  • 再学 comparable
  • 再学简单自定义约束

一步一步来。


18. 一个完整的综合示例

下面把这节课几个核心点串起来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package main

import "fmt"

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

func Max[T Ordered](a, b T) T {
if a > b {
return a
}
return b
}

func Contains[T comparable](items []T, target T) bool {
for _, item := range items {
if item == target {
return true
}
}
return false
}

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

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

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

func main() {
fmt.Println(Max(10, 20))
fmt.Println(Max("apple", "zoo"))

fmt.Println(Contains([]int{1, 2, 3}, 2))
fmt.Println(Contains([]string{"Go", "Java"}, "Rust"))

var s Stack[string]
s.Push("A")
s.Push("B")
v, ok := s.Pop()
fmt.Println(v, ok)
}

这个例子对应了三类最典型的泛型用途:

  • Max:通用算法 + 有序约束
  • Contains:通用算法 + 可比较约束
  • Stack[T]:通用数据结构

如果你能把这三种写法看明白,说明泛型入门已经掌握住主干了。


19. 本课练习

练习 1:写一个泛型 Min

仿照 Max,写一个 Min[T Ordered](a, b T) T,要求支持:

  • int
  • float64
  • string

练习 2:写一个泛型切片反转函数

实现:

1
func ReverseSlice[T any](items []T) []T

要求:

  • 返回一个新的切片
  • 不修改原切片
  • 测试 []int[]string、空切片三种情况

练习 3:写一个泛型 Set

实现:

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

并补充方法:

  • Add
  • Remove
  • Has
  • Len

然后分别用 stringint 测试。


练习 4:写一个泛型栈

实现:

  • Push
  • Pop
  • Peek
  • Len
  • IsEmpty

要求:

  • PopPeek 在空栈时返回零值和 false
  • 分别用 intstring 做示例

练习 5:接口和泛型配合练习

定义一个接口:

1
2
3
type Stringer interface {
String() string
}

再写一个泛型函数:

1
func JoinStrings[T Stringer](items []T) string

要求:

  • 把所有元素的 String() 结果用逗号拼起来
  • 至少定义两个实现了 String() 的结构体类型进行测试

20. 自测题

20.1 概念题

  1. Go 泛型主要是为了解决什么问题?
  2. 类型参数和约束分别是什么?它们的角色有什么不同?
  3. anyinterface{} 的关系是什么?
  4. 为什么 Contains 这类函数通常要用 comparable 约束?
  5. ~int 中的 ~ 表示什么含义?
  6. 泛型和接口的核心区别是什么?各自更适合解决什么问题?
  7. 为什么说业务代码不应该为了“高级感”而滥用泛型?
  8. var zero T 这个写法通常用来解决什么问题?

20.2 代码阅读题

下面这段代码有两个明显问题,试着找出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func Max[T any](a, b T) T {
if a > b {
return a
}
return b
}

func Contains[T any](items []T, target T) bool {
for _, item := range items {
if item == target {
return true
}
}
return false
}
点击查看答案

问题 1:Max 的约束写错了。

  • T any 太宽,不能保证 > 操作合法
  • 应该把 T 约束为有序类型,例如 Ordered

问题 2:Contains 的约束写错了。

  • T any 不能保证 == 合法
  • 应该改成 T comparable

修正后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
type Ordered interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
~float32 | ~float64 | ~string
}

func Max[T Ordered](a, b T) T {
if a > b {
return a
}
return b
}

func Contains[T comparable](items []T, target T) bool {
for _, item := range items {
if item == target {
return true
}
}
return false
}

21. 本课总结

这一课你已经进入了 Go 高级能力的入口。

知识点 要点
泛型目标 复用通用逻辑,同时保持类型安全
类型参数 [T ...] 表示类型变量
约束 限定 T 能参与哪些操作
常见约束 anycomparable、自定义类型集合
~ 允许底层类型相同的自定义类型参与
典型用途 通用算法、通用数据结构、基础工具库

最重要的四件事:

  1. 泛型不是替代接口,而是补充接口
  2. 约束必须和函数体里要做的操作匹配
  3. 泛型最适合解决“同一逻辑适用于多种类型”的问题
  4. 如果用了泛型反而更绕、更难读,就说明这次抽象大概率不值得

22. 下一课预告

你已经掌握了 Go 泛型的入门思路。下一步,我们来学另一个非常强大但必须谨慎使用的能力:反射。

下一课:反射入门

会重点讲:

  • reflect.Typereflect.Value 是什么
  • 怎么在运行时查看变量的类型和值
  • 反射可以做什么,代价是什么
  • 常见反射代码该怎么读
  • 为什么很多框架喜欢用反射,又为什么业务代码里要慎用

学完下一课,你就能开始读懂很多框架和高级库里的“动态处理”代码了。