约束条件

前一篇文章中我们简单介绍了约束条件的概念,知道了约束条件是通过接口来定义的。说到接口,大家都应该很熟悉了,它包含一组方法的声明,一个自定义类型只要实现了这组方法,就算实现了该接口。空接口interface{}没有声明任何方法,所以任意类型都实现了空接口。前一篇文章中我们展示了,当一个接口被用作约束条件时,泛型函数中只能使用该接口声明的方法。这对于一些自定义类型是没有问题的,但是对于一些内置类型来讲就不够用了。

比如在下面的例子中,Sum函数用来对一个int型切片进行累加求和,我们想把它扩展成支持所有整型类型:

1
2
3
4
5
6
7
func Sum(s []int) int {
var r int
for i := 0; i < len(s); i++ {
r += s[i]
}
return r
}

因为这些内置类型没有实现任何方法,所以我们无法用原有的接口来进行约束。直观来看,需要保证传入的类型参数支持加法运算符,然而同样的运算符作用于不同数据类型时也有不同的含义,比如加法运算符+对于字符串类型是拼接操作,所以我们需要约束传入的类型参数为所有整型类型中的一种,因此在go1.18中,对接口进行了扩展以支持这种基于数据类型的约束条件,这就是我们接下来要介绍的类型集。

Type sets

还是从扩展之前的传统接口类型说起,接口声明了一组方法,我们称之为该接口的方法集。实际上,这组方法也同时定义了一个类型集,所有实现了这组方法的自定义类型都属于该类型集。因为自定义类型的数量是无穷的,所以传统接口定义的类型集是一个无限集。假如接口I1声明了一个方法M1,接口I2声明了一个方法M2,接口I3声明了两个方法M1M2,那么I3所代表的类型集就是I1I2的交集。同理,任何一个接口的类型集都是interface{}的子集。

在go1.18中,接口被重新定义以完整支持constraints。按照之前的定义,当一种数据类型实现了约束条件所对应的接口时,才算满足该约束。在新的定义中,只要给定的数据类型属于约束条件对应的类型集,就满足该约束。在语法层面,之前的接口定义允许接口中声明方法集,以及嵌入其他的接口。经过扩展之后,现在的接口中还允许列出任意的数据类型,如下面例子所示:

1
2
3
type Int interface {
int
}

这个Int接口约束了传入的类型参数只能是int类型,这种约束条件是合法的,但是没有什么意义,只限一种类型的话就称不上泛型了。所以,Go又允许我们使用|运算符来组合多种数据类型,从而形成一个真正的类型集和,下面的示例代码给出了所有内置有符号整型构成的类型集:

1
2
3
type Integer interface {
int | int8 | int16 | int32 | int64
}

有了这些还是不够,因为很多时候我们会基于现有的数据类型来定义新的类型,比如像JSON那样定义一种数值类型type Number float64。使用泛型就是为了增强代码的复用性,我们不希望常常为了新定义的类型而去改动已有的约束。这个Number类型的底层是float64,因此Go又提供了一个新的符号~,让我们能够基于底层类型来定义约束条件。比如,下面代码所定义的类型集,包含float32float64,以及所有底层是float32float64的自定义类型:

1
2
3
type Float interface {
~float32 | ~float64
}

我们可以在接口中同时列出数据类型和声明方法集,以及嵌入其他的接口。但是需要注意的是,这种经过扩展的接口只能用作约束条件,不能像之前那样用来声明变量。如果要通过接口来使用Go的动态语言特性的话,还是要使用原始的接口语法,其中只能定义方法集、以及嵌入其他未被扩展的接口。

comparable

现在可以回过头来看看本文最初的那个Sum函数了,在go1.18beta2标准库的constraints包中(这个包在几天前被从标准库中移出,现在被移到了golang.org/x/exp这个mod中),已经为我们定义了一些常用的约束条件,其中整型被分开定义成了有符号整型和无符号整型两个约束,如下所示:

1
2
3
4
5
6
7
type Signed interface {
~int | ~int8 | ~int16 | ~int32 | ~int64
}

type Unsigned interface {
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}

所以我们可以直接使用这些定义好的约束条件来重写我们的Sum函数,重写后的代码如下所示:

1
2
3
4
5
6
7
func Sum[T Signed | Unsigned](s []T) T {
var r T
for i := 0; i < len(s); i++ {
r += s[i]
}
return r
}

因为所有的整型类型都支持加法运算,所以我们使用扩展过的接口定义了类型集,作为约束条件进而实现了泛型的Sum函数。但是Go的有些运算符是没有办法定义与之对应的类型集的,这个例外就是用来做相等性比较的两个运算符==!=。除了一众基础类型之外,它们还可以用来比较结构体类型、数组类型和接口类型,只要结构体和数组的元素类型、或接口背后的具体类型支持相等性比较。我们没有办法定义包含结构体、数组和接口的类型集,何况还要考虑可比较性,专门针对这种情况,Go引入了一个预定义的约束条件comparable。下述代码用来在一个切片中查找给定值的索引:

1
2
3
4
5
6
7
8
func Index[T comparable](s []T, x T) int {
for i, v := range s {
if v == x {
return i
}
}
return -1
}

好了,关于约束条件的介绍就到这里。有兴趣的同学可以阅读The Type Parameters Proposal,我们下一篇再见。