Go 1.18 引入泛型后,很多之前需要靠 interface{} 加类型断言、或者靠代码生成来规避的重复逻辑,终于可以用类型参数优雅地收敛。但泛型不是银弹,真正有价值的用法,是识别那些“结构完全一样、只是类型不同”的代码,然后用类型参数把它压成一份。
下面通过五个典型案例,展示从重复代码到泛型抽象的完整重构过程。每个案例都保留前后对比,方便你判断自己的项目里是否也存在类似模式。
案例一:通用切片查找与过滤
重构前:为每种元素类型写一个查找函数。
func FindInt(s []int, pred func(int) bool) (int, bool) {
for _, v := range s {
if pred(v) {
return v, true
}
}
return 0, false
}
func FindString(s []string, pred func(string) bool) (string, bool) {
for _, v := range s {
if pred(v) {
return v, true
}
}
return "", false
}
每新增一种类型就要复制一遍,逻辑完全一致。
重构后:用类型参数 T 统一。
func Find[T any](s []T, pred func(T) bool) (T, bool) {
for _, v := range s {
if pred(v) {
return v, true
}
}
var zero T
return zero, false
}
调用时类型自动推导:
n, ok := Find([]int{1, 2, 3}, func(v int) bool { return v > 1 })
name, ok := Find([]string{"a", "b"}, func(v string) bool { return v == "b" })
关键点:返回零值时用 var zero T,不能写 return nil,因为 T 可能是值类型。
案例二:类型安全的 Set 集合
重构前:用 map[string]struct{} 只能存字符串,想存 int 就得再写一套。
type StringSet map[string]struct{}
func (s StringSet) Add(v string) { s[v] = struct{}{} }
func (s StringSet) Has(v string) bool { _, ok := s[v]; return ok }
重构后:泛型 Set,要求键可比较。
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 }
func (s Set[T]) Remove(v T) { delete(s, v) }
func (s Set[T]) Len() int { return len(s) }
使用 comparable 约束而不是 any,因为 map 的键必须可比较。这是泛型约束最典型的应用场景之一。
s := NewSet[int]()
s.Add(1)
s.Add(2)
fmt.Println(s.Has(1)) // true
案例三:通用缓存与并发安全 Map
重构前:为 User、Config 等类型各写一个带 sync.RWMutex 的缓存结构,代码高度重复。
type UserCache struct {
mu sync.RWMutex
m map[string]User
}
重构后:泛型缓存,键值类型都可参数化。
type Cache[K comparable, V any] struct {
mu sync.RWMutex
m map[K]V
}
func NewCache[K comparable, V any]() *Cache[K, V] {
return &Cache[K, V]{m: make(map[K]V)}
}
func (c *Cache[K, V]) Get(k K) (V, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.m[k]
return v, ok
}
func (c *Cache[K, V]) Set(k K, v V) {
c.mu.Lock()
defer c.mu.Unlock()
c.m[k] = v
}
注意 Get 返回的 V 在未命中时是零值,调用方通过 ok 判断。如果 V 是指针类型,零值是 nil,行为符合直觉。
案例四:函数式编程工具(Map / Filter / Reduce)
重构前:对切片做转换时,每种类型组合都要写循环。
func IntsToStrings(nums []int) []string {
out := make([]string, 0, len(nums))
for _, n := range nums {
out = append(out, strconv.Itoa(n))
}
return out
}
重构后:泛型 Map,输入输出类型分离。
func Map[T, U any](s []T, f func(T) U) []U {
out := make([]U, 0, len(s))
for _, v := range s {
out = append(out, f(v))
}
return out
}
func Filter[T any](s []T, pred func(T) bool) []T {
out := make([]T, 0, len(s))
for _, v := range s {
if pred(v) {
out = append(out, v)
}
}
return out
}
调用链清晰:
names := Map([]int{1, 2, 3}, strconv.Itoa) // []string{"1","2","3"}
evens := Filter([]int{1, 2, 3, 4}, func(n int) bool { return n%2 == 0 })
一个实践建议:Map 的容量预分配用 len(s),避免多次扩容。这是泛型工具函数容易忽略的性能细节。
案例五:带约束的数值运算
重构前:求和、求最大值等运算,int 和 float64 各写一份。
func SumInts(nums []int) int {
var sum int
for _, n := range nums { sum += n }
return sum
}
重构后:用联合类型约束 ~int | ~float64。~ 表示底层类型匹配,这样自定义的 type MyInt int 也能用。
type Number interface {
~int | ~int64 | ~float64
}
func Sum[T Number](nums []T) T {
var sum T
for _, n := range nums {
sum += n
}
return sum
}
func Max[T Number](nums []T) T {
if len(nums) == 0 {
var zero T
return zero
}
m := nums[0]
for _, n := range nums[1:] {
if n > m {
m = n
}
}
return m
}
约束是泛型的核心。没有约束,sum += n 无法编译,因为 any 类型不支持 +。约束既保证了类型安全,又让编译器能生成高效代码。
何时不该用泛型
泛型不是万能重构工具。以下情况建议保持原样:
- 只有一两个具体类型使用:抽象成本高于收益。
- 逻辑依赖具体类型的方法:约束写起来会比原代码更复杂。
- 性能敏感的热路径:泛型可能引入接口装箱或单态化不足,需实测。
- 用
interface{}加类型断言已经足够清晰:不要为了泛型而泛型。
总结
这五个案例的共同特征是:算法结构固定,仅类型变化。识别这种模式,是决定是否引入泛型的关键。
- 切片查找/过滤 →
[T any] - Set / Map 键 →
[T comparable] - 缓存 →
[K comparable, V any] - 函数式工具 →
[T, U any] - 数值运算 → 联合类型约束
~int | ~float64
Go 泛型的约束系统设计得相对克制,这反而让代码更可预测。重构时建议从最小的工具函数开始,逐步替换,而不是一次性重写整个模块。先用泛型收敛重复,再用测试保证行为一致,才是稳妥的落地路径。
未经允许不得转载:任鹏个人博客 » Go 泛型实战:用类型参数重构重复代码的五个典型案例


朋友圈点赞图在线生成源码