Go 泛型之泛型约束
目录
一、引入
虽然泛型是开发人员表达“通用代码”的一种重要方式,但这并不意味着所有泛型代码对所有类型都适用。更多的时候,我们需要对泛型函数的类型参数以及泛型函数中的实现代码设置限制。泛型函数调用者只能传递满足限制条件的类型实参,泛型函数内部也只能以类型参数允许的方式使用这些类型实参值。在 Go 泛型语法中,我们使用类型参数约束(type parameter constraint)(以下简称约束)来表达这种限制条件。
约束之于类型参数就好比函数参数列表中的类型之于参数:

函数普通参数在函数实现代码中可以表现出来的性质与可以参与的运算由参数类型限制,而泛型函数的类型参数就由约束(constraint)来限制。
2018 年 8 月由伊恩·泰勒和罗伯特·格瑞史莫主写的 Go 泛型第一版设计方案中,Go 引入了 contract 关键字来定义泛型类型参数的约束。但经过约两年的 Go 社区公示和讨论,在 2020 年 6 月末发布的泛型新设计方案中,Go 团队又放弃了新引入的 contract 关键字,转而采用已有的 interface 类型来替代 contract 定义约束。这一改变得到了 Go 社区的大力支持。使用 interface 类型作为约束的定义方法能够最大程度地复用已有语法,并抑制语言引入泛型后的复杂度。
但原有的 interface 语法尚不能满足定义约束的要求。所以,在 Go 泛型版本中,interface 语法也得到了一些扩展,也正是这些扩展给那些刚刚入门 Go 泛型的 Go 开发者带来了一丝困惑,这也是约束被认为是 Go 泛型的一个难点的原因。
下面我们来看一下 Go 类型参数的约束, Go 原生内置的约束、如何定义自己的约束、新引入的类型集合概念等。我们先来看一下 Go 语言的内置约束,从 Go 泛型中最宽松的约束:any 开始。
二、最宽松的约束:any
无论是泛型函数还是泛型类型,其所有类型参数声明中都必须显式包含约束,即便你允许类型形参接受所有类型作为类型实参传入也是一样。那么我们如何表达“所有类型”这种约束呢?我们可以使用空接口类型(interface{})来作为类型参数的约束:- func Print[T interface{}](sl []T) {
- // ... ...
- }
- func doSomething[T1 interface{}, T2 interface{}, T3 interface{}](t1 T1, t2 T2, t3 T3) {
- // ... ...
- }
复制代码 不过使用 interface{} 作为约束至少有以下几点“不足”:
- 如果存在多个这类约束时,泛型函数声明部分会显得很冗长,比如上面示例中的 doSomething 的声明部分;
- interface{} 包含 {} 这样的符号,会让本已经很复杂的类型参数声明部分显得更加复杂;
- 和 comparable、Sortable、ordered 这样的约束命名相比,interface{} 作为约束的表意不那么直接。
为此,Go 团队在 Go 1.18 泛型落地的同时又引入了一个预定义标识符:any。any 本质上是 interface{} 的一个类型别名:- // $GOROOT/src/builtin/buildin.go
- // any is an alias for interface{} and is equivalent to interface{} in all ways.
- type any = interface{}
复制代码 这样,我们在泛型类型参数声明中就可以使用 any 替代 interface{},而上述 interface{} 作为类型参数约束的几点“不足”也随之被消除掉了。
any 约束的类型参数意味着可以接受所有类型作为类型实参。在函数体内,使用 any 约束的形参 T 可以用来做如下操作:
- 声明变量
- 同类型赋值
- 将变量传给其他函数或从函数返回
- 取变量地址
- 转换或赋值给 interface{} 类型变量
- 用在类型断言或 type switch 中
- 作为复合类型中的元素类型
- 传递给预定义的函数,比如 new
下面是 any 约束的类型参数执行这些操作的一个示例:- // any.go
- func doSomething[T1, T2 any](t1 T1, t2 T2) T1 {
- var a T1 // 声明变量
- var b T2
- a, b = t1, t2 // 同类型赋值
- _ = b
- f := func(t T1) {
- }
- f(a) // 传给其他函数
- p := &a // 取变量地址
- _ = p
- var i interface{} = a // 转换或赋值给interface{}类型变量
- _ = i
- c := new(T1) // 传递给预定义函数
- _ = c
- f(a) // 将变量传给其他函数
- sl := make([]T1, 0, 10) // 作为复合类型中的元素类型
- _ = sl
- j, ok := i.(T1) // 用在类型断言中
- _ = ok
- _ = j
- switch i.(type) { // 作为type switch中的case类型
- case T1:
- case T2:
- }
- return a // 从函数返回
- }
复制代码 但如果对 any 约束的类型参数进行了非上述允许的操作,比如相等性或不等性比较,那么 Go 编译器就会报错:- // any.go
- func doSomething[T1, T2 any](t1 T1, t2 T2) T1 {
- var a T1
- if a == t1 { // 编译器报错:invalid operation: a == t1 (incomparable types in type set)
- }
-
- if a != t1 { // 编译器报错:invalid operation: a != t1 (incomparable types in type set)
- }
- ... ...
- }
复制代码 所以说,如果我们想在泛型函数体内部对类型参数声明的变量实施相等性(==)或不等性比较(!=)操作,我们就需要更换约束,这就引出了 Go 内置的另外一个预定义约束:comparable。
三、支持比较操作的内置约束:comparable
Go 泛型提供了预定义的约束:comparable,其定义如下:- // $GOROOT/src/builtin/buildin.go
- // comparable is an interface that is implemented by all comparable types
- // (booleans, numbers, strings, pointers, channels, arrays of comparable types,
- // structs whose fields are all comparable types).
- // The comparable interface may only be used as a type parameter constraint,
- // not as the type of a variable.
- type comparable interface{ comparable }
复制代码 不过从上述这行源码我们仍然无法直观看到 comparable 的实现细节,Go 编译器会在编译期间判断某个类型是否实现了 comparable 接口。
根据其注释说明,所有可比较的类型都实现了 comparable 这个接口,包括:布尔类型、数值类型、字符串类型、指针类型、channel 类型、元素类型实现了 comparable 的数组和成员类型均实现了 comparable 接口的结构体类型。下面的例子可以让我们直观地看到这一点:- // comparable.go
- type foo struct {
- a int
- s string
- }
- type bar struct {
- a int
- sl []string
- }
- func doSomething[T comparable](t T) T {
- var a T
- if a == t {
- }
-
- if a != t {
- }
- return a
- }
-
- func main() {
- doSomething(true)
- doSomething(3)
- doSomething(3.14)
- doSomething(3 + 4i)
- doSomething("hello")
- var p *int
- doSomething(p)
- doSomething(make(chan int))
- doSomething([3]int{1, 2, 3})
- doSomething(foo{})
- doSomething(bar{}) // bar does not implement comparable
- }
复制代码 我们看到,最后一行 bar 结构体类型因为内含不支持比较的切片类型,被 Go 编译器认为未实现 comparable 接口,但除此之外的其他类型作为类型实参都满足 comparable 约束的要求。
此外还要注意,comparable 虽然也是一个 interface,但它不能像普通 interface 类型那样来用,比如下面代码会导致编译器报错:- var i comparable = 5 // 编译器错误:cannot use type comparable outside a type constraint: interface is (or embeds) comparable
复制代码 从编译器的错误提示,我们看到:comparable 只能用作修饰类型参数的约束。
四、自定义约束
我们知道,Go 泛型最终决定使用 interface 语法来定义约束。这样一来,凡是接口类型均可作为类型参数的约束。下面是一个使用普通接口类型作为类型参数约束的示例:- // stringify.go
- func Stringify[T fmt.Stringer](s []T) (ret []string) {
- for _, v := range s {
- ret = append(ret, v.String())
- }
- return ret
- }
- type MyString string
- func (s MyString) String() string {
- return string(s)
- }
- func main() {
- sl := Stringify([]MyString{"I", "love", "golang"})
- fmt.Println(sl) // 输出:[I love golang]
- }
复制代码 这个例子中,我们使用的是 fmt.Stringer 接口作为约束。一方面,这要求类型参数 T 的实参必须实现 fmt.Stringer 接口的所有方法;另一方面,泛型函数 Stringify 的实现代码中,声明的 T 类型实例(比如 v)也仅被允许调用 fmt.Stringer 的 String 方法。
这类基于行为(方法集合)定义的约束对于习惯了 Go 接口类型的开发者来说,是相对好理解的。定义和使用起来,与下面这样的以接口类型作为形参的普通 Go 函数相比,区别似乎不大:- func Stringify(s []fmt.Stringer) (ret []string) {
- for _, v := range s {
- ret = append(ret, v.String())
- }
- return ret
- }
复制代码 但现在我想扩展一下上面 stringify.go 这个示例,将 Stringify 的语义改为只处理非零值的元素:- // stringify_without_zero.go
- func StringifyWithoutZero[T fmt.Stringer](s []T) (ret []string) {
- var zero T
- for _, v := range s {
- if v == zero { // 编译器报错:invalid operation: v == zero (incomparable types in type set)
- continue
- }
- ret = append(ret, v.String())
- }
- return ret
- }
复制代码 我们看到,针对 v 的相等性判断导致了编译器报错,我们需要为类型参数赋予更多的能力,比如支持相等性和不等性比较。这让我们想起了我们刚刚学过的 Go 内置约束 comparable,实现 comparable 的类型,便可以支持相等性和不等性判断操作了。
我们知道,comparable 虽然不能像普通接口类型那样声明变量,但它却可以作为类型嵌入到其他接口类型中,下面我们就扩展一下上面示例:- // stringify_new_without_zero.go
- type Stringer interface {
- comparable
- String() string
- }
- func StringifyWithoutZero[T Stringer](s []T) (ret []string) {
- var zero T
- for _, v := range s {
- if v == zero {
- continue
- }
- ret = append(ret, v.String())
- }
- return ret
- }
- type MyString string
- func (s MyString) String() string {
- return string(s)
- }
- func main() {
- sl := StringifyWithoutZero([]MyString{"I", "", "love", "", "golang"}) // 输出:[I love golang]
- fmt.Println(sl)
- }
复制代码 在这个示例里,我们自定义了一个 Stringer 接口类型作为约束。在该类型中,我们不仅定义了 String 方法,还嵌入了 comparable,这样在泛型函数中,我们用 Stringer 约束的类型参数就具备了进行相等性和不等性比较的能力了!
但我们的示例演进还没有完,现在相等性和不等性比较已经不能满足我们需求了,我们还要为之加上对排序行为的支持,并基于排序能力实现下面的 StringifyLessThan 泛型函数:- func StringifyLessThan[T Stringer](s []T, max T) (ret []string) {
- var zero T
- for _, v := range s {
- if v == zero || v >= max {
- continue
- }
- ret = append(ret, v.String())
- }
- return ret
- }
复制代码 但现在当我们编译上面 StringifyLessThan 函数时,我们会得到编译器的报错信息 invalid operation: v >= max (type parameter T is not comparable with >=)。Go 编译器认为 Stringer 约束的类型参数 T 不具备排序比较能力。
如果连排序比较性都无法支持,这将大大限制我们泛型函数的表达能力。但是 Go 又不支持运算符重载(operator overloading),不允许我们定义出下面这样的接口类型作为类型参数的约束:
[code]type Stringer[T any] interface { String() string comparable >(t T) bool >=(t T) bool |