泛型接口

有一个概念,初次接触时可能不易察觉:接口本身也是一种类型,因此,它同样可以拥有类型参数。在为泛型函数和类型定义约束时,这一特性显得尤为强大。本文将通过几个常见场景,深入探讨如何运用带类型参数的接口,一展其威力。

一个简单的二叉搜索树

我们从一个经典的例子开始:实现一个泛型的二叉搜索树。存储在树中的元素必须是可排序的,因此我们需要为类型参数添加一个约束,来明确排序规则。一个直接的选择是使用 Go 1.21 中引入的 cmp.Ordered 约束。它将类型参数限制为内置的有序类型(如字符串和数字),并允许我们直接使用内置的比较运算符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// The zero value of a Tree is a ready-to-use empty tree.
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
}

然而,该方案的局限在于,它只适用于 Go 内置支持 < 运算符的基本类型;你无法用它来存储像 time.Time 这样的结构体类型。

为了解决这个问题,我们可以要求用户提供一个比较函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// A FuncTree must be created with NewTreeFunc.
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}
}

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

type funcNode[E any] struct {
value E
left *funcNode[E]
right *funcNode[E]
}

func (n *funcNode[E]) insert(cmp func(E, E) int, element E) *funcNode[E] {
if n == nil {
return &funcNode[E]{value: element}
}
sign := cmp(element, n.value)
switch {
case sign < 0:
n.left = n.left.insert(cmp, element)
case sign > 0:
n.right = n.right.insert(cmp, element)
}
return n
}

这种方法虽然可行,但同样存在不足。首先,我们无法再使用容器类型的零值,因为它需要显式初始化比较函数。其次,将比较函数作为字段存储,使得编译器难以对其进行内联优化,可能引入显著的运行时开销。

更理想的方案是利用元素类型自身的方法来定义比较行为,因为方法与类型直接关联。这样一来,比较逻辑不必作为参数显式传递,编译器也能更好地洞察调用目标,从而可能进行内联优化。但问题是,我们如何通过约束来要求元素类型必须提供我们需要的方法呢?

在约束中使用接收器

我们首先想到的,或许是定义一个传统的接口,其中包含 Compare 方法:

1
2
3
type Comparer interface{
Compare(Comparer) int
}

然而,我们很快会发现这行不通。因为要实现该接口,Compare 方法的参数必须是 Comparer 接口类型。这意味着实现方需要对参数进行类型断言,而且每个实现该接口的类型都必须显式地导入定义 Comparer 的包,这使得代码耦合过高,不够通用。

更好的方式是让 Comparer 接口本身变为泛型接口:

1
2
3
type Comparer[T any] interface{
Compare(T) int
}

这个 Comparer[T] 定义了一个接口族,每种可实例化的类型 T 都会对应一个具体的接口。实现 Comparer[T] 的类型等于声明:“我可以将自己与 T 类型的实例进行比较”。例如,time.Time 类型因为定义了 Compare(u Time) int 方法,所以它自动实现了 Comparer[time.Time] 接口。

1
2
// 实现了 Comparer[Time]
func(t Time) Compare(u Time) int

这比最初的定义要好,但还不够。我们真正需要的是一个能够表达“类型参数可以与自身比较”的约束,即一个自引用的约束。这里的关键点在于,这种“自比较”的特性不一定需要体现在接口定义的内部(即 Comparer 类型中 T 的约束仍然是 any),而应该体现在我们使用它作为约束的时候:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// The zero value of a MethodTree is a ready-to-use empty tree.
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
}

由于 time.Time 实现了 Comparer[time.Time],我们现在便可将其用于 ,并且依然可以方便地使用零值作为空容器。 MethodTree

1
2
var t MethodTree[time.Time]
t.Insert(time.Now())

为了提供最大的灵活性,我们的库可以同时提供上述所有三个版本的 API。如果想减少代码重复,所有版本都可以基于一个共享的内部实现来封装。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
type node[E any] struct {
value E
left *node[E]
right *node[E]
}

func (n *node[E]) insert(cmp func(E, E) int, element E) *node[E] {
if n == nil {
return &node[E]{value: element}
}
sign := cmp(element, n.value)
switch {
case sign < 0:
n.left = n.left.insert(cmp, element)
case sign > 0:
n.right = n.right.insert(cmp, element)
}
return n
}

