Go | (类型)集合的乐趣

Jul 13, 2025
#go

Notion链接

💡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 {
    int
}

非常直接!它看起来就像一个普通的接口定义,只不过不是包含方法元素,而是包含一个类型元素(type element),由一个命名类型组成。在这个例子中,这个命名类型是 int。

使用类型集合约束

我们如何使用这样的约束呢?那我们来写一个 Double 函数:

func Double[T OnlyInt](v T) T {
    return v * 2
}

换句话说,对于满足 OnlyInt 约束的某个类型 T,Double 接受一个 T 类型的参数并返回一个 T 类型的结果。

注意,我们现在有了一个解决方案,针对之前尝试编写 AddAnything 函数时遇到的问题:如何在参数化函数中启用 * 运算符(或其他算术运算符)。由于 T 只能是 int(得益于 OnlyInt 约束),Go 可以保证 * 运算符能用于 T 类型的值。

不过,这还不是完整的答案,因为还有其他支持 * 运算符的类型,它们不会被此约束允许。而且,如果我们只打算支持 int,也完全可以写一个接受 int 参数的普通函数。

因此,我们需要能够稍微扩大约束允许的类型范围,但不能超出支持 * 运算符的类型。我们该如何做到这点呢?

联合类型

哪些类型能满足约束 OnlyInt?答案是,只有 int!为了扩大这个范围,我们可以创建一个指定多个命名类型的约束:

type Integer interface {
    int | int8 | int16 | int32 | int64
}

这些类型由管道符号(|)分隔。你可以把它理解为表示“或”的关系。换句话说,如果一个类型是 int 或 int8 或……,那么它就满足这个约束。

这种接口元素称为联合(union)。联合中的类型元素可以包含任意 Go 类型,包括接口类型。

它甚至可以包含其他约束。换言之,我们可以从已有的约束组合(compose)出新的约束,比如:

type Float interface {
    float32 | float64
}

type Complex interface {
    complex64 | complex128
}

type Number interface {
    Integer | Float | Complex
}

我们说 Integer、Float 和 Complex 都是不同内置数值类型的联合,同时我们也创建了一个新的约束 Number,它是这三个接口类型的联合。只要是整数、浮点数或复数,那它就是一个数字!

所有允许类型的集合

约束的类型集合(type set)是满足该约束的所有类型的集合。空接口(any)的类型集合就是所有类型的集合,正如你所预期的那样。

联合元素(例如前面例子中的 Float)的类型集合是其所有成员类型集合的联合。

在 Float 例子中,它是 float32 | float64 的联合,其类型集合包含 float32float64,且不包含其他类型。

交集

你可能知道,对于一个基本接口,一个类型必须实现该接口中列出的所有方法。如果接口中包含其他接口,一个类型必须实现所有这些接口,而不仅仅是其中一个。

例如:

type ReaderStringer interface {
    io.Reader
    fmt.Stringer
}

如果我们把它写成接口字面量(interface literal),方法之间用分号隔开,而不是换行,但含义一样:

interface { io.Reader; fmt.Stringer }

要实现这个接口,类型必须同时实现 io.Reader 和 fmt.Stringer,单独实现其中一个是不够的。

接口定义中的每一行都被视为一个独立的类型元素。接口的类型集合是所有元素类型集合的交集。也就是说,只有那些所有元素都共有的类型才属于该接口的类型集合。

因此,将接口元素写在不同的行上,实际上要求类型必须实现所有这些元素。我们不常用这种接口,但可以想象某些场景中它是必要的。

空类型集合

你可能会想,如果我们定义了一个类型集合完全为空的接口会发生什么。也就是说,没有任何类型能满足该约束。

这种情况确实有可能发生,比如两个类型集合做交集,但它们没有任何共同元素。例如:

type Unpossible interface {
    int
    string
}

显然,没有任何类型可以同时是 int 和 string!换句话说,这个接口的类型集合是空的。

如果我们尝试实例化一个受 Unpossible 约束限制的函数,自然会发现无法完成:

cannot implement Unpossible (empty type set)

我们大概不会故意这么做,因为无法满足的约束似乎没什么用。但在更复杂的接口中,我们可能无意中将允许的类型集合缩减到零,这时了解这个错误信息的含义有助于我们解决问题。

复合类型字面量

复合类型是由其他类型构建而成的类型。我们在之前的教程中见过一些复合类型,比如 []E,它是元素类型为 E 的切片。

但我们不仅限于带有名称的已定义类型。我们也可以使用类型字面量(type literal)动态构造新类型:即直接把类型定义写成接口的一部分。

举个例子,这个接口指定了一个 struct 类型字面量:

type Pointish interface {
    struct{ X, Y int }
}

