Go 博客

解构类型参数

Ian Lance Taylor
2023 年 9 月 26 日

slices 包函数签名

slices.Clone 函数非常简单:它复制任何类型的切片。

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

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

简单的 Clone 函数

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

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

泛型函数 Clone1 有一个类型参数 E。它接受一个类型为 E 的切片参数 s,并返回一个相同类型的切片。这个签名对于熟悉 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

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

灵活的 Clone 函数

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

我们知道它必须看起来像这样。

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

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

这里我将约束写成了 ?,但这只是一个占位符。要使其工作,我们需要编写一个约束,以便我们可以编写函数体。对于 Clone1,我们可以直接对元素类型使用 any 约束。对于 Clone2,这不起作用:我们想要求 s 是一个切片类型。

既然我们知道我们想要一个切片,那么 S 的约束必须是一个切片。我们不关心切片的元素类型是什么,所以我们就称之为 E,就像我们在 Clone1 中所做的那样。

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 MySliceMySlice2 的底层类型是 []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 约束为 comparable,这是 map 键类型所必需的。我们可以根据需要约束组件类型。

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

这表示 WithStrings 的参数必须是一个切片类型,其元素类型具有 String 方法。

由于所有 Go 类型都可以由组件类型构建而成,因此我们总是可以使用类型参数来解构这些类型并根据需要对其进行约束。

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