Go 博客
你想知道的关于类型推断的一切 - 以及更多
本文是根据我在圣迭戈 GopherCon 2023 大会上关于类型推断的演讲整理的博客版本,内容略有扩展并进行了编辑,以便更清晰地阐述。
什么是类型推断?
维基百科对类型推断的定义如下
类型推断是指在编译时自动(部分或完全)推导出表达式类型的能力。编译器通常能够在没有显式类型注解的情况下,推断出变量的类型或函数的类型签名。
这里的关键短语是“自动推导出……表达式的类型”。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
关键字的能力——结合成一个非常紧凑的语句。考虑以下 map 变量声明
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
的 S
和 E
的约束方程(参见下文)。
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
绑定类型参数 vs 自由类型参数
至此,我们对各种类型方程的来源有了更清晰的理解,但对于应该为哪些类型参数求解方程还不够精确。让我们考虑另一个例子。在下面的代码中,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
相同。这种匹配过程称为合一(unification)。
类型相同的规则告诉我们如何比较类型。由于绑定类型参数扮演类型变量的角色,我们需要指定它们如何与其他类型进行匹配。规则如下
- 如果类型参数
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[…]…
其中 …
代表我们在此步骤忽略的各自的 map 键和值类型。由于两侧都是 map,到目前为止类型是相同的。合一递归地进行,首先比较键类型,X
的 map 键类型是 A
,Y
的 map 键类型是 string
。相应的键类型必须相同,由此我们可以立即推断出 A
的类型实参必须是 string
A ≡ string => A ➞ string
继续比较 map 元素类型,我们得到
struct{i int; s []B} ≡ struct{i C; s []byte}
两侧都是 struct,所以合一继续比较 struct 字段。如果它们的顺序相同、名称相同且类型相同,则它们是相同的。第一个字段对是 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
的类型实参。但是 map 的值类型不同,合一失败。
X: map[A]struct{i int; s []B}
Y: map[string]bool
X
和 Y
都是 map 类型,所以合一像之前一样递归进行,从键类型开始。我们得到
A ≡ string => A ➞ string
同样像之前一样。但当我们继续处理 map 的值类型时,我们有
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
当我们比较第二个 struct 字段类型时,我们有
[]A ≡ []C => A ≡ C
由于 A
和 C
都推断出了类型实参,它们代表这些类型实参,分别是 string
和 int
。这些是不同的类型,所以 A
和 C
不可能匹配。合一(因此类型推断)失败。
其他类型关系
合一求解形如 X ≡ Y
的类型方程,其目标是类型相同。但是对于 X :≡ Y
或 X ∈ Y
呢?
这里有一些观察可以帮助我们:类型推断的工作仅仅是找到被省略的类型实参的类型。类型推断之后总是跟着类型或函数实例化,它检查每个类型实参是否确实满足其相应的类型约束。最后,在泛型函数调用的情况下,编译器还会检查函数实参是否可赋值给其相应的函数参数。所有这些步骤都必须成功,代码才是有效的。
如果类型推断不够精确,它可能会推断出(不正确的)类型实参,而实际上可能不存在这样的类型。如果出现这种情况,实例化或实参传递将会失败。无论哪种方式,编译器都会产生错误消息。只是错误消息可能略有不同。
这个洞察使我们能够对类型关系 :≡
和 ∈
稍微放宽要求。具体来说,它允许我们简化它们,使得它们可以几乎与 ≡
同等对待。简化的目标是从类型方程中提取尽可能多的类型信息,从而在精确实现可能失败的情况下推断出类型实参,因为我们可以这样做。
简化 X :≡ Y
Go 的可赋值性规则相当复杂,但大多数情况下,我们实际上可以用类型相同或其微小变体来处理。只要我们找到潜在的类型实参,我们就很高兴,正是因为类型推断之后仍然有类型实例化和函数调用。如果推断找到了不应该存在的类型实参,它稍后会被捕获。因此,在匹配可赋值性时,我们对合一算法进行以下调整
- 当命名(定义)类型与类型字面量匹配时,改为比较它们的底层类型。
- 比较 channel 类型时,忽略 channel 方向。
此外,忽略赋值方向:X :≡ Y
被视为与 Y :≡ X
相同。
这些调整仅适用于类型结构的顶层:例如,根据 Go 的可赋值性规则,命名 map 类型可以赋值给未命名 map 类型,但键和元素类型仍然必须相同。通过这些更改,用于可赋值性的合一成为类型相同合一的一个(微小)变体。以下示例说明了这一点。
假设我们将我们之前的 List
类型(定义为 type List []int
)的值传递给类型为 []E
的函数参数,其中 E
是一个绑定类型参数(即 E
由正在调用的泛型函数声明)。这产生了类型方程 []E :≡ List
。尝试合一这两种类型需要比较 []E
和 List
。这两种类型不相同,如果不对合一的工作方式做任何改变,它将失败。但是因为我们是为了可赋值性而进行合一,这个初始匹配不需要是精确的。继续使用命名类型 List
的底层类型并没有坏处:最坏情况下,我们可能会推断出不正确的类型实参,但这会在稍后检查赋值时导致错误。最好情况下,我们找到了一个有用且正确的类型实参。在我们的例子中,不精确合一成功,我们正确地为 E
推断出 int
。
简化 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
:无论我们传递实参 s
和 t
的顺序如何,为 P
推断出的类型都应该相同(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{} :≡ T
为真(因为 struct{} ≡ under(T)
为真),并且 T
是一个命名类型。因此,统一过程进行修正,并将 P ➞ T
。结果,无论统一顺序如何,两种情况下结果都是相同的 (T
)。
自递归函数
另一个在推断的朴素实现中引起问题的场景是自递归函数。让我们考虑一个泛型阶乘函数 fact
,其定义使其也适用于浮点参数(playground)。请注意,这不是伽马函数(gamma function)的数学上正确实现,它仅仅是一个方便的例子。
func fact[P ~int | ~float64](n P) P {
if n <= 1 {
return 1
}
return fact(n-1) * n
}
这里的重点不是阶乘函数本身,而是 fact
通过传入参数 n-1
调用自身,其类型 P
与传入参数 n
的类型相同。在这种调用中,类型参数 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 变得更好时。