解构类型参数

这篇文章是对 Go 官方博客 Deconstructing Type Parameters 的翻译. 所使用的 Go 版本为 1.21.

Slice 包的函数签名

slice.Clone 函数非常的简单,它复制了任何类型的 slice

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

上面的方法是可行的, 因为向零容量的 slice 追加元素会分配一个新的底层数组。我们可以看到函数体的长度竟然比函数签名还要短,这不仅因为函数体本身很简短,也因为签名有点长。在这篇博文中,我们将解释为什么签名是这样写的。

简单的复制

我们来先写一个简单的泛型 Clone 函数。这不是 slices 包中的那个。我们想要一个可以接受任意元素类型的 slice,并返回一个新的 slice。

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

带有单个类型参数E的泛型函数Clone1有一个参数s,它是一个E类型的 slice,并返回同样类型的 slice。对于熟悉 Go 泛型的人来说,这个签名很简单明了。

然而,这里有一个问题。在 Go 中命名的 slice 类型不常见, 但确实有人在使用。

1
2
3
4
5
6
7
// 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, 然后获取其可打印版本, 但是字符串按排序顺序排列。

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

不幸的是这段代码无法正常工作,编译器会给出错误:

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

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

1
func InstantiatedClone1(s []string) []string

Go 的赋值规则允许我们将 MySlice 类型的值传递给 []string 类型的参数,所以调用 Clone1 没问题。但是 Clone1 会返回一个 []string 类型的值,而不是 MySlice 类型的值。[]string 类型没有 String 方法,所以编译器会报错。

灵活的复制

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

我们知道它的样子大致如下

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

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

这里我用 ? 写了一个约束条件, 但是那只是一个占位符。为了使这个函数工作, 我们需要编写一个约束条件, 以便编写函数体。对于 Clone1,我们可以对元素类型使用 any 约束。但对于 Clone2 这行不通: 我们需要 s 是 slice 类型。

由于我们知道需要一个 slice,所以 S 的约束条件必须是一个 slice。我们不关心 slice 元素类型是什么,所以像在 Clone1 中一样,我们称它为 E

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

这仍然无效,因为我们还没有声明 EE 的类型参数可以是任何类型, 这意味着它本身也必须是一个类型参数。由于它可以是任何类型,所以它的约束条件是 any

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

这已经很接近了,至少它可以编译通过, 但我们还没完全搞定。如果我们编译这个版本, 在调用 Clone4(ms) 时会产生错误。

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

编译器告诉我们,不能为类型参数 S 使用类型实参 MySlice,因为 MySlice 不满足约束条件 []E。这是因为作为约束条件的 []E 只允许字面量 slice 类型,如 []string。它不允许命名类型如 MySlice

基础类型约束

正如错误消息所暗示的,答案是添加一个 ~ 符号。

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

再重复一遍,编写类型参数和约束条件 [S []E, E any] 意味着 S 的类型实参可以是任何未命名的 slice 类型, 但不能是定义为 slice 字面量的命名类型。

编写 [S ~[]E, E any], 带有 ~ 符号,意味着 S 的类型实参可以是任何基础类型为 slice 类型的类型。

对于任何命名类型 type T1 T2,T1 的基础类型就是 T2 的基础类型。预声明类型如 int 或字面量类型如 []string 的基础类型就是它们自己。详细信息请参阅语言规范。在我们的示例中, MySlice 的基础类型是 []string

由于 MySlice 的基础类型是一个 slice, 所以我们可以将 MySlice 类型的参数传递给 Clone5。你可能已经注意到, Clone5 的签名与 slices.Clone 的签名相同。我们终于得到了我们想要的结果。

在继续之前,让我们讨论一下为什么 Go 语法要求使用 ~。可能看起来我们总是希望允许传递 MySlice,那么为什么不将其作为默认值呢?或者,如果我们需要支持精确匹配,为什么不把事情翻转过来,使得约束条件 []E 允许命名类型,而约束条件 =[]E 仅允许 slice 类型字面量?

为了解释这一点, 我们先观察到像 [T ~MySlice] 这样的类型参数列表是没有意义的。这是因为 MySlice 不是任何其他类型的基础类型。

例如, 如果我们有这样的定义 type MySlice2 MySlice, 那么 MySlice2 的基础类型是 []string, 而不是 MySlice。因此, [T ~MySlice] 要么根本不允许任何类型, 要么与 [T MySlice] 相同, 仅匹配 MySlice。无论哪种方式, [T ~MySlice] 都没有用。为了避免这种困惑, 语言禁止 [T ~MySlice], 编译器会产生像下面这样的错误

1
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 的签名是:

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

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

这意味着我们可以写:

1
c := Clone(ms)

而不必写:

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

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

也就是说,我们可以写:

1
myClone := Clone[MySlice]

而不必写:

1
myClone := Clone[MySlice, string]

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

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

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

与 slices.Clone 一样,我们使用类型参数来指定参数 m 的类型,然后使用另外两个类型参数 K 和 V 来解构类型。 在 maps.Clone 中,我们将 K 约束为可比较的,因为这是 map 键类型所必需的。我们可以根据需要以任何方式约束基础类型。

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

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

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


解构类型参数
https://blog.zhangliangliang.cc/post/deconstruction-type-parameters.html
作者
Bobby Zhang
发布于
2023年11月22日
许可协议