// Insert inserts element into the tree, if E implements cmp.Ordered.
func (t *Tree[E]) Insert(element E) {
t.root = t.root.insert(cmp.Compare[E], element)
}

// Insert inserts element into the tree, using the provided comparison function.
func (t *FuncTree[E]) Insert(element E) {
t.root = t.root.insert(t.cmp, element)
}

// Insert inserts element into the tree, if E implements Comparer[E].
func (t *MethodTree[E]) Insert(element E) {
t.root = t.root.insert(E.Compare, element)
}

值得注意的是,这个共享的内部实现(基于函数的版本)本身不受任何约束,以保持最大的灵活性。我们也没有将比较函数存储在结构体字段中,而是将其作为参数传递,因为函数参数通常比结构体字段更容易被编译器分析和优化。

当然,这种封装仍然需要一些模板代码,因为每个导出的实现都需要复制完整的 API 并适配不同的调用模式。但好在这些封装代码通常逻辑简单,易于编写和阅读。

组合方法和类型集

我们可以利用这棵树来实现一个有序集合(ordered set),它能以对数时间复杂度查找元素。现在,设想我们需要将元素查找的效率提升至近乎常数时间(O(1))。一个自然的想法是在树结构之外,额外维护一个 map 来存储元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
type OrderedSet[E Comparer[E]] struct {
tree MethodTree[E] // for efficient iteration in order
elements map[E]bool // for (near) constant time lookup
}

func (s *OrderedSet[E]) Has(e E) bool {
return s.elements[e]
}

func (s *OrderedSet[E]) Insert(e E) {
if s.elements == nil {
s.elements = make(map[E]bool)
}
if s.elements[e] {
return
}
s.elements[e] = true
s.tree.Insert(e)
}

func (s *OrderedSet[E]) All() iter.Seq[E] {
return func(yield func(E) bool) {
s.tree.root.all(yield)
}
}

func (n *node[E]) all(yield func(E) bool) bool {
return n == nil || (n.left.all(yield) && yield(n.value) && n.right.all(yield))
}

然而, 这个代码无法编译:

1
invalid map key type E (missing comparable constraint)

错误信息提示我们,为了将类型参数 E 作为 map 的键,我们需要为其添加额外的约束。comparable 是一个预声明的特殊约束,所有支持 ==!= 运算符的类型都满足它,这些类型也正是可以用作 map 键的类型

我们有三种方式来为类型参数添加这个额外的约束,它们各有取舍:

  1. 我们可以将 comparable 嵌入到原始的 Comparer 定义中:

    1
    2
    3
    4
    type Comparer[E any] interface {
    comparable
    Compare(E) int
    }

    缺点是这会限制我们所有的树类型都只能与可比较(comparable)的类型一起使用。通常,我们不希望在没有必要的情况下过度约束泛型类型

  2. 定义一个新的组合约束

    1
    2
    3
    4
    5
    6
    7
    8
    type Comparer[E any] interface {
    Compare(E) int
    }

    type ComparableComparer[E any] interface {
    comparable
    Comparer[E]
    }

    这很简洁, 但它会在我们的 API 中引入一个新的标识符(ComparableComparer),而且这类名称往往很难起得优雅

  3. 在使用处内联约束:

    1
    2
    3
    4
    5
    6
    7
    type OrderedSet[E interface {
    comparable
    Comparer[E]
    }] struct {
    tree Tree[E]
    elements map[E]struct{}
    }

    这种内联方式会降低代码的可读性,尤其是在需要频繁使用该约束的场景下。同时,这也使得在其他地方重用此约束变得困难。

具体选择哪一种,很大程度上取决于个人偏好和项目约定 .

(不) 约束泛型接口

对泛型接口的约束是一个值得深入探讨的话题。假设你想为通用的“集合”类型定义一个接口。考虑到现实世界中存在多种多样的集合实现,它们在性能和特性上各有权衡,定义一个通用接口可以增加代码的灵活性,将具体实现的选择权交给用户:

1
2
3
4
5
6
type Set[E any] interface {
Insert(E)
Delete(E)
Has(E) bool
All() iter.Seq[E]
}

