Go 博客

解构类型参数

Ian Lance Taylor
2023 年 9 月 26 日

slices 包函数签名

The slices.Clone 函数非常简单:它会创建一个任意类型切片的副本。

func Clone[S ~[]E, E any](s S) S {
    return append(s[:0:0], s...)
}

这是因为向一个容量为零的切片追加元素会分配一个新的底层数组。函数体最终比函数签名更短,部分原因是函数体很短,但也因为签名很长。在这篇博文中,我们将解释为什么签名是这样写的。

简单克隆

我们将从编写一个简单的泛型 Clone 函数开始。这不是 slices 包中的函数。我们想要获取任意元素类型的切片,并返回一个新的切片。

func Clone1[E any](s []E) []E {
    // body omitted
}

泛型函数 Clone1 只有一个类型参数 E。它接受一个参数 s,其类型为 E 的切片,并返回相同类型的切片。对于任何熟悉 Go 中泛型的用户来说,这个签名都很直观。

但是,存在一个问题。命名切片类型在 Go 中并不常见,但人们确实会使用它们。

// MySlice is a slice of strings with a special String method.
type MySlice []string

// String returns the printable version of a MySlice value.
func (s MySlice) String() string {
    return strings.Join(s, "+")
}

假设我们想要创建一个 MySlice 的副本,然后获取其可打印版本,但字符串按排序顺序排列。

func PrintSorted(ms MySlice) string {
    c := Clone1(ms)
    slices.Sort(c)
    return c.String() // FAILS TO COMPILE
}

不幸的是,这不起作用。编译器报告了一个错误

c.String undefined (type []string has no field or method String)

如果我们通过用类型实参替换类型参数来手动实例化 Clone1,就可以看到问题所在。

func InstantiatedClone1(s []string) []string

The Go 赋值规则 允许我们将类型为 MySlice 的值传递给类型为 []string 的参数,因此调用 Clone1 是可以的。但 Clone1 将返回类型为 []string 的值,而不是类型为 MySlice 的值。类型 []string 没有 String 方法,因此编译器报告了一个错误。

灵活克隆

为了解决此问题,我们必须编写一个版本的 Clone,它返回与其参数相同类型的返回值。如果我们可以做到这一点,那么当我们用类型为 MySlice 的值调用 Clone 时,它将返回类型为 MySlice 的结果。

我们知道它必须类似于这样。

func Clone2[S ?](s S) S // INVALID

Clone2 函数返回的值与其参数类型相同。

在这里,我将约束写为 ?,但这只是一个占位符。为了使它工作,我们需要编写一个约束,让我们能够编写函数体。对于 Clone1,我们只需对元素类型使用 any 约束即可。对于 Clone2,这将不起作用:我们希望要求 s 为切片类型。

由于我们知道我们想要一个切片,因此 S 的约束必须是一个切片。我们不关心切片元素类型是什么,所以让我们像 Clone1 一样将其称为 E

func Clone3[S []E](s S) S // INVALID

这仍然无效,因为我们尚未声明 EE 的类型实参可以是任何类型,这意味着它也必须是类型参数本身。由于它可以是任何类型,因此其约束为 any

func Clone4[S []E, E any](s S) S

这越来越接近了,至少它可以编译,但我们还没有完全达到目标。如果我们编译此版本,当我们调用 Clone4(ms) 时,会收到错误。

MySlice does not satisfy []string (possibly missing ~ for []string in []string)

编译器告诉我们,我们无法将类型实参 MySlice 用于类型参数 S,因为 MySlice 不满足约束 []E。这是因为 []E 作为约束仅允许切片类型字面量,例如 []string。它不允许像 MySlice 这样的命名类型。

底层类型约束

如错误消息所示,答案是添加一个 ~

func Clone5[S ~[]E, E any](s S) S

重复一遍,编写类型参数和约束 [S []E, E any] 表示 S 的类型实参可以是任何未命名的切片类型,但不能是定义为切片字面量的命名类型。编写 [S ~[]E, E any],带有一个 ~,表示 S 的类型实参可以是任何其底层类型为切片类型的类型。

对于任何命名类型 type T1 T2T1 的底层类型是 T2 的底层类型。像 int 这样的预声明类型或像 []string 这样的类型字面量的底层类型就是类型本身。有关确切的细节,请参阅语言规范。在我们的示例中,MySlice 的底层类型是 []string

