Go 泛型约束设计 把接口当谓词来写
Go 1.18 引入泛型后,大量代码变成了两种极端:一种是"能不用就不用",一种是到处 T any 结果失去类型安全。真正好用的关键在约束设计——约束不是"参数类型标注",而是"编译期对类型集合的谓词"。本文给出可复用的设计套路。
一、约束的四种表达层次
// 1) 预声明:够用就用
func Max[T cmp.Ordered](a, b T) T { if a > b { return a }; return b }
// 2) 类型集合(union):限定具体类型
type Number interface{ ~int | ~int64 | ~float64 }
func Sum[T Number](xs []T) T { var s T; for _, x := range xs { s += x }; return s }
// 3) 隐式接口:按方法签名匹配,最常用
type Stringer interface{ String() string }
func Join[T Stringer](xs []T, sep string) string
// 4) 自引用约束:返回自身类型,用于链式/比较
type Equal[T any] interface{ Equal(T) bool }
func Contains[T Equal[T]](xs []T, v T) bool
第四条最被低估:Equal[T] 让元素具备"与自己同类型比较"的能力,比依赖 ==(结构体里有 slice/map 字段会 panic)安全得多。
二、~ 与 any 的取舍
~int 匹配所有底层类型为 int 的类型(含自定义 type ID int)。不用 ~ 时自定义类型会被拒绝:
type UserID int64
func Sum[T cmp.Ordered](x, y T) T // UserID 可用(Ordered 已含 ~)
func Add[T int](x, y T) T // UserID 传不进来
T any 只在两种情况下合理使用:容器类型(Stack[T])、以及仅做透传不做运算的逻辑。除此之外写 any 基本等于放弃泛型的价值。
三、一个真实场景:批量落库的 idiom
不用泛型的写法要为每种实体复制一份:
func InsertUsers(db *sql.DB, xs []User) error { /* ... */ }
func InsertOrders(db *sql.DB, xs []Order) error { /* ... */ }
抽象出"可入库"谓词:
type Row interface {
Table() string
Values() []any
}
func InsertMany[T Row](ctx context.Context, db *sql.DB, xs []T) error {
if len(xs) == 0 {
return nil
}
var b strings.Builder
args := make([]any, 0, len(xs)*8)
first := xs[0]
cols := Columns(first)
b.WriteString("INSERT INTO ")
b.WriteString(first.Table())
b.WriteString(" (")
b.WriteString(strings.Join(cols, ","))
b.WriteString(") VALUES ")
for i, x := range xs {
if i > 0 { b.WriteString(",") }
ph := make([]string, 0, len(cols))
vals := x.Values()
for j, v := range vals {
ph = append(ph, fmt.Sprintf("$%d", i*len(cols)+j+1))
args = append(args, v)
}
b.WriteString("(" + strings.Join(ph, ",") + ")")
}
_, err := db.ExecContext(ctx, b.String(), args...)
return err
}
Row 是"可入库"这一能力的谓词,任何实体实现两个方法即可复用批量插入。注意这里不能把 Values() 做成反射读字段——一旦引入反射,泛型的显式性优势就没了。
四、泛型的边界:什么时候别用
三条判据:
- 只需要一个
interface{}+ 类型断言,且实现者远少于调用者 → 用普通接口; - 需要运行时才知道类型(如 JSON 解码到未知结构)→ 泛型帮不上;
- 逻辑本身有分支处理不同类别 → 用接口多态,别在泛型函数里写
if判类型。
Go 的官方立场很清楚:泛型用于算法复用(map/filter/sort/容器),不用于建模业务实体。
func MapSlice[T, U any](xs []T, f func(T) U) []U {
out := make([]U, 0, len(xs))
for _, x := range xs { out = append(out, f(x)) }
return out
}
func FilterSlice[T any](xs []T, ok func(T) bool) []T {
out := make([]T, 0, len(xs)/4)
for _, x := range xs { if ok(x) { out = append(out, x) } }
return out
}
标准库 slices、maps、cmp 已经提供大部分能力,写自己版本前先查一遍。
五、错误处理与泛型的配合
Go 没有 Result<T, E>,但用多返回值 + 泛型可以做出干净管道:
func Try[T any](f func() (T, error)) Result[T] {
v, err := f()
return Result[T]{v: v, err: err}
}
type Result[T any] struct {
v T
err error
}
func (r Result[T]) Unwrap() T { if r.err != nil { panic(r.err) }; return r.v }
func (r Result[T]) Err() error { return r.err }
func (r Result[T]) Ok() bool { return r.err == nil }
注意 Go 社区对此有争议:库代码里慎用 Try 命名和 panic 语义,容易与 1.22 的内置 min/max 及未来的 try 表达式撞车。生产里更保守的做法是普通泛型函数返回 (T, error)。
六、编译期约束带来的性能红利
非泛型接口方案要走 interface{} → 装箱 → 逃逸到堆。泛型编译期单态化(对相同类型复用同一份代码),避免装箱:
// 装箱:每次调用可能触发堆分配
func SumAny(xs []any) any { var s float64; for _, x := range xs { s += x.(float64) }; return s }
// 泛型:栈上操作,无反射无断言
func SumFloat[T ~float32 | ~float64](xs []T) T { var s T; for _, x := range xs { s += x }; return s }
判断方法仍然是 go test -bench -benchmem,别凭感觉。实测热路径上泛型版本可以少掉一次装箱和断言开销。
小结
设计泛型时先问自己:"这个参数必须满足什么能力?"把答案写成接口约束,而不是把类型写死。四个层次——预声明约束、类型集合、隐式方法接口、自引用约束——按需要选最低够用的一层。同时守住边界:业务建模用接口多态,算法与容器才用泛型。最后,能用标准库 slices/maps/cmp 解决的,不要自己写第二遍。
评论(0)
登录 后参与评论。