一个很自然的问题是:这个泛型接口的类型参数 E 应该接受什么样的约束?如果可能,**泛型接口上的类型参数约束应尽量保持为 any**,以允许任何类型。

答案,其实已在我们此前的讨论中初见端倪:不同的具体实现可能需要不同的约束。我们上面实现的所有 Tree 类型以及 OrderedSet 类型,尽管它们各自有着不同的约束,但都可以实现这个 Set[E] 接口。

定义接口的核心目的,正是为了将具体实现的选择权交给用户。既然我们无法预知用户会为其实现施加何种约束,那么就应该将比 any 更强的约束下放到具体的实现中,而不是限制在接口定义上。

指针接收器

让我们尝试在实践中使用 Set 接口。考虑一个从序列中移除重复元素的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Unique removes duplicate elements from the input sequence, yielding only
// the first instance of any element.
func Unique[E comparable](input iter.Seq[E]) iter.Seq[E] {
return func(yield func(E) bool) {
seen := make(map[E]bool)
for v := range input {
if seen[v] {
continue
}
if !yield(v) {
return
}
seen[v] = true
}
}
}

这里使用 map 作为 Set[E] 的一个简单实现,它只适用于可比较的类型。如果我们想让它支持任意类型,就需要将 map 替换为一个 Set[E] 接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Unique removes duplicate elements from the input sequence, yielding only
// the first instance of any element.
func Unique[E any](input iter.Seq[E]) iter.Seq[E] {
return func(yield func(E) bool) {
var seen Set[E]
for v := range input {
if seen.Has(v) {
continue
}
if !yield(v) {
return
}
seen.Insert(v)
}
}
}

但这并不可行。因为 Set[E] 是一个接口类型,变量 seen 的零值是 nil 。直接在 nil 接口值上调用方法会引发运行时 panic。我们需要 Set[E] 接口的一个具体实现。然而,正如我们所见,并不存在一个适用于所有元素类型的通用 Set[E] 实现。

因此,我们必须要求用户提供一个我们可以使用的具体实现,将其作为额外的类型参数传入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Unique removes duplicate elements from the input sequence, yielding only
// the first instance of any element.
func Unique[E any, S Set[E]](input iter.Seq[E]) iter.Seq[E] {
return func(yield func(E) bool) {
var seen S
for v := range input {
if seen.Has(v) {
continue
}
if !yield(v) {
return
}
seen.Insert(v)
}
}
}

playground

当我们尝试传入一个我们之前定义的 OrderedSet[E] 作为 S 时,会遇到另一个问题:

1
2
3
4
// OrderedSet[E] does not satisfy Set[E] (method All has pointer receiver)
Unique[E, OrderedSet[E]](slices.Values(s))
// panic: invalid memory address or nil pointer dereference
Unique[E, *OrderedSet[E]](slices.Values(s))

第一个问题从错误信息中一目了然:我们的 OrderedSet 的方法(如 Insert)使用了指针接收器,因此满足 Set[E] 接口的是 *OrderedSet[E] 类型,而不是 OrderedSet[E] 类型。

然而,当我们修正这一点,传入 *OrderedSet[E] 后,又会遇到第二个问题。这源于我们声明变量的方式 var seen S。如果 S*OrderedSet[E],那么 seen 会被初始化为 nil ,后续调用其方法依然会导致 panic

我们陷入了一个两难境地:如果类型参数是值类型,我们无法调用指针方法;如果是指针类型,我们得到的却是一个 nil 指针。我们需要的是一个非 nil 的指针。

结果是,我们需要同时处理值类型和指针类型。为此,我们必须引入一个带有新约束 PtrToSet 的附加类型参数 : PS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// PtrToSet is implemented by a pointer type implementing the Set[E] interface.
type PtrToSet[S, E any] interface {
*S
Set[E]
}

// Unique removes duplicate elements from the input sequence, yielding only
// the first instance of any element.
func Unique[E, S any, PS PtrToSet[S, E]](input iter.Seq[E]) iter.Seq[E] {
return func(yield func(E) bool) {
// We convert to PS, as only that is constrained to have the methods.
// The conversion is allowed, because the type set of PS only contains *S.
seen := PS(new(S))
for v := range input {
if seen.Has(v) {
continue
}
if !yield(v) {
return
}
seen.Insert(v)
}
}
}