由于 MySlice 的底层类型是切片,因此我们可以将类型为 MySlice 的参数传递给 Clone5。您可能已经注意到,Clone5 的签名与 slices.Clone 的签名相同。我们终于到达了我们想要到达的地方。

在我们继续之前,让我们讨论一下为什么 Go 语法需要一个 ~。可能看起来我们总是希望允许传递 MySlice,那么为什么不将其设为默认值呢?或者,如果我们需要支持精确匹配,为什么不反过来,这样 []E 的约束允许命名类型,而例如 =[]E 的约束仅允许切片类型字面量呢?

为了解释这一点,让我们首先观察到像 [T ~MySlice] 这样的类型参数列表没有意义。这是因为 MySlice 不是任何其他类型的底层类型。例如,如果我们有像 type MySlice2 MySlice 这样的定义,则 MySlice2 的底层类型是 []string,而不是 MySlice。因此,[T ~MySlice] 要么不允许任何类型,要么与 [T MySlice] 相同,并且仅匹配 MySlice。无论哪种方式,[T ~MySlice] 都是没有用的。为了避免这种混淆,语言禁止使用 [T ~MySlice],并且编译器会产生类似以下的错误

invalid use of ~ (underlying type of MySlice is []string)

如果 Go 不需要波浪号,以便 [S []E] 匹配任何其底层类型为 []E 的类型,那么我们将必须定义 [S MySlice] 的含义。

我们可以禁止 [S MySlice],或者我们可以说 [S MySlice] 仅匹配 MySlice,但这两种方法都会遇到预声明类型的麻烦。像 int 这样的预声明类型是其自身的底层类型。我们希望允许人们能够编写接受任何其底层类型为 int 的类型实参的约束。在今天的语言中,他们可以通过编写 [T ~int] 来做到这一点。如果我们不需要波浪号,我们仍然需要一种方法来说“任何其底层类型为 int 的类型”。自然的说法是 [T int]。这意味着 [T MySlice][T int] 的行为将不同,尽管它们看起来非常相似。

我们也许可以说 [S MySlice] 匹配任何其底层类型为 MySlice 的底层类型的类型,但这使得 [S MySlice] 变得不必要且令人困惑。

我们认为最好要求使用 ~ 并非常清楚地说明何时匹配底层类型而不是类型本身。

类型推断

现在我们已经解释了 slices.Clone 的签名,让我们看看如何通过类型推断简化 slices.Clone 的实际使用。请记住,Clone 的签名是

func Clone[S ~[]E, E any](s S) S

slices.Clone 的调用会将一个切片传递给参数 s。简单的类型推断将允许编译器推断类型参数 S 的类型实参是传递给 Clone 的切片的类型。然后,类型推断功能强大到足以看出 E 的类型实参是传递给 S 的类型实参的元素类型。

这意味着我们可以编写

    c := Clone(ms)

而无需编写

    c := Clone[MySlice, string](ms)

如果我们引用 Clone 而不调用它,我们确实必须为 S 指定类型实参,因为编译器没有任何可以用来推断它的东西。幸运的是,在这种情况下,类型推断能够从 S 的实参推断出 E 的类型实参,我们不必单独指定它。

也就是说,我们可以编写

    myClone := Clone[MySlice]

而无需编写

    myClone := Clone[MySlice, string]

解构类型参数

我们在这里使用的通用技术,其中我们使用另一个类型参数 E 定义一个类型参数 S,是解构泛型函数签名中的类型的一种方法。通过解构类型,我们可以命名并约束类型的各个方面。

例如,以下是 maps.Clone 的签名。

func Clone[M ~map[K]V, K comparable, V any](m M) M

就像 slices.Clone 一样,我们使用一个类型参数来表示参数 m 的类型,然后使用另外两个类型参数 KV 来解构类型。

maps.Clone 中,我们将 K 约束为可比较的,这是映射键类型所必需的。我们可以根据需要约束组件类型。

func WithStrings[S ~[]E, E interface { String() string }](s S) (S, []string)

这意味着 WithStrings 的参数必须是切片类型,其元素类型具有 String 方法。

由于所有 Go 类型都可以从组件类型构建,因此我们始终可以使用类型参数来解构这些类型并根据需要约束它们。

下一篇文章:关于类型推断的一切 - 以及更多
上一篇文章:修复 Go 1.22 中的 For 循环
博客索引