Go语言类型参数深度解析:从切片克隆到泛型解构

本文深入解析Go语言泛型中的类型参数机制,通过切片克隆函数的具体实现,详细探讨了类型约束、底层类型匹配和类型推断等核心概念,帮助开发者掌握泛型设计的精髓。

分解与解释类型参数

太长不看版

由于所有Go类型都可以从组件类型构建而成,我们总是可以使用类型参数来解构这些类型,并按照我们的意愿进行约束。

提到的公司/包

slices包函数签名

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

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

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

简单克隆版本

我们首先编写一个简单的通用Clone函数。这不是slices包中的那个版本。我们希望接受任何元素类型的切片,并返回一个新切片。

1
2
3
func Clone1[E any](s []E) []E {
    // 省略函数体
}

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

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

1
2
3
4
5
6
7
// MySlice是一个具有特殊String方法的字符串切片
type MySlice []string

// String返回MySlice值的可打印版本
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() // 编译失败
}

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

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 // 无效

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

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

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

1
func Clone3[S []E](s S) S // 无效

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

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

这已经很接近了,至少它会编译,但我们还没有完全达到目标。如果我们编译这个版本,在调用Clone4(ms)时会得到一个错误。

1
MySlice不满足[]string(可能在[]string中缺少~用于[]string)

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

底层类型约束

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

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

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

对于任何命名类型type T1 T2,T1的底层类型是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],编译器会产生类似这样的错误:

1
无效使用~(MySlice的底层类型是[]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会将一个切片传递给参数s。简单的类型推断让编译器推断出类型参数S的类型实参是传递给Clone的切片的类型。然后类型推断足够强大,可以看到类型参数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约束为可比较的,这是映射键类型所要求的。我们可以以任何我们喜欢的方式约束组件类型。

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

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

由于所有Go类型都可以从组件类型构建而成,我们总是可以使用类型参数来解构这些类型,并按照我们的意愿进行约束。

Ian Lance Taylor

照片由Robin Jonathan Deutsch在Unsplash上提供。本文可在The Go博客上获得,采用CC BY 4.0 DEED许可证。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计