Go 泛型在生产中的实践——哪些地方该用,哪些地方用了反而是负担
Go 泛型在生产中的实践——哪些地方该用,哪些地方用了反而是负担
适读人群:Go 1.18+,想在实际项目中用好泛型的工程师 | 阅读时长:约15分钟 | 核心价值:不是泛型教程,是泛型在生产项目中的正确使用边界
先说我踩过的坑
Go 1.18 发布泛型支持的时候,我大概是最兴奋的那批人之一。Java 用泛型用了这么多年,终于 Go 也有了。然后我做了一件很蠢的事:把项目里几乎所有能用泛型的地方都改成了泛型。
然后代码 review 的时候被骂了。
同事说:"你这个 Repository[T] 泛型抽象,反而比之前更难读。"
我当时还不服气,觉得泛型可以复用代码嘛。花了一周时间冷静下来重新审视,才发现自己确实用力过猛了。
这篇文章就是把我的反思写出来:Go 泛型的合理使用场景,以及那些看起来可以用泛型但实际上是负担的场景。
泛型真正解决了什么
在没有泛型的年代,Go 有两种方式处理"对不同类型做相同操作"的需求:
- 为每种类型各写一个函数(重复代码)
- 用
interface{}+ 类型断言(运行时类型检查,不安全)
泛型解决的是:在保留编译时类型安全的前提下,写出可以作用于多种类型的代码。
这个定义本身就告诉了我们泛型的适用场景:当你需要对多种具体类型执行相同的逻辑,而这些类型在编译时是确定的。
真正适合泛型的场景
场景一:容器和数据结构
标准库的 slices、maps 包就是最好的例子。
package collection
import "cmp"
// Filter 过滤切片,返回满足条件的元素
// 这是泛型的经典用法:对任意类型的切片执行相同的过滤逻辑
func Filter[T any](slice []T, predicate func(T) bool) []T {
result := make([]T, 0, len(slice)/2)
for _, v := range slice {
if predicate(v) {
result = append(result, v)
}
}
return result
}
// Map 将切片中的每个元素转换为另一种类型
func Map[T, U any](slice []T, transform func(T) U) []U {
result := make([]U, len(slice))
for i, v := range slice {
result[i] = transform(v)
}
return result
}
// Reduce 将切片归约为单个值
func Reduce[T, U any](slice []T, initial U, f func(U, T) U) U {
acc := initial
for _, v := range slice {
acc = f(acc, v)
}
return acc
}
// Contains 检查切片是否包含某个元素(需要可比较类型)
func Contains[T comparable](slice []T, target T) bool {
for _, v := range slice {
if v == target {
return true
}
}
return false
}
// Unique 去重
func Unique[T comparable](slice []T) []T {
seen := make(map[T]struct{}, len(slice))
result := make([]T, 0, len(slice))
for _, v := range slice {
if _, exists := seen[v]; !exists {
seen[v] = struct{}{}
result = append(result, v)
}
}
return result
}
// MinBy 按某个 key 找最小值
func MinBy[T any, K cmp.Ordered](slice []T, key func(T) K) (T, bool) {
if len(slice) == 0 {
var zero T
return zero, false
}
min := slice[0]
minKey := key(min)
for _, v := range slice[1:] {
if k := key(v); k < minKey {
min = v
minKey = k
}
}
return min, true
}这些函数在项目里被大量使用,减少了很多重复的遍历代码,而且完全类型安全。
场景二:结果类型(Option/Result 模式)
Go 没有原生的 Option 或 Result 类型,用泛型可以实现一个比 (T, error) 更语义清晰的结果类型:
package result
// Result 封装一个操作的结果,要么是值,要么是错误
type Result[T any] struct {
value T
err error
ok bool
}
// Ok 创建一个成功的 Result
func Ok[T any](value T) Result[T] {
return Result[T]{value: value, ok: true}
}
// Err 创建一个失败的 Result
func Err[T any](err error) Result[T] {
return Result[T]{err: err}
}
// Unwrap 获取值,如果是错误则 panic(类似 Rust 的 unwrap)
func (r Result[T]) Unwrap() T {
if !r.ok {
panic(r.err)
}
return r.value
}
// UnwrapOr 获取值,如果是错误则返回默认值
func (r Result[T]) UnwrapOr(defaultValue T) T {
if !r.ok {
return defaultValue
}
return r.value
}
// IsOk 判断是否成功
func (r Result[T]) IsOk() bool {
return r.ok
}
// Err 返回错误
func (r Result[T]) Error() error {
return r.err
}不该用泛型的场景(这才是重点)
误区一:泛型 Repository
这是我最初犯的错,也是最常见的误用之一:
// 看起来很美,实际上是负担
type Repository[T any] interface {
FindByID(ctx context.Context, id string) (T, error)
FindAll(ctx context.Context) ([]T, error)
Save(ctx context.Context, entity T) error
Delete(ctx context.Context, id string) error
}为什么这是负担:
- 不同类型的 FindAll 往往有不同的查询条件(用户有分页、有按状态过滤,订单有按日期范围查询……)
- 泛型约束写起来越来越复杂,最终往往需要在接口上打一堆补丁方法
- 代码可读性下降——看到
Repository[User]的时候,不如直接看UserRepository清晰
结论: Repository 的接口应该根据实际需要定义,而不是为了"通用性"强行泛型化。
误区二:业务逻辑层用泛型
// 我当时写的,现在觉得很奇怪
func ProcessEntity[T Processable](ctx context.Context, entity T) error {
// 处理逻辑
}业务逻辑通常是针对特定类型的,强行泛型化会让代码失去表达力。当你需要为 User 和 Order 写不同的处理逻辑时,泛型帮不了你——你最终还是要写两个实现。
误区三:为了复用 HTTP handler 逻辑而泛型化
// 有人会想这么写
func GenericCreateHandler[T any, R any](
svc func(context.Context, T) (R, error),
) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req T
// ...
}
}这种模式在简单场景下看起来不错,但一旦不同接口有不同的权限验证、不同的参数校验逻辑,这个泛型 handler 就要接受越来越多的配置参数,最终变成一个不可维护的怪物。
踩坑实录
坑一:约束写错了,编译错误信息极难看懂
现象: 泛型约束写了一半,编译器报了一大堆莫名其妙的错误,比 Go 的普通错误难看懂10倍。
原因: Go 的泛型错误信息不够友好,类型推断失败的时候报的是内部类型名,不是你写的类型名。
解法: 泛型接口和约束尽量简单,复杂约束分步定义并加注释。写单元测试来验证约束是否正确。
坑二:泛型函数不能有方法集
现象: 想给泛型类型加一些便捷方法,发现 Go 不允许在泛型函数上定义方法。
// 这是不合法的 Go 代码
func (r Result[T]) AndThen[U any](f func(T) Result[U]) Result[U] {
// ...
}解法: 只能用普通函数代替:
func AndThen[T, U any](r Result[T], f func(T) Result[U]) Result[U] {
if !r.ok {
return Err[U](r.err)
}
return f(r.value)
}坑三:泛型代码的 IDE 支持还不完美
现象: VSCode 里,泛型函数的代码补全偶尔不工作,类型参数推断的提示也不总是准确。
解法: 这是工具链的问题,等 gopls 版本更新。目前建议泛型接口不要嵌套太深。
结论:泛型的三个使用原则
算法 > 数据结构 > 业务逻辑:泛型最适合算法(sort、search)和数据结构(Set、Queue),其次是通用数据结构(Result、Optional),不适合业务逻辑层
先写具体实现,后提炼泛型:不要一开始就设计泛型,先写两三个具体实现,等你发现真正的重复之后,再考虑是否用泛型提炼
如果加了泛型让代码更难读,就不要加:这条没有例外
