泛型接口
有一个概念,初次接触时可能不易察觉:接口本身也是一种类型,因此,它同样可以拥有类型参数。在为泛型函数和类型定义约束时,这一特性显得尤为强大。本文将通过几个常见场景,深入探讨如何运用带类型参数的接口,一展其威力。
一个简单的二叉搜索树
我们从一个经典的例子开始:实现一个泛型的二叉搜索树。存储在树中的元素必须是可排序的,因此我们需要为类型参数添加一个约束,来明确排序规则。一个直接的选择是使用 Go 1.21 中引入的 cmp.Ordered
约束。它将类型参数限制为内置的有序类型(如字符串和数字),并允许我们直接使用内置的比较运算符。
1 |
|
然而,该方案的局限在于,它只适用于 Go 内置支持 <
运算符的基本类型;你无法用它来存储像 time.Time
这样的结构体类型。
为了解决这个问题,我们可以要求用户提供一个比较函数:
1 |
|
这种方法虽然可行,但同样存在不足。首先,我们无法再使用容器类型的零值,因为它需要显式初始化比较函数。其次,将比较函数作为字段存储,使得编译器难以对其进行内联优化,可能引入显著的运行时开销。
更理想的方案是利用元素类型自身的方法来定义比较行为,因为方法与类型直接关联。这样一来,比较逻辑不必作为参数显式传递,编译器也能更好地洞察调用目标,从而可能进行内联优化。但问题是,我们如何通过约束来要求元素类型必须提供我们需要的方法呢?
在约束中使用接收器
我们首先想到的,或许是定义一个传统的接口,其中包含 Compare
方法:
1 |
|
然而,我们很快会发现这行不通。因为要实现该接口,Compare
方法的参数必须是 Comparer
接口类型。这意味着实现方需要对参数进行类型断言,而且每个实现该接口的类型都必须显式地导入定义 Comparer
的包,这使得代码耦合过高,不够通用。
更好的方式是让 Comparer
接口本身变为泛型接口:
1 |
|
这个 Comparer[T]
定义了一个接口族,每种可实例化的类型 T
都会对应一个具体的接口。实现 Comparer[T]
的类型等于声明:“我可以将自己与 T
类型的实例进行比较”。例如,time.Time
类型因为定义了 Compare(u Time) int
方法,所以它自动实现了 Comparer[time.Time]
接口。
1 |
|
这比最初的定义要好,但还不够。我们真正需要的是一个能够表达“类型参数可以与自身比较”的约束,即一个自引用的约束。这里的关键点在于,这种“自比较”的特性不一定需要体现在接口定义的内部(即 Comparer
类型中 T
的约束仍然是 any
),而应该体现在我们使用它作为约束的时候:
1 |
|
由于 time.Time
实现了 Comparer[time.Time]
,我们现在便可将其用于 ,并且依然可以方便地使用零值作为空容器。 MethodTree
1 |
|
为了提供最大的灵活性,我们的库可以同时提供上述所有三个版本的 API。如果想减少代码重复,所有版本都可以基于一个共享的内部实现来封装。
1 |
|
值得注意的是,这个共享的内部实现(基于函数的版本)本身不受任何约束,以保持最大的灵活性。我们也没有将比较函数存储在结构体字段中,而是将其作为参数传递,因为函数参数通常比结构体字段更容易被编译器分析和优化。
当然,这种封装仍然需要一些模板代码,因为每个导出的实现都需要复制完整的 API 并适配不同的调用模式。但好在这些封装代码通常逻辑简单,易于编写和阅读。
组合方法和类型集
我们可以利用这棵树来实现一个有序集合(ordered set),它能以对数时间复杂度查找元素。现在,设想我们需要将元素查找的效率提升至近乎常数时间(O(1))。一个自然的想法是在树结构之外,额外维护一个 map 来存储元素:
1 |
|
然而, 这个代码无法编译:
1 |
|
错误信息提示我们,为了将类型参数 E
作为 map 的键,我们需要为其添加额外的约束。comparable
是一个预声明的特殊约束,所有支持 ==
和 !=
运算符的类型都满足它,这些类型也正是可以用作 map 键的类型
我们有三种方式来为类型参数添加这个额外的约束,它们各有取舍:
我们可以将 comparable 嵌入到原始的 Comparer 定义中:
1
2
3
4type Comparer[E any] interface {
comparable
Compare(E) int
}缺点是这会限制我们所有的树类型都只能与可比较(comparable)的类型一起使用。通常,我们不希望在没有必要的情况下过度约束泛型类型
定义一个新的组合约束
1
2
3
4
5
6
7
8type Comparer[E any] interface {
Compare(E) int
}
type ComparableComparer[E any] interface {
comparable
Comparer[E]
}这很简洁, 但它会在我们的 API 中引入一个新的标识符(
ComparableComparer
),而且这类名称往往很难起得优雅在使用处内联约束:
1
2
3
4
5
6
7type OrderedSet[E interface {
comparable
Comparer[E]
}] struct {
tree Tree[E]
elements map[E]struct{}
}这种内联方式会降低代码的可读性,尤其是在需要频繁使用该约束的场景下。同时,这也使得在其他地方重用此约束变得困难。
具体选择哪一种,很大程度上取决于个人偏好和项目约定 .
(不) 约束泛型接口
对泛型接口的约束是一个值得深入探讨的话题。假设你想为通用的“集合”类型定义一个接口。考虑到现实世界中存在多种多样的集合实现,它们在性能和特性上各有权衡,定义一个通用接口可以增加代码的灵活性,将具体实现的选择权交给用户:
1 |
|
一个很自然的问题是:这个泛型接口的类型参数 E
应该接受什么样的约束?如果可能,**泛型接口上的类型参数约束应尽量保持为 any
**,以允许任何类型。
答案,其实已在我们此前的讨论中初见端倪:不同的具体实现可能需要不同的约束。我们上面实现的所有 Tree
类型以及 OrderedSet
类型,尽管它们各自有着不同的约束,但都可以实现这个 Set[E]
接口。
定义接口的核心目的,正是为了将具体实现的选择权交给用户。既然我们无法预知用户会为其实现施加何种约束,那么就应该将比 any
更强的约束下放到具体的实现中,而不是限制在接口定义上。
指针接收器
让我们尝试在实践中使用 Set
接口。考虑一个从序列中移除重复元素的函数:
1 |
|
这里使用 map
作为 Set[E]
的一个简单实现,它只适用于可比较的类型。如果我们想让它支持任意类型,就需要将 map
替换为一个 Set[E]
接口:
1 |
|
但这并不可行。因为 Set[E]
是一个接口类型,变量 seen
的零值是 nil
。直接在 nil
接口值上调用方法会引发运行时 panic。我们需要 Set[E]
接口的一个具体实现。然而,正如我们所见,并不存在一个适用于所有元素类型的通用 Set[E]
实现。
因此,我们必须要求用户提供一个我们可以使用的具体实现,将其作为额外的类型参数传入:
1 |
|
当我们尝试传入一个我们之前定义的 OrderedSet[E]
作为 S
时,会遇到另一个问题:
1 |
|
第一个问题从错误信息中一目了然:我们的 OrderedSet
的方法(如 Insert
)使用了指针接收器,因此满足 Set[E]
接口的是 *OrderedSet[E]
类型,而不是 OrderedSet[E]
类型。
然而,当我们修正这一点,传入 *OrderedSet[E]
后,又会遇到第二个问题。这源于我们声明变量的方式 var seen S
。如果 S
是 *OrderedSet[E]
,那么 seen
会被初始化为 nil
,后续调用其方法依然会导致 panic
我们陷入了一个两难境地:如果类型参数是值类型,我们无法调用指针方法;如果是指针类型,我们得到的却是一个 nil
指针。我们需要的是一个非 nil
的指针。
结果是,我们需要同时处理值类型和指针类型。为此,我们必须引入一个带有新约束 PtrToSet
的附加类型参数 : PS
1 |
|
这里的诀窍在于,我们通过 PtrToSet
接口上的额外类型参数,巧妙地将函数签名中的 S
和 PS
两个类型参数关联起来。S
本身不受约束,但 PS
必须是 *S
类型,并且必须实现 Set[E]
接口。通过这种方式,我们有效地约束了 S
必须拥有一组方法,且这些方法必须定义在指针接收器上。
虽然定义这样的函数需要一个额外的类型参数,但幸运的是,这并不会让调用代码变得复杂:只要这个额外的类型参数位于列表末尾,Go 的类型推断就能处理它:
1 |
|
这是一个值得记住的通用模式,无论是在阅读他人的代码,还是在编写自己的库时
1 |
|
如果你有两个类型参数,其中一个被限制为指向另一个的指针,则约束将确保相关方法使用指针接收器。
需要指针类型接收器约束吗
读到这里,你或许会感到有些困惑。上述模式确实相当复杂,期望每个 Go 程序员都能透彻理解其背后的机制似乎不太现实。而且,我们还不得不在 API 中引入了新的名称。这正是当初人们对 Go 添加泛型所担忧的问题之一
因此,当你发现自己陷入这类问题时,非常值得后退一步。我们通常可以通过换个角度思考问题来避免这种复杂性。在上面的例子中,我们构建了一个接受迭代器 Seq[E]
并返回一个去重后迭代器 Seq[E]
的函数。但要去重,我们必然需要将元素存入一个集合。既然无论如何都需要为结果分配存储空间,那么将结果表示为流(iterator)可能并没有带来真正的益处
如果我们重新审视这个问题,可以通过将 Set[E]
作为一个普通的接口值参数来传递,从而完全避免额外的类型参数和复杂的约束。
1 |
|
通过将 Set
作为一个普通的接口类型参数,调用方就必须提供一个有效的、非 nil
的具体实现实例。这是一种非常常见且清晰的模式。如果调用方后续需要一个迭代器 Seq[E]
,他们只需调用 set.All()
即可
这个方案虽然给调用者增加了一点点负担(需要预先创建 set
实例),但与复杂的指针接收器约束相比,它还有一个显著的优势:回想一下,我们最初的简单集合实现是 map[E]bool
。基于它来实现 Set[E]
接口非常容易:
1 |
|
这个 HashSet
的实现方法使用的都是值接收器,而非指针接收器。因此,尽管它是一个完全有效的 Set[E]
实现,但它并不能满足我们之前那个复杂的、要求指针接收器的 Unique
函数的约束。然而,它却能与我们这个更简单的 InsertAll
函数完美配合
结论
我希望本文阐明了在接口类型上使用类型参数的一些模式和权衡。泛型接口是一个强大的工具,但能力越大,也意味着复杂性越高。以下是几个关键的 takeaway:
- 自我引用约束:利用泛型接口
Constraint[T]
约束类型T
本身,实现如MethodTree[E Comparer[E]]
这样的自引用约束,要求类型拥有处理同类型实例的方法。 - 关联约束:通过泛型接口为不同的类型参数建立关联,例如约束一个类型参数必须是另一个类型参数的指针类型,并实现特定接口。
- 抽象不同实现:使用泛型接口(如
Set[E]
)来统一代表多种具有不同约束的具体实现(如基于cmp.Ordered
、比较函数或Compare
方法的树)。 - 警惕过度复杂:当你发现需要编写复杂的指针接收器约束时,不妨后退一步,思考能否通过重构 API(例如,将对象作为参数传入而非在函数内创建)来简化设计。
最后,请牢记:切勿过度设计。一个灵活性稍逊但更简单、可读性更强的方案,往往是更明智的选择。