主题
Go 泛型:同一套算法,怎样保留不同的具体类型
统计一组字符串出现的次数,和统计一组用户 ID 出现的次数,循环几乎一样:取出元素,把它在 map 里的计数加一。复制两份函数容易漏改;全部换成 any,又会让调用者失去“输入什么类型,就得到什么类型的键”这条关系。
泛型让函数把这条关系写进签名,由编译器检查。本文用计数、筛选和一个小容器说明类型参数、约束、comparable 和类型推断,再讨论什么时候普通接口更合适。这里按 Go 1.27.2 讲解,特别核对了 Go 1.27 新增的泛型方法;不能再照搬早期教程里“方法永远不能有自己的类型参数”的说法。[1][2]
正文输出来自本次 Linux/amd64 实跑。完整代码、测试和 7 个故意编译失败的反例都放在文末。
先写出一段真正需要复用的算法
下面的函数统计每个元素出现几次:
go
func Count[T comparable](xs []T) map[T]int {
out := make(map[T]int)
for _, x := range xs {
out[x]++
}
return out
}
fmt.Println(Count([]string{"go", "rust", "go"}))text
map[go:2 rust:1]签名里有两组参数。[T comparable] 声明类型参数,(xs []T) 声明普通的值参数。调用时,这里推断出 T 是 string,结果类型就是 map[string]int。换成 []UserID,结果就变成 map[UserID]int,无需把键再从 any 断言回来。[3]
comparable 不是给读者看的注释。算法要把元素当 map 键,约束必须允许这个操作;若写成 T any,编译器不能保证每一种 T 都适合作为键。先从函数体实际要做的操作推导约束,签名会更容易解释。[4]
| 泛型写法中的部分 | 它回答的问题 | 这个例子里的答案 |
|---|---|---|
T | 哪个类型暂时还没确定 | 输入元素、输出 map 键使用同一个类型 |
comparable | 可以代入什么类型 | 满足可比较约束的类型 |
[]T | 输入值是什么形状 | 一组 T 类型元素 |
map[T]int | 结果与输入有什么关系 | 输入元素类型继续作为键,值是计数 |
Count[string] | 类型具体确定后得到什么 | 可调用的函数,参数为 []string |
把类型实参代入泛型声明并检查约束,叫实例化。Count[string] 已经得到一个可用的函数值,随后才是传入切片执行调用。泛型不是让每个元素在运行时自由选择 T;同一次实例化里的 T 已经确定。[3:1]
约束既筛选类型,也限制函数体能做什么
约束用接口表示。普通接口可以要求方法,例如 io.Writer 要求 Write;约束还可以写出允许的类型集合。先看常见的几种:[4:1]
| 约束 | 允许范围 | 可以据此做什么,不能推导什么 |
|---|---|---|
any | 不额外限制类型 | 可以传递、保存 T,不能因此使用 + 或 == |
comparable | 满足可比较约束的类型 | 可以 ==、!=,也可作 map 键;不等于能用 < 排序 |
interface{ int64 } | 类型 int64 本身,及它的别名 | 不自动包括新定义的 UserID |
interface{ ~int64 } | 底层类型为 int64 的类型 | 包括 int64 和 type UserID int64 |
interface{ ~int | ~int64 } | 两组类型的并集 | 可对这一组类型使用整数加法 |
io.Writer | 具有约定 Write 方法的类型 | 可调用 Write,不因此拥有具体类型的其他方法 |
cmp.Ordered | 有序的整数、浮点数和字符串类型 | 可使用有序比较;浮点 NaN 仍须单独理解 |
为了让例子范围清楚,下面只支持底层类型是 int 或 int64 的类型,未声称包含全部整数类型:
go
type IntOrInt64 interface { ~int | ~int64 }
type UserID int64
func Sum[T IntOrInt64](xs ...T) T {
var out T
for _, x := range xs { out += x }
return out
}
n := Sum(UserID(2), UserID(3))
fmt.Printf("%T %v\n", n, n)text
main.UserID 5out 的零值随 T 确定;在这组整数类型里它就是 0。+ 对约束允许的类型都合法,所以函数体可以使用它。约束若改为 any,就不成立了:T 也可能是结构体,编译器不能因为当前碰巧传了整数,就忽略声明允许的其他类型。文末反例的真实诊断是:[4:2][5]
text
invalid operation: operator + not defined on a (variable of type T constrained by any)cmp.Ordered 位于 Go 1.21 起的标准库 cmp,新代码不必仅为了它去依赖早期教程中的实验性 constraints 包。选择约束时仍要看业务语义:字符串支持 + 和有序比较,不表示它应该被纳入一个“计算金额总和”的函数。[6]
int64 和 ~int64 差在是否接受业务定义的类型
type UserID int64 声明了一个新类型;type Alias = int64 才是给原类型起别名。因而没有 ~ 的约束不接受 UserID,加入 ~ 才会接纳底层类型相同的定义类型。[7]
正在绘制图示…
查看图源代码
flowchart LR
A["int64"] --> E["int64 类型项:只含该类型"]
A --> F["~int64:底层类型为 int64 的集合"]
B["UserID,声明为 type UserID int64"] --> F
F --> U["~int | ~int64:两组集合的并集"]
C["~int:底层类型为 int 的集合"] --> U
实际反例把 UserID(1) 传给 func exact[T int64](x T) T,编译器拒绝:
text
UserID does not satisfy int64 (possibly missing ~ for int64 in int64)多个类型项用 | 连接,表示并集;同一个接口里的多个要求则同时成立,相当于取交集。例如 interface { ~int64; String() string } 要求既以 int64 为底层类型,又有 String 方法。~ 也不是可以随便加的修饰符:其后类型的底层类型必须是它自身,不能因为 UserID 的底层是 int64 就写 ~UserID。多项 union 的非接口类型项还必须互不重叠,~int | int 就有重叠。复杂约束报错时,先把集合关系画清楚。[8]
约束接口不一定能用来保存普通值
只由方法定义的基本接口,比如 io.Writer,既可以作普通变量的类型,也能用作约束。包含 ~int、类型 union 或 comparable 这类非基本接口,只能用于约束位置,不能写成 var n IntOrInt64 来装某个整数。[8:1]
实际编译结果:
text
cannot use type Number outside a type constraint: interface contains type constraints如果需要在运行时保存不同实现,用普通接口;如果需要在编译时约束可代入的类型,用类型参数。两种机制相关,但使用位置不同。不要把“类型集合”想成一个自动支持所有集合成员的运行时联合值。
~[]E 为什么经常出现在标准库签名里
筛选切片时,往往既想复用循环,又想保留业务类型名。假设 IDs 是 []UserID 的定义类型:
go
type IDs []UserID
func Filter[S ~[]E, E any](src S, keep func(E) bool) S {
var out S
for _, x := range src {
if keep(x) { out = append(out, x) }
}
return out
}这两个类型参数各有职责:
| 参数 | 从什么地方确定 | 保留什么信息 |
|---|---|---|
S | 调用时传入的整个 slice 类型 | 结果仍然叫 IDs |
E | S 的底层 slice 元素类型 | 回调接收 UserID |
如果只写 func FilterPlain[E any](src []E, keep func(E) bool) []E,算法仍能工作,但返回类型是普通的 []E。实际对照:[9]
go
ids := IDs{1, 2, 3, 4}
keep := func(id UserID) bool { return id%2 == 0 }
a, b := Filter(ids, keep), FilterPlain(ids, keep)
fmt.Printf("%T %v\n", a, a)
fmt.Printf("%T %v\n", b, b)text
main.IDs [2 4]
[]main.UserID [2 4]元素相同,结果类型不同。如果 IDs 有自己的方法,保留类型名可以让返回值继续拥有那组方法。这也是 slices.Clone 采用 S ~[]E 一类签名的原因。没有这项需求时,不必为了显得通用而把每个函数都写成更长的形式。[9:1]
这个 Filter 从 nil 的输出 slice 开始追加,不修改输入的底层数组;没有元素通过时返回 nil。这是此处算法的行为,不是泛型自动提供的承诺。元素若是指针、slice 或 map,输出仍保存相应的值副本,不会自动深拷贝它们指向的数据。类型参数能保留类型关系,所有权和复制策略仍需要函数自己约定。[10]
comparable 允许比较,不代表所有运行时值都不会出问题
Count 使用 map[T]int,所以选择 comparable。整数、字符串、指针可以作为键;数组和结构体能否作为键取决于其成员类型;slice、map 和函数不能直接作键。comparable 允许相等比较,却不赋予排序关系,例如结构体可比较时也不意味着能写 a < b。[11]
这里最容易漏掉的是接口。自 Go 1.20 起,any 可以满足 comparable 约束,所以这段代码可以编译运行:
go
fmt.Println(len(Count([]any{1, "one", 1})))text
2T 在这里确定为 any,每个接口值又分别保存一个动态类型和值。整数和字符串作为这些动态值没有问题;如果放入一个 slice,运行时拿它做 map 键时就会 panic:
go
_ = Count([]any{[]int{1}})本次用一个外层延迟函数捕获这次预期 panic,输出:
text
slice key panics: true这正是“满足 comparable 约束”与“所有动态值都严格可比较”之间的区别。Go 1.20 的规则允许这种接口类型实参,不会把 slice 本身变成合法键。接收不受控的 []any 时,要提前限制元素类型,或把元素转成明确的业务键,不能把编译通过当作运行时安全保证。[11:1]
浮点数还有另一种问题,它不 panic,却可能不符合你想要的“相同”:
go
nan := math.NaN()
fmt.Println(nan == nan, len(Count([]float64{nan, nan})))text
false 2NaN 不等于自身,所以这里的两个 NaN 成了两个 map 条目。如果业务想把所有 NaN 归为一类,应先归一化或设计自己的键表示。泛型让操作合法,不替业务决定“什么算同一项”。[12]
类型推断省掉重复信息,但仍需要足够的线索
调用 Filter(ids, keep) 时,编译器从 ids 得出 S 是 IDs,再根据 S ~[]E 得到 E 是 UserID。因此不用重复写 Filter[IDs, UserID](...)。也可以显式提供从左开始的一部分类型实参,例如 Filter[IDs](...),剩下的继续推断。不能跳过前面的参数,只点名填中间某一项。[13]
| 场景 | 写法 | 能否推断 |
|---|---|---|
| 值参数带有类型信息 | Count([]string{"go"}) | 可以,T 是 string |
| 约束关联多个类型参数 | Filter(ids, keep) | 可以,从 S 的底层类型得到 E |
| 类型只出现在调用结果 | var n int = Zero() | 这里不可以,仅靠结果接收变量不够 |
| 显式给出缺少的类型 | Zero[int]() | 可以 |
| 泛型函数赋给已知函数类型 | var f func(int) int = Identity | 可以,实例化成对应函数 |
| 使用泛型类型的复合字面量 | Box{Value: 42} | 不可以,需写 Box[int]{Value: 42} |
第三行的函数定义是:
go
func Zero[T any]() T { var z T; return z }var n int = Zero() 的真实诊断为 cannot infer T。应写 Zero[int]()。这里讨论的是调用结果,不要据此记成“Go 从不利用上下文推断”。函数值本身的赋值或转换,是另一类上下文:[13:1]
go
func Identity[T any](x T) T { return x }
type IntFunc func(int) int
f := Identity[int]
var g func(int) int = Identity
h := IntFunc(Identity)
fmt.Println(f(7), g(8), h(9))text
7 8 9第一行显式实例化,第二种写法从目标函数类型推断。Go 1.27 把这类推断推广到泛型函数赋值或转换到匹配函数类型的各个上下文,最后的转换写法已在本次版本验证。既要知道当前版本能做什么,也要区分它和无法推断的 Zero() 调用。[2:1][13:2]
泛型类型有自己的方法,Go 1.27 又多了一种能力
泛型不只用于函数。下面的 Box[T] 保存一个 T 类型值:
go
type Box[T any] struct { Value T }
func (b Box[T]) Get() T { return b.Value }Box[int] 和 Box[string] 是不同的实例化类型。使用时写明类型实参;接收者里的 [T] 则让方法可以引用容器的元素类型。Get 本身没有额外的类型参数。[14]
Go 1.27 起,方法还可以声明自己的类型参数,适合把“把容器里的 T 转成 U”放在这个类型下面:
go
func (b Box[T]) Map[U any](f func(T) U) Box[U] {
return Box[U]{Value: f(b.Value)}
}
converted := Box[int]{Value: 42}.Map(strconv.Itoa)
fmt.Printf("%T %q\n", converted, converted.Get())text
main.Box[string] "42"T 来自接收者 Box[int],U 从传入的 func(int) string 推断出来,结果是 Box[string]。这段代码用 Go 1.27.2 编译运行;项目如果仍要求 Go 1.26 或更早,可以把转换写成包级泛型函数,不要直接引入新方法语法。[2:2][15]
新能力仍有边界:
| 写法 | Go 1.27 的状态 |
|---|---|
| 具体类型的方法声明自己的类型参数 | 支持 |
| 泛型类型的普通方法使用接收者类型参数 | 支持 |
| 接口中的方法声明自己的类型参数 | 不支持 |
| 用泛型方法实现接口中的某个方法 | 不支持 |
匿名函数字面量写成 func[T any](x T) T { ... } | 不支持 |
文末反例实际验证了接口泛型方法和泛型匿名函数的拒绝结果。普通方法当然仍可以参与接口实现;受限的是方法额外声明类型参数这种形式,不要把它扩大成“泛型类型不能实现接口”。[15:1][16]
何时用泛型,何时用接口
类型参数和普通接口可以协作,但解决的侧重点不同。选择时可以先问:调用者是否需要保留具体类型之间的关系?还是函数只关心几个动作?[17]
| 需求 | 通常从什么方案开始 | 为什么 |
|---|---|---|
| 对多种元素类型使用相同算法 | 泛型函数 | 避免重复,输入输出类型关系清楚 |
| 写类型安全的容器、树或集合 | 泛型类型 | 放进去和取出来的类型由编译器检查 |
| 接受文件、缓冲区等“能写”的对象 | io.Writer 等小接口 | 只依赖行为,没有额外类型关系要保存 |
| 一组数据中混合不同实现 | 普通接口值 | 支持运行时的不同动态类型 |
| 同一元素类型有多种排序或过滤规则 | 函数参数,必要时配合泛型 | 把规则直接交给算法,不强迫元素实现固定方法 |
| 只有一份短而清楚的具体实现 | 先保留具体类型 | 不先为尚未出现的复用写复杂约束 |
例如只想把文本写出去:
go
func emit(w io.Writer, s string) error {
_, err := io.WriteString(w, s)
return err
}
var out bytes.Buffer
if err := emit(&out, "hello"); err != nil { panic(err) }
fmt.Println(out.String())text
helloemit 只需要 Write 能力。即使改成 emit[T io.Writer],这里也没有借 T 保留有价值的输入输出关系。普通接口已经把依赖说清楚,调用者无需理解额外类型参数。反过来,Filter 要返回与输入相同的 S,Box.Map 要表达 T 到 U 的关系,泛型的收益就很具体。[17:1][18]
最后别把泛型当作性能开关。语言规定类型检查与程序含义,没有承诺“使用泛型一定比接口快”或“绝不分配”。是否能内联、是否发生分配、代码体积怎样,都要在目标工具链与真实调用方式下测量。本文没有做泛型/接口性能基准,也没有给出速度倍数。先用合适的抽象把代码写清楚,只有性能证据指向它时才进一步优化。[17:2]
依据与验证范围
2026-10-13 在 Go 1.27.2、Linux/amd64 实际执行:
go run .:验证计数、定义类型求和、保留/不保留 slice 类型的对照、零值、函数推断、泛型方法、接口动态键的 panic、NaN 以及普通 Writer 接口。go test -count=1 -v .:4 项测试通过,覆盖上述核心结果及 Filter 不复用输入底层数组的约定。go test -race -count=1 .、go vet .:通过。这组测试未并发访问 map,不能推出 Count 的结果可被无同步地并发写入。- 7 个
testdata反例逐个编译,均按预期失败,分别对应:any 加法、缺少~、约束接口作值、结果类型不足以推断、泛型匿名函数、接口泛型方法、泛型类型未实例化。
所有源码都只用标准库,没有外部依赖。没有运行 Go 1.26 工具链做双版本对照;版本分界依据官方发布说明,当前版本行为以 Go 1.27.2 实测核对。
完整可复现代码
建立下列目录与文件,使用 Go 1.27 或更高版本。正常样例与故意失败的编译样例分开执行;不要把失败的退出码解释为主程序出错。
sh
go version
go run .
go test -count=1 -v .
go test -race -count=1 .
go vet .
# 每条都应失败;具体行号随排版变化
for name in anyadd exact interfacevalue inferresult literal interfacemethod typeinfer; do
go build ./testdata/$name
echo "exit=$?"
donego.mod
go.mod
module example.com/go-generics
go 1.27.0main.go
代码块 · 79 行 · 点击收起 / 展开
go
package main
import (
"bytes"
"fmt"
"io"
"math"
"strconv"
)
type UserID int64
type IDs []UserID
type IntOrInt64 interface{ ~int | ~int64 }
func Sum[T IntOrInt64](xs ...T) T {
var out T
for _, x := range xs {
out += x
}
return out
}
func Count[T comparable](xs []T) map[T]int {
out := make(map[T]int)
for _, x := range xs {
out[x]++
}
return out
}
func Filter[S ~[]E, E any](src S, keep func(E) bool) S {
var out S
for _, x := range src {
if keep(x) {
out = append(out, x)
}
}
return out
}
func FilterPlain[E any](src []E, keep func(E) bool) []E { return Filter(src, keep) }
func Zero[T any]() T { var z T; return z }
func Identity[T any](x T) T { return x }
type IntFunc func(int) int
type Box[T any] struct{ Value T }
func (b Box[T]) Get() T { return b.Value }
func (b Box[T]) Map[U any](f func(T) U) Box[U] { return Box[U]{Value: f(b.Value)} }
func emit(w io.Writer, s string) error { _, err := io.WriteString(w, s); return err }
func dynamicKeyPanics() (panicked bool) {
defer func() { panicked = recover() != nil }()
_ = Count([]any{[]int{1}})
return false
}
func main() {
fmt.Println("counts:", Count([]string{"go", "rust", "go"}))
fmt.Printf("sum: %T %v\n", Sum(UserID(2), UserID(3)), Sum(UserID(2), UserID(3)))
ids := IDs{1, 2, 3, 4}
keep := func(id UserID) bool { return id%2 == 0 }
got, plain := Filter(ids, keep), FilterPlain(ids, keep)
fmt.Printf("filter: %T %v\n", got, got)
fmt.Printf("plain: %T %v\n", plain, plain)
fmt.Println("zero:", Zero[int](), Zero[string]() == "")
f := Identity[int]
var g func(int) int = Identity
h := IntFunc(Identity)
fmt.Println("function inference:", f(7), g(8), h(9))
b := Box[int]{Value: 42}
converted := b.Map(strconv.Itoa)
fmt.Printf("generic method: %T %q\n", converted, converted.Get())
fmt.Println("mixed keys:", len(Count([]any{1, "one", 1})))
fmt.Println("slice key panics:", dynamicKeyPanics())
nan := math.NaN()
fmt.Println("NaN:", nan == nan, len(Count([]float64{nan, nan})))
var out bytes.Buffer
if err := emit(&out, "hello"); err != nil {
panic(err)
}
fmt.Println("writer:", out.String())
}main_test.go
代码块 · 51 行 · 点击收起 / 展开
go
package main
import (
"math"
"reflect"
"strconv"
"testing"
)
func TestCount(t *testing.T) {
if got := Count([]int{2, 1, 2}); !reflect.DeepEqual(got, map[int]int{1: 1, 2: 2}) {
t.Fatal(got)
}
if got := Count([]string(nil)); len(got) != 0 {
t.Fatal(got)
}
if !dynamicKeyPanics() {
t.Fatal("slice dynamic key must panic")
}
nan := math.NaN()
if len(Count([]float64{nan, nan})) != 2 {
t.Fatal("NaN is not equal to itself")
}
}
func TestFilter(t *testing.T) {
xs := IDs{1, 2, 3, 4}
var got IDs = Filter(xs, func(x UserID) bool { return x%2 == 0 })
if !reflect.DeepEqual(got, IDs{2, 4}) {
t.Fatal(got)
}
got[0] = 99
if xs[1] != 2 {
t.Fatal("filter must not reuse the source array in this implementation")
}
}
func TestGenericMethodAndInference(t *testing.T) {
b := Box[int]{Value: 42}.Map(strconv.Itoa)
if b.Get() != "42" {
t.Fatal(b)
}
var g func(int) int = Identity
h := IntFunc(Identity)
if g(8) != 8 || h(9) != 9 {
t.Fatal("function inference")
}
}
func TestSum(t *testing.T) {
if Sum(UserID(2), UserID(3)) != UserID(5) || Sum[int]() != 0 {
t.Fatal("sum")
}
}testdata/anyadd/main.go
go
package main
func add[T any](a, b T) T { return a + b }
func main() {}testdata/exact/main.go
go
package main
type UserID int64
func exact[T int64](x T) T { return x }
func main() { _ = exact(UserID(1)) }testdata/interfacevalue/main.go
go
package main
type Number interface { ~int | ~int64 }
func main() { var n Number; _ = n }testdata/inferresult/main.go
go
package main
func Zero[T any]() T { var z T; return z }
func main() { var n int = Zero(); _ = n }testdata/literal/main.go
go
package main
func main() { _ = func[T any](x T) T { return x } }testdata/interfacemethod/main.go
go
package main
type Mapper interface { Map[T any](T) T }
func main() {}testdata/typeinfer/main.go
go
package main
type Box[T any] struct { Value T }
func main() { _ = Box{Value: 42} }Go 官方:An Introduction to Generics,泛型在 Go 1.18 加入,以及类型参数的基本含义。 ↩︎
Go 1.27 发布说明:语言变化,泛型方法、接口限制和函数类型推断扩展。 ↩︎ ↩︎ ↩︎
cmp.Ordered,Go 1.27.2,同时以官方发行包中的go doc cmp.Ordered及api/go1.21.txt核对。 ↩︎规范:slice 类型、append。
Filter的具体分配与 nil 行为来自文末实现,不作为其他泛型函数的承诺。 ↩︎Go 官方:When To Use Generics。文中具体 API 选择和是否值得抽象是工程建议,不是语言强制要求。 ↩︎ ↩︎ ↩︎