playground

这里的诀窍在于,我们通过 PtrToSet 接口上的额外类型参数,巧妙地将函数签名中的 SPS 两个类型参数关联起来。S 本身不受约束,但 PS 必须是 *S 类型,并且必须实现 Set[E] 接口。通过这种方式,我们有效地约束了 S 必须拥有一组方法,且这些方法必须定义在指针接收器上。

虽然定义这样的函数需要一个额外的类型参数,但幸运的是,这并不会让调用代码变得复杂:只要这个额外的类型参数位于列表末尾,Go 的类型推断就能处理它:

1
2
// The third type argument is inferred to be *OrderedSet[int]
Unique[int, OrderedSet[int]](slices.Values(s))

这是一个值得记住的通用模式,无论是在阅读他人的代码,还是在编写自己的库时

1
func SomeFunction[T any, PT interface{ *T; SomeMethods }]()

如果你有两个类型参数,其中一个被限制为指向另一个的指针,则约束将确保相关方法使用指针接收器。

需要指针类型接收器约束吗

读到这里,你或许会感到有些困惑。上述模式确实相当复杂,期望每个 Go 程序员都能透彻理解其背后的机制似乎不太现实。而且,我们还不得不在 API 中引入了新的名称。这正是当初人们对 Go 添加泛型所担忧的问题之一

因此,当你发现自己陷入这类问题时,非常值得后退一步。我们通常可以通过换个角度思考问题来避免这种复杂性。在上面的例子中,我们构建了一个接受迭代器 Seq[E] 并返回一个去重后迭代器 Seq[E] 的函数。但要去重,我们必然需要将元素存入一个集合。既然无论如何都需要为结果分配存储空间,那么将结果表示为流(iterator)可能并没有带来真正的益处

如果我们重新审视这个问题,可以通过将 Set[E] 作为一个普通的接口值参数来传递,从而完全避免额外的类型参数和复杂的约束。

1
2
3
4
5
6
// InsertAll adds all unique elements from seq into set.
func InsertAll[E any](set Set[E], seq iter.Seq[E]) {
for v := range seq {
set.Insert(v)
}
}

通过将 Set 作为一个普通的接口类型参数,调用方就必须提供一个有效的、非 nil 的具体实现实例。这是一种非常常见且清晰的模式。如果调用方后续需要一个迭代器 Seq[E],他们只需调用 set.All() 即可

这个方案虽然给调用者增加了一点点负担(需要预先创建 set 实例),但与复杂的指针接收器约束相比,它还有一个显著的优势:回想一下,我们最初的简单集合实现是 map[E]bool。基于它来实现 Set[E] 接口非常容易:

1
2
3
4
5
6
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) }

这个 HashSet 的实现方法使用的都是值接收器,而非指针接收器。因此,尽管它是一个完全有效的 Set[E] 实现,但它并不能满足我们之前那个复杂的、要求指针接收器的 Unique 函数的约束。然而,它却能与我们这个更简单的 InsertAll 函数完美配合

结论

我希望本文阐明了在接口类型上使用类型参数的一些模式和权衡。泛型接口是一个强大的工具,但能力越大,也意味着复杂性越高。以下是几个关键的 takeaway:

  1. 自我引用约束:利用泛型接口 Constraint[T] 约束类型 T 本身,实现如 MethodTree[E Comparer[E]] 这样的自引用约束,要求类型拥有处理同类型实例的方法。
  2. 关联约束:通过泛型接口为不同的类型参数建立关联,例如约束一个类型参数必须是另一个类型参数的指针类型,并实现特定接口。
  3. 抽象不同实现:使用泛型接口(如 Set[E])来统一代表多种具有不同约束的具体实现(如基于 cmp.Ordered、比较函数或 Compare 方法的树)。
  4. 警惕过度复杂:当你发现需要编写复杂的指针接收器约束时,不妨后退一步,思考能否通过重构 API(例如,将对象作为参数传入而非在函数内创建)来简化设计。

最后,请牢记:切勿过度设计。一个灵活性稍逊但更简单、可读性更强的方案,往往是更明智的选择。


泛型接口
https://blog.zhangliangliang.cc/post/generic-interfaces.html
作者
Bobby Zhang
发布于
2025年8月14日
许可协议