Go | (类型)集合的乐趣
💡A set is a Many that allows itself to be >>thought of as a One.
—Georg Cantor, quoted in Rudy Rucker’s “Infinity and the Mind”
泛型编程的要点是能够编写操作多于一种具体数据类型的代码。这样一来,我们就不必重复编写相同的代码,一遍又一遍,为每种需要处理的数据类型各写一份代码。
但是对数据类型过于自由和宽松也会走得太远:接受字面上任何类型数据的类型参数并不太有用。我们需要约束(constraints)来缩小一个函数能处理的类型集合。当类型集合是无限的(例如 [T any]),我们几乎无法对这些值做任何事情,因为我们对它们一无所知。
那么,我们如何编写更灵活的约束,使得它们所包含的类型集合既足够广泛以实用,又足够狭窄以便可用呢?
我们已经知道,一种接口可以通过列出方法元素(method elements)来指定允许的类型范围,比如 String()
这个字符串方法。我们将使用“基本接口”(basic interface)一词来描述只包含方法元素的接口,但现在让我们引入另一种接口。它不是列出类型必须拥有的方法,而是直接指定允许的类型集合。
类型元素
例如,假设我们想写一个泛型函数 Double,将一个数字乘以二,并且我们想要一个类型约束,只允许 int 类型的值。我们知道 int 没有任何方法,所以不能用任何基本接口作为约束。那么我们该如何写呢?
好吧,方法如下:
type OnlyInt interface
非常直接!它看起来就像一个普通的接口定义,只不过不是包含方法元素,而是包含一个类型元素(type element),由一个命名类型组成。在这个例子中,这个命名类型是 int。
使用类型集合约束
我们如何使用这样的约束呢?那我们来写一个 Double 函数:
func Double[T OnlyInt](v T) T
换句话说,对于满足 OnlyInt 约束的某个类型 T,Double 接受一个 T 类型的参数并返回一个 T 类型的结果。
注意,我们现在有了一个解决方案,针对之前尝试编写 AddAnything 函数时遇到的问题:如何在参数化函数中启用 *
运算符(或其他算术运算符)。由于 T 只能是 int(得益于 OnlyInt 约束),Go 可以保证 *
运算符能用于 T 类型的值。
不过,这还不是完整的答案,因为还有其他支持 *
运算符的类型,它们不会被此约束允许。而且,如果我们只打算支持 int,也完全可以写一个接受 int 参数的普通函数。
因此,我们需要能够稍微扩大约束允许的类型范围,但不能超出支持 *
运算符的类型。我们该如何做到这点呢?
联合类型
哪些类型能满足约束 OnlyInt?答案是,只有 int!为了扩大这个范围,我们可以创建一个指定多个命名类型的约束:
type Integer interface
这些类型由管道符号(|
)分隔。你可以把它理解为表示“或”的关系。换句话说,如果一个类型是 int 或 int8 或……,那么它就满足这个约束。
这种接口元素称为联合(union)。联合中的类型元素可以包含任意 Go 类型,包括接口类型。
它甚至可以包含其他约束。换言之,我们可以从已有的约束组合(compose)出新的约束,比如:
type Float interface
type Complex interface
type Number interface
我们说 Integer、Float 和 Complex 都是不同内置数值类型的联合,同时我们也创建了一个新的约束 Number,它是这三个接口类型的联合。只要是整数、浮点数或复数,那它就是一个数字!
所有允许类型的集合
约束的类型集合(type set)是满足该约束的所有类型的集合。空接口(any)的类型集合就是所有类型的集合,正如你所预期的那样。
联合元素(例如前面例子中的 Float)的类型集合是其所有成员类型集合的联合。
在 Float 例子中,它是 float32 | float64
的联合,其类型集合包含 float32
、float64
,且不包含其他类型。
交集
你可能知道,对于一个基本接口,一个类型必须实现该接口中列出的所有方法。如果接口中包含其他接口,一个类型必须实现所有这些接口,而不仅仅是其中一个。
例如:
type ReaderStringer interface
如果我们把它写成接口字面量(interface literal),方法之间用分号隔开,而不是换行,但含义一样:
interface
要实现这个接口,类型必须同时实现 io.Reader 和 fmt.Stringer,单独实现其中一个是不够的。
接口定义中的每一行都被视为一个独立的类型元素。接口的类型集合是所有元素类型集合的交集。也就是说,只有那些所有元素都共有的类型才属于该接口的类型集合。
因此,将接口元素写在不同的行上,实际上要求类型必须实现所有这些元素。我们不常用这种接口,但可以想象某些场景中它是必要的。
空类型集合
你可能会想,如果我们定义了一个类型集合完全为空的接口会发生什么。也就是说,没有任何类型能满足该约束。
这种情况确实有可能发生,比如两个类型集合做交集,但它们没有任何共同元素。例如:
type Unpossible interface
显然,没有任何类型可以同时是 int 和 string!换句话说,这个接口的类型集合是空的。
如果我们尝试实例化一个受 Unpossible 约束限制的函数,自然会发现无法完成:
cannot implement Unpossible (empty type set)
我们大概不会故意这么做,因为无法满足的约束似乎没什么用。但在更复杂的接口中,我们可能无意中将允许的类型集合缩减到零,这时了解这个错误信息的含义有助于我们解决问题。
复合类型字面量
复合类型是由其他类型构建而成的类型。我们在之前的教程中见过一些复合类型,比如 []E
,它是元素类型为 E 的切片。
但我们不仅限于带有名称的已定义类型。我们也可以使用类型字面量(type literal)动态构造新类型:即直接把类型定义写成接口的一部分。
举个例子,这个接口指定了一个 struct 类型字面量:
type Pointish interface
具有此约束的类型参数允许任何该结构体的实例。换句话说,其类型集合正好包含一个类型:struct{ X, Y int }
。
访问结构体字段
虽然我们可以编写受某些结构体类型(如 Pointish)约束的泛型函数,但该函数对该类型能做的事情存在限制。其中之一是它无法访问结构体的字段:
func GetX[T Pointish](p T) int
// p.X 未定义(类型 T 没有字段或方法 X)
换句话说,我们无法访问参数 p 的字段,尽管函数的约束明确说明任何 p 都保证是至少包含字段 X 的结构体。这是 Go 编译器的一个限制,目前尚未解决。对此表示抱歉。
类型集合的一些限制
包含类型元素的接口只能用作类型参数的约束。它不能像基本接口那样,用作变量或参数声明的类型。这一点未来可能会有所改变,但目前情况就是这样。
那么,究竟是什么阻止了我们这样做呢?我们已经知道可以编写接受一些基本接口类型(如 Stringer)作为普通参数的函数。那么如果尝试用包含类型元素的接口(例如 Number)做同样的事情,会发生什么呢?
来看一个例子:
func Double(p Number) Number
这段代码无法编译,原因正如我们之前讨论的。这里可能产生一些混淆:基本接口既可以用作普通接口类型,也可以用作类型参数的约束;但包含类型元素的接口只能用作约束,不能用作普通接口类型。
-
基本接口(basic interface)
指的是不包含类型元素(type elements)的接口,比如:
type Stringer interface
-
接口中的类型元素(type elements)
是 Go 1.18 及以后版本引入的机制,允许接口表达类型约束,例如:
type Number interface
这是一个类型集合,表示所有满足约束的类型。
1. 基本接口可以双重身份:接口类型和约束
基础接口具有双重身份:
-
接口类型:可以用来声明变量、函数参数或返回值的具体类型。
func PrintString(s Stringer)
-
类型约束:也可以用于泛型类型参数约束,告诉编译器此泛型参数需要满足某个接口。
func PrintAll[T Stringer](items []T)
这里,Stringer
既是一个接口类型(能用作变量或参数类型),又是一个类型约束(能用于泛型约束)。
2. 含有类型元素的接口只能用作约束,不能作为普通接口类型
接口如果包含类型元素(类型集合), 就只能作为类型参数的约束,不能用作普通接口类型进行变量声明或函数参数传递。
原因如下:
-
接口表示“类型集合”而非单一类型
基本接口定义的是一组类型应该实现哪些方法(行为),所以它是一个类型描述,可以对应一个动态类型的接口值;
而含有类型元素的接口,是描述一个“集合类型”,例如:
type Number interface
这意味着
Number
表示两种不同类型的集合——int
和float64
。这不是一个具体的类型,而是一个编译期的类型约束。
-
运行时无法表示这种集合类型
在 Go 语言的运行时,接口类型变量有一个具体的实现类型和值;但是集合类型描述的是一个范围、一组类型,而不是单一的动态类型。这使得所谓的“Number 类型变量”在运行时是不明确或不存在的。
-
编译器限制
因此,Go 编译器拒绝将带有类型元素的接口用作普通接口类型,即不能像基本接口那样声明变量或函数参数。
3. 具体对比示例
// 基本接口,可以当作类型使用
type Stringer interface
func PrintString(s Stringer)
// 含类型元素的接口,只能用作约束
type Number interface
// 不能这样用(会报错)
func Double(n Number) Number
// 正确的用法,作为类型参数约束
func DoubleGeneric[T Number](n T) T
总结
接口类型 | 用作变量/参数类型(变量声明) | 用作泛型约束(类型参数约束) |
---|---|---|
基本接口 | 支持 | 支持 |
含类型元素的接口 | 不支持 | 支持 |
约束不是类
如果你有一些使用类(类指类型的层级体系)的语言经验,那么在 Go 泛型中有一点可能会让你困惑:约束并不是类,你不能基于约束接口实例化泛型函数或类型。
举例来说,假设我们有两个具体类型 Cow 和 Chicken:
type Cow struct
type Chicken struct
然后假设我们定义了一个接口 Animal,它的类型集合由 Cow 和 Chicken 组成:
type Animal interface
到目前为止,一切都很好。现在假设我们定义了一个泛型类型 Farm,它是类型参数 T Animal 的切片:
type Farm[T Animal] []T
由于我们知道 Animal 的类型集合包含且仅包含 Cow 和 Chicken,那么这两种类型中的任何一个都可以用来实例化 Farm:
dairy := Farm[Cow]
poultry := Farm[Chicken]
那 Animal 本身怎么样呢?我们可以创建一个 Farm[Animal] 吗?不可以,因为不存在 Animal 这样一个类型。它是类型约束,而不是类型,所以会报错:
mixed := Farm[Animal]
// interface contains type constraints
正如我们看到的,我们也不能将 Animal 用作某个变量的类型,或者普通函数的参数类型。只有基本接口可以这样使用,包含类型元素的接口不行。
- GPT-4.1 mini
- https://bitfieldconsulting.com/posts/type-sets