Ian Lance Taylor 2023-09-26 在 Go 官方博客发表了 Deconstructing Type Parameters,从标准库 slices.Clone 那个看起来有点绕的签名讲起,一步步推导它为什么要写成 func Clone[S ~[]E, E any](s S) S,顺带解释 ~ 约束和类型推断的设计取舍。

从 slices.Clone 说起#

slices.Clone 函数很简单:拷贝一个 slice,元素类型任意。

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

s[:0:0] 会让 append 分配一个新的底层数组来容纳 s 的元素。原文要回答的问题是:这个泛型函数的签名为什么要定义成这样。

朴素版 Clone#

如果自己写一个泛型的 slice 拷贝,第一反应大概是:

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

Clone1 只有一个类型参数 E:输入一个 E 的 slice,输出一个 E 的 slice,E 可以是任何类型,看上去很直观。

但 named slice type 虽然在 Go 里不算常见,确实有人这么写:

// 一个 string slice,带一个 String 方法
type MySlice []string

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

假设我们想拷贝一个 MySlice,排序后再拿它的可打印形式:

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,类型为 MySlice 的值可以传给类型为 []string 的参数,所以调用 Clone1 没问题。但 Clone1 返回的是 []string 而不是 MySlice[]string 又没有 String 方法,编译就失败了——Clone1 返回的类型和输入的类型并不相同。

更灵活的 Clone#

要解决这个问题,Clone 的返回值类型必须和参数类型一致:传入 MySlice,就返回 MySlice。顺着这个思路,签名「想要」的形状是:

// ? 只是示意的占位符,不是合法的 Go 语法
func Clone2[S ?](s S) S

这不是合法的 Go——类型参数必须有一个能写出来的约束。我们知道 S 应该是一个 slice,元素类型任意,姑且叫它 E

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

这个签名仍然非法,因为 E 还没有声明。补上 E any

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

签名本身能编译通过了,但拿 MySlice 去调用时,编译器会报错:

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),不允许 MySlice 这样的 named type。

底层类型约束(underlying type constraints)#

按报错提示加上 ~

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

加上 ~ 之后,S 可以是任何底层类型为 []E 的类型,MySlice 就在其中,见 underlying types spec

那为什么 Go 需要 ~?既然总要允许传 MySlice,为什么不让 []E 默认就按底层类型匹配,需要精确匹配时再用类似 =[]E 的语法?

原文的解释分两步。首先,[T ~MySlice] 这样的写法没有意义:MySlice 不是任何类型的底层类型——就算定义 type MySlice2 MySliceMySlice2 的底层类型也是 []string 而不是 MySlice[T ~MySlice] 匹配不到任何类型,所以语言干脆禁止这种写法,编译器会报:

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

其次,如果 Go 不要 ~、让 [S []E] 默认匹配任意底层类型为 []E 的类型,那就得给 [S MySlice] 定一个含义:是匹配底层类型为 MySlice 的所有类型,还是只匹配 MySlice 本身?两种选择在预声明类型(predeclared types)上都会出问题:int 的底层类型是它自己,我们希望能写出「接受任何底层类型为 int 的类型」这样的约束,现在的写法是 [T ~int];如果没有 ~,最自然的写法就是 [T int]——于是 [T MySlice][T int] 两个长得一样的写法会有不同的行为。也可以规定 [S MySlice] 匹配「底层类型和 MySlice 的底层类型相同」的类型,但这会让 [S MySlice] 本身变得多余且令人困惑。

所以结论是:保留 ~,在需要按底层类型匹配时显式写出来,更清晰。

类型推断(type inference)#

签名解释清楚了,再看实际调用 slices.Clone 时,类型推断怎么简化代码:

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

调用时会传给参数 s 一个 slice,编译器可以从实参的类型推导出 S。于是可以直接写

c := Clone(ms)

而不必写

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

如果只是引用 Clone 而不调用,就没有实参可供推断,需要显式指定 S;但 E 可以从 S 推导出来,所以写

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 约束参数 m,再用 KV 两个类型参数解构 map 的键和值的类型。

Go 的类型都由组成类型(component types)构建,所以总能用类型参数把它们解构开,按需要施加约束。

参考资料#