Go 泛型实战:用类型参数重构重复代码的五个典型案例

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

重构前:为 UserConfig 等类型各写一个带 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 泛型实战:用类型参数重构重复代码的五个典型案例

赞 (0) 打赏

评论 0

取消
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