• 在Go中使用泛型时该如何设计和使用约束?
  • 发布于 4小时前
  • 8 热度
    0 评论
自从 Go 引入泛型以来,大家在泛型上最常讨论的点之一就是 如何设计/使用约束(constraints)。甚至连 Go 官方都出了多篇博文来频频介绍这个知识点。今天就来源于:

我们知道泛型的类型参数可以被限制在 cmp.Ordered、comparable 之类的集合:

但有一个容易被忽视的事实是:接口本身也是类型,它们也能有类型参数。这意味着我们可以用 “泛型接口” 来更优雅地表达某些约束关系,尤其是涉及 元素自身比较、组合约束、指针接收者 这些场景。下面我们就结合以下几个例子,结合着来快速理解这个特性。

从一个树开始:比较与约束
假设我们要写一个泛型二叉搜索树。最简单的做法是直接用 cmp.Ordered:
type Tree[E cmp.Ordered] struct {
    root *node[E]
}

func (t *Tree[E]) Insert(element E) {
    t.root = t.root.insert(element)
}

type node[E cmp.Ordered] struct {
    value E
    left  *node[E]
    right *node[E]
}

func (n *node[E]) insert(element E) *node[E] {
    if n == nil {
        return &node[E]{value: element}
    }
    switch {
    case element < n.value:
        n.left = n.left.insert(element)
    case element > n.value:
        n.right = n.right.insert(element)
    }
    return n
}
这样写很直观,但有个问题:它只适用于内置可比较的类型(int、string 等)。如果我想存 time.Time 呢?就用不了了。
常见解法是要求用户传一个比较函数:
type FuncTree[E any] struct {
    root *funcNode[E]
    cmp  func(E, E) int
}

func NewFuncTree[E any](cmp func(E, E) int) *FuncTree[E] {
    return &FuncTree[E]{cmp: cmp}
}
// 堆代码 duidaima.com
func (t *FuncTree[E]) Insert(element E) {
    t.root = t.root.insert(t.cmp, element)
}
这当然能跑,但有两个缺点:
1.必须显式初始化,不能用零值直接用。
2.调用 cmp 是函数调用,编译器不太好内联,性能可能受影响。
能不能换个思路?答案就是:泛型接口。

用泛型接口表达 “自比较”
如果我们定义一个接口:
type Comparer interface {
    Compare(Comparer) int
}
看似不错,但写起来很尴尬:方法参数是 Comparer,每个类型都要强转回来,非常不 Go。
改进后:
type Comparer[T any] interface {
    Compare(T) int
}
这样就好多了。比如 time.Time 有个 Compare(Time) int 方法,自然就实现了 Comparer[time.Time]。进一步,我们就能写一个支持自比较的树:
type MethodTree[E Comparer[E]] struct {
    root *methodNode[E]
}

func (t *MethodTree[E]) Insert(element E) {
    t.root = t.root.insert(element)
}

type methodNode[E Comparer[E]] struct {
    value E
    left  *methodNode[E]
    right *methodNode[E]
}

func (n *methodNode[E]) insert(element E) *methodNode[E] {
    if n == nil {
        return &methodNode[E]{value: element}
    }
    sign := element.Compare(n.value)
    switch {
    case sign < 0:
        n.left = n.left.insert(element)
    case sign > 0:
        n.right = n.right.insert(element)
    }
    return n
}
好处是:
1.time.Time 这种自带 Compare 方法的类型直接能用。

2.容器仍然支持零值初始化。


再和 map 结合:需要 comparable
如果我们想基于树实现一个 OrderedSet,里面加个 map 来做 O(1) 查询:
type OrderedSet[E Comparer[E]] struct {
    tree     MethodTree[E]
    elements map[E]bool
}

func (s *OrderedSet[E]) Has(e E) bool {
    return s.elements[e]
}
结果编译报错:
invalid map key type E (missing comparable constraint)
因为 Go 要求 map key 必须是 comparable。

这时有三种写法:
1.在 Comparer 接口里直接嵌入 comparable。
2.定义一个新的 ComparableComparer 接口。
3.在 OrderedSet 类型参数约束里 inline:
type OrderedSet[E interface {
    comparable
    Comparer[E]
}] struct { ... }
哪种方式用,看团队习惯,但推荐避免不必要的全局约束,尽量在具体类型上加。

泛型接口不必过度约束
再看一个常见场景。定义一个通用集合接口:
type Set[E any] interface {
    Insert(E)
    Delete(E)
    Has(E) bool
    All() iter.Seq[E]
}
这里的泛型参数最好只约束成 any,而不是强加 comparable 或 Comparer,因为不同实现有不同需求。例如基于 map 的实现必须要求 comparable,而基于 Tree 的则不需要。

指针接收者的坑
如果我们用 Set 来实现一个去重函数 Unique,会遇到一个尴尬点:
有些实现(比如 OrderedSet)的方法用指针接收者。
如果我们在泛型函数里声明 var seen S,当 S 是 *OrderedSet 时,它会被初始化成 nil,调用就 panic。
解决方案是:用一个额外的类型参数约束“必须是某个类型的指针”:
type PtrToSet[S, E any] interface {
    *S
    Set[E]
}

func Unique[E, S any, PS PtrToSet[S, E]](...) { ... }
这样写虽然麻烦,但至少能表达出“S 的指针实现了 Set”这个语义。Go 编译器还能帮我们推断最后一个参数,使用时还算顺手。

是否要约束指针接收者?
这类写法会让函数签名变得很 “吓人”。很多时候,我们其实不需要这么复杂的约束,可以换个角度:例如,我们本来写 Unique 想返回一个 iter.Seq[E],但其实它内部要构建一个集合,结果已经全存下来了,那干脆让调用方自己传 Set 进来就好:
func InsertAll[E any](set Set[E], seq iter.Seq[E]) {
    for v := range seq {
        set.Insert(v)
    }
}
这样简单明了,还能让不同实现的 Set 都能复用。
例如,最简单的 map 版:
type HashSet[E comparable] map[E]bool

func (s HashSet[E]) Insert(v E)       { s[v] = true }
func (s HashSet[E]) Delete(v E)       { delete(s, v) }
func (s HashSet[E]) Has(v E) bool     { return s[v] }
func (s HashSet[E]) All() iter.Seq[E] { return maps.Keys(s) }
用接口值,而不是复杂约束,往往更容易读懂,也更灵活。

总结
泛型接口是个很有意思的工具,能帮我们更自然地表达一些约束关系。
以下是几个要点:
1.可以用泛型接口来约束元素必须支持“和自己比较”。
2.组合 Comparer 和 comparable,能写出更强大的容器类型。
3.接口定义最好保持宽松,把具体约束交给实现。

4.如果因为指针接收者搞出很复杂的泛型函数签名,不妨退一步,改用接口值。


泛型给了 Go 更大的表达能力,但也带来了复杂性。当然,我还是建议能用简单方案解决的,就别上太花哨的写法。真的容易累人。
用户评论