英文原文

https://go.dev/blog/deconstructing-type-parameters

笔记

slices.Clone函数很简单,它可以拷贝任何类型slice。

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

s[:0:0]分配了一个新的底层数组,来容纳s的值。这篇文章主要是探讨为什么这个泛型函数的方法签名是这么定义的。

简单的Clone方法

如果我们拷贝一个泛型的slice,可能会怎么写?

func Clone1[E any](s []E) []E{
    // 忽略方法体
}

该泛型函数Clone1有一个单独的类型参数E。它的输入是一个E的slice,输出是一个E的slice,而E代表任何类型。看过去很直观。

但是有个问题Named slice虽然在Go中比较少用,但是确认有人这么写。

// 以下是string的slice,带有个方法String
type MySlice []string

func (s MySlice) String() string {
    return strings.Join(s, "+")
}

如果我们想要拷贝一个MySlice并获取一个printable版本,该版本的输出需要先排序好。

func PrintSorted(ms MySlice) string{
    c := Clone1(ms)
    slices.Sort(c)
    return c.String() // 这里会编译失败 
    // c.String undefined (type []string has no field or method String)
}

根据Go assignment rules,go允许我们将类型为MySlice的值传递给类型为[]string的参数,所以调用Clone1是可以的。但是Clone1将会返回一个类型为[]string的值,而不是类型为MySlice的值。而类型[]string并没有String方法,所以编译失败了。实际上Clone1返回的类型和输入的类型并不相同。

弹性的Clone方法

为了解决这个问题,需要编写一个Clone返回值和输入参数一样的类型。这样我们才能说,我们Clone了一个MySlice,它就会返回一个MySlice.

所以Clone可能这么写

// 此处?表示可能的占位符,并不是实际的泛型约束
func Clone2[S ?](s S) S 

此时编译器会提示错误信息type parameters must be named

由于我们知道我们想要操作的是个slice,且slice中的值的类型可以是任意类型,姑且称之为E。那么方法签名就会变成

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

此时编译器会提示错误信息undefined: EcompilerUndeclaredName

以上签名还是非法的,因为没有声明E。由于E可以是任意类型,方法签名就可以变为

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

该方法签名本身编译不会报错,但是当我们调用Clone4的时候,编译器会提示错误信息MySlice does not satisfy []string (possibly missing ~ for []string in []string)

func PrintSorted(ms MySlice) string {
    // MySlice does not satisfy []string (possibly missing ~ for []string in []string)
    c := Clone4(ms)
    slices.Sort(c)
    return c.String()
}

这个提示告诉我们,MySlice不符合约束[]E。因为[]E作为一个约束,只允许slice类型本身,比如[]string。 它并不允许一个命名的类型(named type)比如MySlice

底层类型约束(Underlying type constraints)

按照错误提示,我们可以添加上~,那么方法签名就变成了

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

加上~之后有什么区别呢?

带上~后,意味着该类型参数S可以是任意底层类型是一个slice的类型,比如MySlice。可以查看underlying types spec

为什么Go语法需要~呢?我们总要允许传递MySlice,因此为什么不让([]E)默认就是底层类型呢?或者,如果说在我们需要明确的类型匹配的时候,使用类似=[]E的语法,只精确地允许该类型。

为了解释这个问题,我们首先发现一个type parameter列表如[T ~MySlice]是没有意义的。这是因为MySlice不是任何其他类型的underlying type。例如,如果我们有一个定义如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]的含义,这表示底层类型为MySlice的所有类型?还是MySlice本身呢?

我们可以禁用[S MySlice]这样的Named type,或者我们可以说[S MySlice]只匹配MySlice,但是这两种方式在碰到预定义的类型的时候都会有问题。一个预定义类型,如int是它本身的底层类型。我们想要允许人们可以编写约束,可以接受任何底层类型为int的类型参数。按照目前的Go语言,可以这么写[T ~int]。如果我们不需要~,我们仍然需要有一种方式来表达任何底层类型为int的类型。最自然的方式是[T int]。这也就意味着[T MySlice][T int]将会有不同的行为,即便他俩看起来很类似。

我们可能可以说[S MySlice]匹配一种类型,该类型表示底层类型和MySlice的底层类型是一样的,但是这让[S MySlice]变得没有必要且让人困惑。

所以最好还是要有~,在我们需要匹配底层类型,而非该类型本身的时候,可以更清晰一点。

类型推断(Type inference)

既然我们已经解释清楚了slices.Clone这个方法签名,那么我们可以看看实际使用slices.Clone的时候,type inference怎么帮助我们简化代码。

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

一个slices.Clone的调用会传递一个slice给参数s。简单的type inference将会让编译器会从传递给该函数的参数的类型来推导出类型参数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来定义该类型。

由于Go类型可以从components types构建,我们也可以使用类型参数来解构这些类型,并按照我们的需要约束它们。

相关代码

demo code