具有此约束的类型参数允许任何该结构体的实例。换句话说,其类型集合正好包含一个类型:struct{ X, Y int }

访问结构体字段

虽然我们可以编写受某些结构体类型(如 Pointish)约束的泛型函数,但该函数对该类型能做的事情存在限制。其中之一是它无法访问结构体的字段:

func GetX[T Pointish](p T) int {
    return p.X
}
// p.X 未定义(类型 T 没有字段或方法 X)

换句话说,我们无法访问参数 p 的字段,尽管函数的约束明确说明任何 p 都保证是至少包含字段 X 的结构体。这是 Go 编译器的一个限制,目前尚未解决。对此表示抱歉。

类型集合的一些限制

包含类型元素的接口只能用作类型参数的约束。它不能像基本接口那样,用作变量或参数声明的类型。这一点未来可能会有所改变,但目前情况就是这样。

那么,究竟是什么阻止了我们这样做呢?我们已经知道可以编写接受一些基本接口类型(如 Stringer)作为普通参数的函数。那么如果尝试用包含类型元素的接口(例如 Number)做同样的事情,会发生什么呢?

来看一个例子:

func Double(p Number) Number {
    // interface contains type constraints
}

这段代码无法编译,原因正如我们之前讨论的。这里可能产生一些混淆:基本接口既可以用作普通接口类型,也可以用作类型参数的约束;但包含类型元素的接口只能用作约束,不能用作普通接口类型。

  • 基本接口(basic interface)

    指的是不包含类型元素(type elements)的接口,比如:

    type Stringer interface {
        String() string
    }
    
  • 接口中的类型元素(type elements)

    是 Go 1.18 及以后版本引入的机制,允许接口表达类型约束,例如:

    type Number interface {
        int | float64
    }
    

    这是一个类型集合,表示所有满足约束的类型。

1. 基本接口可以双重身份:接口类型和约束

基础接口具有双重身份:

  • 接口类型:可以用来声明变量、函数参数或返回值的具体类型。

    func PrintString(s Stringer) {
        fmt.Println(s.String())
    }
    
    
  • 类型约束:也可以用于泛型类型参数约束,告诉编译器此泛型参数需要满足某个接口。

    func PrintAll[T Stringer](items []T) {
        for _, item := range items {
            fmt.Println(item.String())
        }
    }
    
    

这里,Stringer 既是一个接口类型(能用作变量或参数类型),又是一个类型约束(能用于泛型约束)。

2. 含有类型元素的接口只能用作约束,不能作为普通接口类型

接口如果包含类型元素(类型集合), 就只能作为类型参数的约束,不能用作普通接口类型进行变量声明或函数参数传递。

原因如下:

  • 接口表示“类型集合”而非单一类型

    基本接口定义的是一组类型应该实现哪些方法(行为),所以它是一个类型描述,可以对应一个动态类型的接口值;

    而含有类型元素的接口,是描述一个“集合类型”,例如:

    type Number interface {
        int | float64
    }
    

    这意味着 Number 表示两种不同类型的集合——intfloat64

    这不是一个具体的类型,而是一个编译期的类型约束

  • 运行时无法表示这种集合类型

    在 Go 语言的运行时,接口类型变量有一个具体的实现类型和值;但是集合类型描述的是一个范围、一组类型,而不是单一的动态类型。这使得所谓的“Number 类型变量”在运行时是不明确或不存在的。

  • 编译器限制

    因此,Go 编译器拒绝将带有类型元素的接口用作普通接口类型,即不能像基本接口那样声明变量或函数参数。

3. 具体对比示例

// 基本接口,可以当作类型使用
type Stringer interface {
    String() string
}

func PrintString(s Stringer) {
    fmt.Println(s.String())
}

// 含类型元素的接口,只能用作约束
type Number interface {
    int | float64
}

// 不能这样用(会报错)
func Double(n Number) Number {
    // 编译失败,类型 Number 不能用作参数类型
}

// 正确的用法,作为类型参数约束
func DoubleGeneric[T Number](n T) T {
    return n + n
}

总结

接口类型用作变量/参数类型(变量声明)用作泛型约束(类型参数约束)
基本接口支持支持
含类型元素的接口不支持支持

约束不是类

如果你有一些使用类(类指类型的层级体系)的语言经验,那么在 Go 泛型中有一点可能会让你困惑:约束并不是类,你不能基于约束接口实例化泛型函数或类型。

举例来说,假设我们有两个具体类型 Cow 和 Chicken:

type Cow struct{ moo string }

type Chicken struct{ cluck string }

然后假设我们定义了一个接口 Animal,它的类型集合由 Cow 和 Chicken 组成:

type Animal interface {
    Cow | Chicken
}

到目前为止,一切都很好。现在假设我们定义了一个泛型类型 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
https://inasa.dev/posts/rss.xml