Go 博客
关于类型推导,您想知道的一切——以及更多
这是我在 2023 年圣迭戈 GopherCon 大会上关于类型推导的演讲的博客版本,为清晰起见略有扩展和编辑。
什么是类型推导?
维基百科对类型推导的定义如下:
类型推导是指在编译时自动推断表达式类型(部分或全部)的能力。编译器通常能够在没有显式类型注解的情况下推断出变量的类型或函数的类型签名。
这里的关键短语是“自动推断……表达式的类型”。Go 从一开始就支持基本形式的类型推导。
const x = expr // the type of x is the type of expr
var x = expr
x := expr
这些声明中没有给出显式类型,因此等号 (=
) 和 :=
左侧的常量和变量 x
的类型就是右侧相应初始化表达式的类型。我们说这些类型是从它们的初始化表达式(的类型)推导出来的。随着 Go 1.18 中泛型的引入,Go 的类型推导能力得到了显著扩展。
为什么要类型推导?
在非泛型 Go 代码中,省略类型效果最明显的是短变量声明。这种声明将类型推导和一点语法糖(可以省略 var
关键字的能力)结合成一个非常简洁的语句。考虑以下映射变量声明:
var m map[string]int = map[string]int{}
和
m := map[string]int{}
省略 :=
左侧的类型可以消除重复,同时提高可读性。
泛型 Go 代码有可能显著增加代码中出现的类型数量:如果没有类型推导,每个泛型函数和类型实例化都需要类型参数。能够省略它们变得更加重要。考虑使用来自新 slices 包的以下两个函数:
package slices
func BinarySearch[S ~[]E, E cmp.Ordered](x S, target E) (int, bool)
func Sort[S ~[]E, E cmp.Ordered](x S)
如果没有类型推导,调用 BinarySearch
和 Sort
需要显式的类型参数。
type List []int
var list List
slices.Sort[List, int](list)
index, found := slices.BinarySearch[List, int](list, 42)
我们不希望在每次这样的泛型函数调用中都重复 [List, int]
。有了类型推导,代码简化为:
type List []int
var list List
slices.Sort(list)
index, found := slices.BinarySearch(list, 42)
这样既干净又紧凑。事实上,它看起来与非泛型代码完全相同,而类型推导使得这一切成为可能。
重要的是,类型推导是一种可选机制:如果类型参数使代码更清晰,那么请随意写出来。
类型推导是一种类型模式匹配
推导会比较类型模式,其中类型模式是包含类型参数的类型。出于稍后显而易见的原因,类型参数有时也称为类型变量。类型模式匹配允许我们推断需要放入这些类型变量中的类型。让我们看一个简短的例子:
// From the slices package
// func Sort[S ~[]E, E cmp.Ordered](x S)
type List []int
var list List
slices.Sort(list)
Sort
函数调用将 list
变量作为函数参数传递给 slices.Sort
的参数 x
。因此,list
的类型(即 List
)必须与 x
的类型(即类型参数 S
)匹配。如果 S
的类型是 List
,则此赋值有效。实际上,赋值规则很复杂,但目前假设类型必须相同就足够了。
一旦我们推断出 S
的类型,我们就可以查看 S
的类型约束。它说(因为有波浪号 ~
符号)S
的底层类型必须是切片 []E
。S
的底层类型是 []int
,因此 []int
必须与 []E
匹配,这样我们就可以得出结论 E
必须是 int
。我们已经找到了 S
和 E
的类型,使得对应的类型匹配。推导成功!
这是一个更复杂的场景,其中有许多类型参数:来自 slices.EqualFunc
的 S1
、S2
、E1
和 E2
,以及泛型函数 equal
的 E1
和 E2
。本地函数 foo
将 equal
函数作为参数调用 slices.EqualFunc
:
// From the slices package
// func EqualFunc[S1 ~[]E1, S2 ~[]E2, E1, E2 any](s1 S1, s2 S2, eq func(E1, E2) bool) bool
// Local code
func equal[E1, E2 comparable](E1, E2) bool { … }
func foo(list1 []int, list2 []float64) {
…
if slices.EqualFunc(list1, list2, equal) {
…
}
…
}
这是一个类型推导真正大放异彩的例子,因为我们可以省略多达六个类型参数,每个类型参数一个。类型模式匹配方法仍然有效,但我们可以看到它很快就会变得复杂,因为类型关系的数量正在激增。我们需要一种系统的方法来确定哪些类型参数和哪些类型涉及哪些模式。
从略有不同的角度看待类型推导会有所帮助。
类型方程
我们可以将类型推导重新表述为解决类型方程的问题。解决方程是我们从高中代数中都熟悉的事情。幸运的是,解决类型方程是一个更简单的问题,我们很快就会看到。
让我们再次看看我们之前的例子:
// From the slices package
// func Sort[S ~[]E, E cmp.Ordered](x S)
type List []int
var list List
slices.Sort(list)
如果可以解决以下类型方程,则推导成功。这里 ≡
代表相同,under(S)
代表 S
的底层类型:
S ≡ List // find S such that S ≡ List is true
under(S) ≡ []E // find E such that under(S) ≡ []E is true
类型参数是方程中的变量。解决方程意味着为这些变量(类型参数)找到值(类型参数),以便方程成立。这种观点使得类型推导问题更易于处理,因为它为我们提供了一个正式的框架,允许我们写下流入推导的信息。
精确处理类型关系
到目前为止,我们只是谈论类型必须相同。但对于实际的 Go 代码来说,这要求太严格了。在前面的例子中,S
不必与 List
相同,而是 List
必须可分配给 S
。同样,S
必须满足其相应的类型约束。我们可以使用我们写成 :≡
和 ∈
的特定运算符来更精确地表述我们的类型方程。
S :≡ List // List is assignable to S
S ∈ ~[]E // S satisfies constraint ~[]E
E ∈ cmp.Ordered // E satisfies constraint cmp.Ordered
通常,我们可以说类型方程有三种形式:两种类型必须相同,一种类型必须可分配给另一种类型,或者一种类型必须满足类型约束。
X ≡ Y // X and Y must be identical
X :≡ Y // Y is assignable to X
X ∈ Y // X satisfies constraint Y
(注意:在 GopherCon 演讲中,我们使用了符号 ≡
A 表示 :≡
,使用 ≡
C 表示 ∈
。我们认为 :≡
更清晰地唤起了赋值关系;而 ∈
直接表达了类型参数所代表的类型必须是其约束的类型集中的一个元素。)
类型方程的来源
在泛型函数调用中,我们可能有显式的类型参数,尽管大多数时候我们希望它们可以被推导出来。通常我们还有普通的函数参数。每个显式类型参数都会贡献一个(平凡的)类型方程:类型参数必须与类型参数相同,因为代码就是这样说的。每个普通函数参数都会贡献另一个类型方程:函数参数必须可分配给其相应的函数参数。最后,每个类型约束也通过约束满足约束的类型来提供类型方程。
总而言之,这会产生 n
个类型参数和 m
个类型方程。与基本高中代数不同,对于类型方程来说,n
和 m
不需要相同就可以解决。例如,下面的单个方程允许我们推断两个类型参数的类型参数:
map[K]V ≡ map[int]string // K ➞ int, V ➞ string (n = 2, m = 1)
让我们逐一查看这些类型方程的来源:
1. 类型参数的类型方程
对于每个类型参数声明:
func f[…, P constraint, …]…
和显式提供的类型参数:
f[…, A, …]…
我们得到类型方程:
P ≡ A
我们可以将此方程平凡地解出 P
:P
必须是 A
,我们写成 P ➞ A
。换句话说,这里没有什么可做的。为了完整起见,我们仍然可以写下相应的类型方程,但在这种情况下,Go 编译器只需将类型参数替换为其类型参数,然后这些类型参数就不复存在了,我们可以忽略它们。
2. 赋值的类型方程
对于传递给函数参数 p
的每个函数参数 x
:
f(…, x, …)
其中 p
或 x
包含类型参数,x
的类型必须可分配给参数 p
的类型。我们可以用方程表示:
𝑻(p) :≡ 𝑻(x)
其中 𝑻(x)
表示“x
的类型”。如果 p
和 x
都不包含类型参数,则没有要解决的类型变量:如果赋值是有效的 Go 代码,则方程为真,如果代码无效,则为假。因此,类型推导只考虑包含所涉及函数(或函数)的类型参数的类型。
从 Go 1.21 开始,未实例化的或部分实例化的函数(但不是函数调用)也可以分配给函数类型的变量,如下所示:
// From the slices package
// func Sort[S ~[]E, E cmp.Ordered](x S)
var intSort func([]int) = slices.Sort
与参数传递类似,此类赋值会导致相应的类型方程。对于这个例子,它将是:
𝑻(intSort) :≡ 𝑻(slices.Sort)
或简化为:
func([]int) :≡ func(S)
以及来自 slices.Sort
的约束(见下文)的方程。
3. 约束的类型方程
最后,对于我们想要推断类型参数的每个类型参数 P
,我们可以从其约束中提取类型方程,因为类型参数必须满足约束。给定声明:
func f[…, P constraint, …]…
我们可以写下方程:
P ∈ constraint
这里,∈
表示“必须满足约束”,这(几乎)等同于作为约束类型集中的一个类型元素。我们稍后将看到,一些约束(例如 any
)是无用的,或者由于实现限制目前无法使用。在这种情况下,推导会简单地忽略相应的方程。
类型参数和方程可能来自多个函数
在 Go 1.18 中,推导的类型参数必须全部来自同一个函数。具体来说,不可能将泛型、未实例化或部分实例化的函数作为函数参数传递,或将其分配给(函数类型的)变量。
如前所述,在 Go 1.21 中,类型推导也适用于这些情况。例如,泛型函数:
func myEq[P comparable](x, y P) bool { return x == y }
可以分配给函数类型的变量:
var strEq func(x, y string) bool = myEq // same as using myEq[string]
而 myEq
没有被完全实例化,类型推导会推断出 P
的类型参数必须是 string
。
此外,泛型函数可以作为参数未实例化或部分实例化的传递给另一个,可能是泛型的函数:
// From the slices package
// func CompactFunc[S ~[]E, E any](s S, eq func(E, E) bool) S
type List []int
var list List
result := slices.CompactFunc(list, myEq) // same as using slices.CompactFunc[List, int](list, myEq[int])
在最后一个例子中,类型推导确定了 CompactFunc
和 myEq
的类型参数。更一般地说,可能需要推断任意多个函数的类型参数。当涉及多个函数时,类型方程也可能来自或涉及多个函数。在 CompactFunc
示例中,我们最终得到三个类型参数和五个类型方程:
Type parameters and constraints:
S ~[]E
E any
P comparable
Explicit type arguments:
none
Type equations:
S :≡ List
func(E, E) bool :≡ func(P, P) bool
S ∈ ~[]E
E ∈ any
P ∈ comparable
Solution:
S ➞ List
E ➞ int
P ➞ int
绑定与自由类型参数
此时,我们对类型方程的各种来源有了更清晰的理解,但我们还没有明确说明要为哪些类型参数解决方程。让我们看另一个例子。在下面的代码中,sortedPrint
的函数体调用 slices.Sort
来进行排序。sortedPrint
和 slices.Sort
都是泛型函数,因为它们都声明了类型参数。
// From the slices package
// func Sort[S ~[]E, E cmp.Ordered](x S)
// sortedPrint prints the elements of the provided list in sorted order.
func sortedPrint[F any](list []F) {
slices.Sort(list) // 𝑻(list) is []F
… // print list
}
我们要推断 slices.Sort
调用的类型参数。将 list
传递给 slices.Sort
的参数 x
会产生方程:
𝑻(x) :≡ 𝑻(list)
这与以下方程相同:
S :≡ []F
在这个方程中有两个类型参数,S
和 F
。我们需要为哪个解决类型方程?因为被调用的函数是 Sort
,我们关心它的类型参数 S
,而不是类型参数 F
。我们说 S
绑定到 Sort
,因为它由 Sort
声明。在这个方程中,S
是相关的类型变量。相比之下,F
绑定到(由)sortedPrint
声明。我们说 F
相对于 Sort
是自由的。它有自己的、已经给定的类型。该类型是 F
,无论它是什么(在实例化时确定)。在这个方程中,F
已经给出,它是一个类型常量。
在解决类型方程时,我们总是为我们正在调用的函数(或在泛型函数赋值的情况下进行赋值)绑定的类型参数求解。
解决类型方程
现在我们已经确定了如何收集相关的类型参数和类型方程,缺失的部分当然是允许我们解决方程的算法。在经历了各种示例之后,可能已经很明显,解决 X ≡ Y
仅仅意味着递归地将类型 X
和 Y
与对方进行比较,并在过程中为可能出现在 X
和 Y
中的类型参数确定合适的类型参数。目标是使类型 X
和 Y
相同。这个匹配过程称为合一。
我们知道类型相同的规则是如何比较类型的。由于绑定类型参数充当类型变量的角色,我们需要指定它们如何与其他类型匹配。规则如下:
- 如果类型参数
P
具有已推断的类型,则P
代表该类型。 - 如果类型参数
P
没有已推断的类型,并且与另一个类型T
匹配,则将P
设置为该类型:P ➞ T
。我们说类型T
是为P
推断的。 - 如果
P
与另一个类型参数Q
匹配,并且P
和Q
都还没有推断的类型,则P
和Q
被统一。
统一两个类型参数意味着将它们组合在一起,使得它们都表示相同的类型参数值:如果其中一个 P
或 Q
与类型 T
匹配,则 P
和 Q
会同时设置为 T
(通常,任意数量的类型参数可以这样统一)。
最后,如果两个类型 X
和 Y
不同,则无法使方程成立,并且求解失败。
统一类型以实现类型相同
几个具体的例子应该能使这个算法变得清晰。考虑包含三个绑定类型参数 A
、B
和 C
的两个类型 X
和 Y
,它们都出现在类型方程 X ≡ Y
中。目标是为类型参数解决此方程;即,为它们找到合适的类型参数,以便 X
和 Y
相同,从而使方程成立。
X: map[A]struct{i int; s []B}
Y: map[string]struct{i C; s []byte}
合一通过递归地比较 X
和 Y
的结构来进行,从顶部开始。仅查看两种类型的结构,我们得到:
map[…]… ≡ map[…]…
其中 …
代表我们在此步骤中忽略的相应映射键和值类型。由于两边都是映射,因此到目前为止类型是相同的。合一递归地进行,首先是键类型,对于 X
映射是 A
,对于 Y
映射是 string
。相应的键类型必须相同,由此我们可以立即推断出 A
的类型参数必须是 string
。
A ≡ string => A ➞ string
继续映射的元素类型,我们得到:
struct{i int; s []B} ≡ struct{i C; s []byte}
两边都是结构体,因此合一继续进行结构体字段。如果它们的顺序相同,名称相同,并且类型相同,则它们是相同的。第一对字段是 i int
和 i C
。名称匹配,因为 int
必须与 C
合一,因此:
int ≡ C => C ➞ int
这种递归类型匹配会一直持续到两种类型的树结构被完全遍历,或者出现冲突。在此示例中,最终我们得到:
[]B ≡ []byte => B ≡ byte => B ➞ byte
一切正常,合一推断出类型参数:
A ➞ string
B ➞ byte
C ➞ int
统一具有不同结构的类型
现在,让我们考虑上一个例子的一个细微变化:这里的 X
和 Y
没有相同的类型结构。当递归比较类型树时,合一仍然成功地推断出 A
的类型参数。但是映射的值类型不同,合一失败。
X: map[A]struct{i int; s []B}
Y: map[string]bool
X
和 Y
都是映射类型,因此合一像之前一样递归进行,从键类型开始。我们得到:
A ≡ string => A ➞ string
也像之前一样。但是当我们继续进行映射的值类型时,我们得到:
struct{…} ≡ bool
struct
类型与 bool
不匹配;我们有不同的类型,合一(因此类型推导)失败。
统一具有冲突类型参数的类型
另一种冲突出现在不同类型匹配同一类型参数时。这里我们再次有一个初始示例的版本,但现在类型参数 A
出现在 X
中两次,而 C
出现在 Y
中两次。
X: map[A]struct{i int; s []A}
Y: map[string]struct{i C; s []C}
递归类型合一起初工作正常,我们得到以下类型参数和类型的配对:
A ≡ string => A ➞ string // map key type
int ≡ C => C ➞ int // first struct field type
当我们处理第二个结构体字段类型时,我们得到:
[]A ≡ []C => A ≡ C
由于 A
和 C
都推断出了类型参数,因此它们代表这些类型参数,分别是 string
和 int
。这些是不同的类型,因此 A
和 C
无法匹配。合一(因此类型推导)失败。
其他类型关系
合一解决了形式为 X ≡ Y
的类型方程,目标是类型相同。但 X :≡ Y
或 X ∈ Y
呢?
一些观察结果对我们有帮助:类型推导的唯一目的是查找被省略的类型参数的类型。类型推导之后总是伴随着类型或函数实例化,它会检查每个类型参数是否确实满足其各自的类型约束。最后,在泛型函数调用的情况下,编译器还会检查函数参数是否可分配给其相应的函数参数。所有这些步骤都必须成功,代码才有效。
如果类型推导不够精确,它可能会推断出一个(不正确的)类型参数,而实际上不存在该类型。如果是这种情况,那么实例化或参数传递都会失败。无论哪种方式,编译器都会产生错误消息。只是错误消息可能略有不同。
这个见解允许我们在 :≡
和 ∈
这两种类型关系上稍微宽松一些。特别是,它允许我们简化它们,使它们可以像 ≡
一样处理。简化的目标是从类型方程中提取尽可能多的类型信息,从而在精确实现可能失败的地方推断类型参数,因为我们可以。
简化 X :≡ Y
Go 的可分配性规则相当复杂,但大多数时候我们实际上可以通过类型相同或其细微变体来完成。只要我们找到潜在的类型参数,我们就很高兴,这正是因为类型推导之后仍然是类型实例化和函数调用。如果推导找到了一个本不该找到的类型参数,它将在稍后被捕获。因此,在匹配可分配性时,我们对合一算法进行以下调整:
- 当命名(已定义)类型与类型字面量匹配时,将比较它们的底层类型。
- 比较通道类型时,会忽略通道方向。
此外,忽略赋值方向:X :≡ Y
被视为 Y :≡ X
。
这些调整仅适用于类型结构的顶层:例如,根据 Go 的可分配性规则,命名映射类型可以分配给未命名的映射类型,但键和元素类型仍然必须相同。有了这些更改,可分配性的合一就成为类型相同的合一(一个细微的)变体。以下示例说明了这一点。
让我们假设我们将前面定义的 List
类型(定义为 type List []int
)的值传递给类型为 []E
的函数参数,其中 E
是绑定类型参数(即 E
由正在调用的泛型函数声明)。这会导致类型方程 []E :≡ List
。尝试统一这两种类型需要比较 []E
和 List
。这两种类型不相同,并且如果没有对合一工作方式的任何更改,它将失败。但是因为我们正在为可分配性进行合一,所以这种初始匹配不需要完全精确。继续使用命名类型 List
的底层类型没有害处:在最坏的情况下,我们可能会推断出一个不正确的类型参数,但这将在稍后检查赋值时导致错误。在最好的情况下,我们找到了一个有用且正确的类型参数。在我们的示例中,不精确合一成功,我们正确地推断出 int
作为 E
。
简化 X ∈ Y
能够简化约束满足关系甚至更重要,因为约束可能非常复杂。
同样,约束检查是在实例化时进行的,因此这里的目标是尽可能地帮助类型推导。这些通常是我们知道类型参数结构的情况;例如,我们知道它必须是一个切片类型,并且我们关心切片的元素类型。例如,形式为 [P ~[]E]
的类型参数列表告诉我们,无论 P
是什么,它的底层类型都必须是 []E
的形式。这些正是约束具有核心类型的情况。
因此,如果我们有一个形式为
P ∈ constraint // or
P ∈ ~constraint
并且如果存在 core(constraint)
(或 core(~constraint)
,分别),则方程可以简化为:
P ≡ core(constraint)
under(P) ≡ core(~constraint) // respectively
在所有其他情况下,涉及约束的类型方程将被忽略。
展开推导的类型
如果合一成功,它会产生一个从类型参数到推断类型参数的映射。但是合一本身并不能确保推断的类型不包含绑定类型参数。为了说明这一点,请考虑下面的泛型函数 g
,它与类型为 int
的单个参数 x
一起调用:
func g[A any, B []C, C *A](x A) { … }
var x int
g(x)
A
的类型约束是 any
,它没有核心类型,所以我们忽略它。其余类型约束具有核心类型,分别是 []C
和 *A
。连同传递给 g
的参数,经过微小简化后,类型方程是:
A :≡ int
B ≡ []C
C ≡ *A
由于每个方程都将一个类型参数与一个非类型参数类型进行对比,因此合一几乎没有什么工作要做,并且立即推断出:
A ➞ int
B ➞ []C
C ➞ *A
但这会在推断的类型中留下类型参数 A
和 C
,这是没有帮助的。就像在高中代数中一样,一旦一个方程为变量 x
求解,我们就需要将 x
替换为其值,遍及剩余的方程。在我们的示例中,在第一步中,[]C
中的 C
被替换为 C
的推断类型(“值”),即 *A
,我们得到:
A ➞ int
B ➞ []*A // substituted *A for C
C ➞ *A
再进行两个步骤,我们将推断类型 []*A
和 *A
中的 A
替换为 A
的推断类型,即 int
:
A ➞ int
B ➞ []*int // substituted int for A
C ➞ *int // substituted int for A
现在推导才完成。就像在高中代数中一样,有时这行不通。可能会出现以下情况:
X ➞ Y
Y ➞ *X
在一轮替换后,我们得到:
X ➞ *X
如果我们继续下去,X
的推断类型会不断增长:
X ➞ **X // substituted *X for X
X ➞ ***X // substituted *X for X
etc.
类型推导在扩展期间检测到这种循环并报告错误(因此失败)。
未类型化的常量
到现在为止,我们已经看到了类型推导如何通过合一解决类型方程,然后展开结果。但是如果没有类型怎么办?如果函数参数是未类型化的常量怎么办?
另一个例子有助于阐明这种情况。让我们考虑一个函数 foo
,它接受任意数量的参数,所有这些参数必须具有相同的类型。foo
调用了各种未类型化的常量参数,包括类型为 int
的变量 x
:
func foo[P any](...P) {}
var x int
foo(x) // P ➞ int, same as foo[int](x)
foo(x, 2.0) // P ➞ int, 2.0 converts to int without loss of precision
foo(x, 2.1) // P ➞ int, but parameter passing fails: 2.1 is not assignable to int
对于类型推导,类型化参数优先于未类型化参数。只有当它被赋值到的类型参数尚未推断出类型时,才会考虑未类型化的常量进行推导。在前三个对 foo
的调用中,变量 x
确定了 P
的推断类型:它是 x
的类型,即 int
。在这种情况下,未类型化的常量被忽略,调用行为与 foo
被显式实例化为 int
完全相同。
如果 foo
只用未类型化的常量参数调用,情况会更有趣。在这种情况下,类型推导会考虑未类型化常量的默认类型。快速回顾一下,Go 中可能的默认类型如下:
Example Constant kind Default type Order
true boolean constant bool
42 integer constant int earlier in list
'x' rune constant rune |
3.1416 floating-point constant float64 v
-1i complex constant complex128 later in list
"gopher" string constant string
有了这些信息,让我们考虑函数调用:
foo(1, 2) // P ➞ int (default type for 1 and 2)
未类型化的常量参数 1
和 2
都是整数常量,它们的默认类型是 int
,因此推断为 foo
的类型参数 P
是 int
。
如果不同的常量——比如未类型化的整数和浮点常量——竞争同一个类型变量,我们就有不同的默认类型。在 Go 1.21 之前,这被视为冲突并导致错误:
foo(1, 2.0) // Go 1.20: inference error: default types int, float64 don't match
这种行为在使用上的人体工程学不佳,并且与未类型化常量在表达式中的行为不同。例如,Go 允许常量表达式 1 + 2.0
;结果是浮点常量 3.0
,默认类型为 float64
。
在 Go 1.21 中,行为相应地得到了更改。现在,如果多个未类型化的数字常量与同一个类型参数匹配,则会选择默认类型,该类型在 int
、rune
、float64
、complex
列表中出现得更晚,这与常量表达式的规则相匹配:
foo(1, 2.0) // Go 1.21: P ➞ float64 (larger default type of 1 and 2.0; behavior like in 1 + 2.0)
特殊情况
现在我们已经对类型推导有了大致的了解。但是有几个重要的特殊情况值得注意。
参数顺序依赖性
第一个与参数顺序依赖性有关。我们希望从类型推导中获得的一个重要属性是,无论函数参数的顺序(以及该函数每次调用的相应参数顺序)如何,推断出的类型都应相同。
让我们重新审视我们的可变参数 foo
函数:为 P
推断的类型应与我们传递参数 s
和 t
的顺序无关(playground)。
func foo[P any](...P) (x P) {}
type T struct{}
func main() {
var s struct{}
var t T
fmt.Printf("%T\n", foo(s, t))
fmt.Printf("%T\n", foo(t, s)) // expect same result independent of parameter order
}
从对 foo
的调用中,我们可以提取相关的类型方程:
𝑻(x) :≡ 𝑻(s) => P :≡ struct{} // equation 1
𝑻(x) :≡ 𝑻(t) => P :≡ T // equation 2
不幸的是,:≡
的简化实现产生了顺序依赖性:
如果合一开始处理方程 1,它会将 P
与 struct
匹配;P
此时还没有推断出类型,因此合一推断出 P ➞ struct{}
。当合一在方程 2 中稍后看到类型 T
时,它会继续使用 T
的底层类型,即 struct{}
,P
和 under(T)
合一时,合一(因此推导)成功。
反之,如果合一开始处理方程 2,它会将 P
与 T
匹配;P
此时还没有推断出类型,因此合一推断出 P ➞ T
。当合一在方程 1 中看到 struct{}
时,它会继续使用推断为 P
的类型 T
的底层类型。该底层类型是 struct{}
,它与方程 1 中的 struct
匹配,合一(因此推导)成功。
因此,根据合一解决两个类型方程的顺序,推断出的类型是 struct{}
或 T
。这当然是令人不满意的:一个程序可能突然停止编译,仅仅因为在代码重构或清理过程中参数的顺序可能发生了变化。
恢复顺序无关性
幸运的是,补救方法很简单。在某些情况下,我们只需要进行一些小的更正。
具体来说,如果合一正在解决 P :≡ T
并且:
P
是一个类型参数,它已经推断出了类型A
:P ➞ A
A :≡ T
为真T
是一个命名类型
那么将 P
的推断类型设置为 T
:P ➞ T
。
这可以确保 P
是命名类型(如果存在选择),而不管命名类型在与 P
匹配的哪个点出现(即,无论类型方程以何种顺序求解)。请注意,如果不同的命名类型与同一个类型参数匹配,我们总是会发生合一失败,因为根据定义,不同的命名类型不相同。
由于我们对通道和接口进行了类似的简化,它们也需要类似的特殊处理。例如,我们在为可分配性合一时会忽略通道方向,因此可能会推断出定向或双向通道,具体取决于参数顺序。接口也出现类似问题。这里我们不讨论这些。
回到我们的示例,如果合一开始处理方程 1,它会像之前一样推断 P ➞ struct{}
。当它继续处理方程 2 时,就像之前一样,合一成功,但现在我们有了恰好需要更正的条件:P
是一个已经有一个类型(struct{}
)的类型参数,struct{}
,struct{} :≡ T
为真(因为 struct{} ≡ under(T)
为真),并且 T
是一个命名类型。因此,合一进行更正并将 P ➞ T
设置为。结果,无论合一顺序如何,两种情况下的结果都是相同的(T
)。
自递归函数
另一种在天真的推导实现中引起问题的场景是自递归函数。让我们考虑一个泛型的阶乘函数 fact
,它被定义为也可以处理浮点参数(playground)。请注意,这不是伽马函数的数学上正确的实现,它只是一个方便的例子。
func fact[P ~int | ~float64](n P) P {
if n <= 1 {
return 1
}
return fact(n-1) * n
}
关键不在于阶乘函数本身,而在于 fact
使用类型与传入参数 n
相同的参数 n-1
来调用自身。在此调用中,类型参数 P
同时是绑定和自由类型参数:它是绑定的,因为它由 fact
(被调用的函数)声明。但它也是自由的,因为它由包含该调用的函数声明,该函数恰好也是 fact
。
将参数 n-1
传递给参数 n
的递归调用产生的方程将 P
与自身进行比较:
𝑻(n) :≡ 𝑻(n-1) => P :≡ P
合一在方程的两边看到相同的 P
。合一成功,因为两种类型相同,但没有获得任何信息,P
仍然没有推断的类型。因此,类型推导失败。
幸运的是,解决这个问题的技巧很简单:在调用类型推导之前,并且仅供类型推导(临时)使用,编译器会重命名涉及相应调用的所有函数签名中的类型参数(但不是函数体)。这不会改变函数签名的含义:无论类型参数的名称是什么,它们都表示相同的泛型函数。
在这个例子中,我们假设 fact
签名中的 P
被重命名为 Q
。效果就像递归调用是通过一个 helper
函数间接完成的(playground):
func fact[P ~int | ~float64](n P) P {
if n <= 1 {
return 1
}
return helper(n-1) * n
}
func helper[Q ~int | ~float64](n Q) Q {
return fact(n)
}
通过重命名或使用 helper
函数,将参数 n-1
传递给 fact
(或 helper
函数)的递归调用产生的方程变为:
𝑻(n) :≡ 𝑻(n-1) => Q :≡ P
这个方程有两个类型参数:由被调用函数声明的绑定类型参数 Q
,以及由包含函数声明的自由类型参数 P
。这个类型方程对于 Q
被平凡地求解,结果是推断 Q ➞ P
,这当然是我们期望的,并且我们可以通过显式实例化递归调用来验证(playground)。
func fact[P ~int | ~float64](n P) P {
if n <= 1 {
return 1
}
return fact[P](n-1) * n
}
缺少什么?
我们描述中明显缺失的是泛型类型的类型推导:当前泛型类型必须始终显式实例化。
这有几个原因。首先,对于类型实例化,类型推导只有类型参数可以工作;与函数调用不同,没有其他参数。因此,至少必须始终提供一个类型参数(除非在类型约束指定所有类型参数恰好有一个可能类型参数的病态情况)。因此,类型推导对于类型仅在完成部分实例化的类型时才有用,其中所有被省略的类型参数都可以从类型约束产生的方程中推导出来;即,其中至少有两个类型参数。我们认为这种情况并不常见。
其次,也是更相关的,类型参数允许一种全新的递归类型。考虑假设的类型:
type T[P T[P]] interface{ … }
其中 P
的约束是正在声明的类型。结合具有多个可能相互引用以复杂递归方式引用彼此的类型参数的能力,类型推导变得更加复杂,目前我们并不完全理解所有这些含义。也就是说,我们认为检测循环并在没有此类循环的情况下继续进行类型推导应该不难。
最后,有些情况下类型推导根本不够强大,无法进行推导,通常是因为合一使用了某些简化假设,例如本文前面描述的那些。这里的主要例子是具有无核心类型的约束,但更复杂的方法可能仍然能够推断出类型信息。
这些都是我们可能会在未来 Go 版本中看到增量改进的领域。重要的是,我们认为当前推导失败的情况要么很少见,要么在生产代码中不重要,并且我们当前的实现涵盖了绝大多数有用的代码场景。
不过,如果您遇到我认为类型推导应该工作或出错的情况,请提交 issue!一如既往,Go 团队很乐意听到您的声音,特别是当它有助于我们使 Go 变得更好时。