大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
泛型入门
这一课你将能做到
- 编写带类型参数的函数(如
Max[T cmp.Ordered]),并利用类型推断省略方括号 - 自定义类型约束(类型集语法、
~波浪号),理解comparable的用途 - 定义泛型结构体(如
Stack[T]),并知道什么场景该用泛型、什么场景不该
为什么要学
Go 1.18 之前,想写一个「对任意类型生效」的函数只有两条烂路:为每个类型复制粘贴,或用 interface{} + 类型断言(运行时才报错)。Go 1.18(2022 年)引入泛型后,标准库新增了泛型版的 slices、maps 包,社区 API 也在全面升级。看懂并写出 [T any] 已是 Go 工程师的必备技能,也是近年 Go 面试的新增考点。
泛型的定位要摆正:它是消除重复代码的工具,不是「升级版继承」。这一课教你写出正确、克制、符合 Go 审美的泛型代码。
先建立图像
泛型 = 印章模板。函数逻辑是刻好的版,类型参数决定印在哪种纸上:
┌───────────── 泛型函数 Max[T] ─────────────┐
│ if a > b { return a } (逻辑只写一遍) │
└───────────────────────────────────────────┘
│ │ │
调用: Max(3, 5) ▼ Max(1.5, 2.0) ▼ Max("a","b") ▼
实例化: Max[int] Max[float64] Max[string]
结果: 5 2.0 "b"
对比 interface{} 旧方案:装箱成 any → 运行时断言 → 类型错了运行时才炸
泛型方案:编译期检查 + 无装箱 + 调用方零断言
跟着做
步骤 1:先看没有泛型的痛
package main
import "fmt"
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
}
// any 版本:逻辑重复没了,但丢掉了类型安全
func MaxAny(a, b any) (any, error) {
switch a.(type) {
case int:
if a.(int) > b.(int) {
return a, nil
}
return b, nil
default:
return nil, fmt.Errorf("不支持的类型")
}
}
func main() {
fmt.Println(MaxInt(3, 5), MaxFloat(1.5, 2.5))
v, _ := MaxAny(3, 5)
n := v.(int) // 调用方被迫断言,忘了断言就是运行时 panic
fmt.Println(n)
}
两份重复代码 + 一个丢类型安全的版本,这就是泛型要解决的世界。
步骤 2:第一个泛型函数
package main
import (
"cmp"
"fmt"
)
// [T cmp.Ordered] 声明类型参数 T,约束为「可排序类型」
// cmp.Ordered 是标准库约束(Go 1.21+),涵盖所有整数、浮点、字符串
func Max[T cmp.Ordered](a, b T) T {
if a > b {
return a
}
return b
}
func main() {
fmt.Println(Max(3, 5)) // 类型推断:T = int,无需写 [int]
fmt.Println(Max(1.5, 2.5)) // T = float64
fmt.Println(Max("a", "b")) // T = string
fmt.Println(Max[int](3, 5)) // 显式指定也可以(推断不了时必须写)
}
语法拆解:func Max[T cmp.Ordered](a, b T) T —— 方括号里是「类型参数列表」,T 是形参名(惯用 T、K、V),cmp.Ordered 是约束(「T 必须长什么样」)。
步骤 3:自定义约束——类型集语法与 ~
约束就是接口,但内容从「方法集」扩展成了「类型集」:
package main
import "fmt"
// Number 是一个类型集:int、int64、float64 及其「底层类型」都满足
type Number interface {
~int | ~int64 | ~float64
}
// ~int 读作「底层类型是 int」:type MyInt int 声明的 MyInt 也满足,
// 没有波浪号时 MyInt 就不满足(它不是字面上的 int)
type MyInt int
func Sum[T Number](nums []T) T {
var total T // 泛型里的零值声明套路:var x T
for _, n := range nums {
total += n
}
return total
}
func main() {
fmt.Println(Sum([]int{1, 2, 3})) // 6
fmt.Println(Sum([]float64{1.5, 2.5})) // 4
fmt.Println(Sum([]MyInt{10, 20})) // 30,靠 ~int 放行
// comparable:内置约束,= 支持 == 和 != 的类型(可当 map key)
fmt.Println(Equal(1, 1), Equal("a", "a"))
}
func Equal[T comparable](a, b T) bool {
return a == b
}
记住三个内置/常用约束:any(任意类型)、comparable(可比较,map key 的最低要求)、cmp.Ordered(可排序)。
步骤 4:多类型参数——Map 与 Filter
package main
import "fmt"
// 两个类型参数:输入 T,输出 R,各自独立推断
func Map[T, R any](in []T, f func(T) R) []R {
out := make([]R, 0, len(in))
for _, v := range in {
out = append(out, f(v))
}
return out
}
func Filter[T any](in []T, keep func(T) bool) []T {
var out []T
for _, v := range in {
if keep(v) {
out = append(out, v)
}
}
return out
}
func main() {
nums := []int{1, 2, 3, 4, 5}
strs := Map(nums, func(n int) string { // T=int, R=string 自动推断
return fmt.Sprintf("#%d", n)
})
evens := Filter(nums, func(n int) bool { return n%2 == 0 })
fmt.Println(strs) // [#1 #2 #3 #4 #5]
fmt.Println(evens) // [2 4]
}
步骤 5:泛型结构体 Stack[T]
package main
import "fmt"
type Stack[T any] struct {
items []T
}
// 方法上不能再声明新的类型参数,直接复用接收者的 T
func (s *Stack[T]) Push(v T) {
s.items = append(s.items, v)
}
func (s *Stack[T]) Pop() (T, bool) {
var zero T // 泛型零值惯例
if len(s.items) == 0 {
return zero, false
}
v := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return v, true
}
func (s *Stack[T]) Len() int { return len(s.items) }
func main() {
s := &Stack[string]{}
s.Push("a")
s.Push("b")
fmt.Println(s.Len()) // 2
v, ok := s.Pop()
fmt.Println(v, ok) // b true
// 类型错误在编译期暴露,不是运行时
// s.Push(123) // 编译错误:cannot use 123 as string
}
步骤 6:标准库泛型包速览
package main
import (
"fmt"
"maps"
"slices"
)
func main() {
nums := []int{3, 1, 4, 1, 5}
slices.Sort(nums) // 原地排序
fmt.Println(slices.Max(nums)) // 5
fmt.Println(slices.Index(nums, 4)) // 1
fmt.Println(slices.Contains(nums, 9)) // false
ages := map[string]int{"a": 1, "b": 2}
keys := slices.Sorted(maps.Keys(ages)) // 取 key 并排序(Go 1.23 迭代器风格)
fmt.Println(keys) // [a b]
}
golang.org/x/exp/constraints 是标准库约束落地前的过渡包,新代码直接用 cmp、slices、maps。
讲清楚要点
- 类型参数声明在方括号里:
func F[T C](a T)、type S[T C] struct{...};调用时编译器按实参推断 T,推断不出才显式写F[int](...)。 - 约束 = 类型的类型:既是「白名单类型集」(
~int | ~float64),也可以是方法要求(和普通接口一样可以混写方法与类型集)。 ~表示底层类型:~int放行type MyInt int;不写~则只放行字面 int。- 方法不能新增类型参数:
func (s *Stack[T]) Push(v T)合法;func (s *Stack[T]) Map[U any](...)编译错误——这是 Go 泛型与 C++/Java 的重要差异。 - 泛型是编译期检查、无装箱开销(Go 实现按「GC 形状」共享代码,指针类类型共享一份实例化,值类型各自生成);any + 断言则是运行时检查 + 装箱。
- 克制原则:只有当「逻辑相同、类型不同、逻辑里真的要对 T 做运算/比较」时才上泛型;多态需求优先接口。
你可能会懵的地方
-
泛型和接口不都是「多态」吗?怎么选? 接口是「运行时多态」:传进来什么实现都行,按方法集派发,适合「同一行为多种实现」;泛型是「编译期多态」:同一逻辑适配多种数据类型,需要运算符语义(比较、加减)时接口表达不了,只能泛型。经验法则:行为多变用接口,类型多变用泛型。
-
为什么约束用接口语法?它和普通接口一样吗? Go 团队复用 interface 关键字降低学习成本,但带类型集(
|、~)的接口只能当约束用,不能作为变量类型:var x Number编译错误。 -
comparable 为什么单独存在? map 的 key、slice 的相等查找都依赖
==。comparable 约束精确表达「这个类型可以当 key」,编译期就挡住拿切片当 key 的代码。 -
泛型会让编译变慢、二进制变大吗? Go 采用 GC shape stenciling(按内存形状共享实例化),膨胀远小于 C++ 模板;普通项目无感知,不必为此回避泛型。
-
什么时候不该用泛型? 只有一种类型却强行泛型化;用泛型模拟继承/方法重载;为了「以后可能支持别的类型」提前抽象。Go 的审美是「等第二个真实用例出现再泛型化」。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
var x Number(类型集约束) | 类型集接口不能当变量类型 | 只在 [T Number] 位置使用 |
func (s *Stack[T]) Convert[U any](...) | 方法不支持新类型参数 | 提成包级函数 func Convert[T,U any](...) |
约束写 int 忘了 ~int | MyInt 等自定义类型被拒 | ~int 表达「底层类型」 |
| 用泛型硬造继承体系 | Go 没有继承,抽象错位 | 组合 + 接口 |
| 约束里写了方法又拿 T 当 map key | 方法不影响比较性 | 需要 key 用 comparable,方法约束分开表达 |
到处 [T any] 却只调用一种类型 | 过度设计,增加阅读成本 | 具体类型直达,出现第二用例再泛型化 |
卡点急救
constraint not satisfied:实参类型不在约束的类型集里。检查~是否遗漏、类型集是否要扩充。cannot use ... as ... value in argument to f:推断出的 T 与另一参数类型不一致,显式写F[int](...)统一。invalid operation: a + b (T is not ...):T 的约束没给运算能力。加运算所需的类型集(数值)或改用函数参数注入行为。- 方法报新类型参数错误:按「讲清楚要点」第 4 条改成包级泛型函数。
面试常问
-
Go 泛型与 Java/C++ 泛型的区别? Java 是类型擦除,运行时无类型信息,基本类型需装箱;C++ 模板对每个类型完整生成代码,编译慢、体积大;Go 按 GC 形状共享实例化(字典 + 部分生成),兼顾编译速度与体积,且约束用接口语法显式声明。
-
comparable 的意义? 表达「支持 ==/!=」,是 map key 的最低要求;没有它,泛型代码里无法对 T 做相等比较,编译器会拒绝。
-
~int是什么意思? 允许「底层类型为 int」的所有命名类型(如type Age int)。类型集里写不带 ~ 的 int 只匹配字面 int。 -
Go 的方法为什么不支持类型参数? 设计权衡:接口的方法集派发与泛型方法组合会导致实现复杂化,官方明确裁剪。解决方式是把该操作提成包级泛型函数。
自测三问
- 写一个泛型函数
Min[T cmp.Ordered],并说明调用Min(2.5, 1.5)时 T 被推断成什么。 type Celsius float64能满足约束~float64吗?能满足float64吗?为什么?- 泛型 Stack 的 Pop 为什么返回
(T, bool)而不是只返回 T?
小结一句话
泛型 = 方括号里的类型参数 + 接口语法的约束(类型集/~ 波浪号/comparable),用来消除同逻辑多类型的重复代码;行为多态交给接口,泛型保持克制。
延伸
- 官方教程:Getting started with generics
- Go Blog: An Introduction To Generics
- 下一课:I/O 与 CLI——用 flag、bufio、encoding/json 写出第一个实用命令行工具。