- 什么是类型推断?
- 为什么要类型推断?
- 类型推断是一种类型模式匹配的形式
- 类型方程
- 在类型关系方面要精确
- 类型方程的来源
- 类型参数和方程可能来自多个函数
- 绑定和自由类型参数
- 解决类型方程
- 统一类型以实现类型一致性
- 统一具有不同结构的类型
- 解决具有冲突类型参数的类型
- 其他类型关系
- 简化 X :≡ Y
- 简化 X ∈ Y
- 扩展推断类型
- 无类型常量
- 特殊情况
- 参数顺序依赖
- 恢复独立性顺序
- 自递归函数
- 还缺少什么?
原文在这里。
由 Robert Griesemer 发布于2023年10月9日
这是我在 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
关键字,使其成为一种非常紧凑的语句。参考以下map
变量声明:
var m map[string]int = map[string]int{}
vs
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
的类型,使相应的类型匹配。推断成功了!
以下是一个更复杂的场景,涉及到许多类型参数:S1
、S2
、E1
和E2
来自slices.EqualFunc
,以及equal
泛型函数的E1
和E2
。本地函数foo
调用slices.EqualFunc
并将equal
函数作为参数传递:
// 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
必须等于 A
,我们用 P ➞ A
表示。换句话说,在这里没有什么需要做的。为了完整起见,我们仍然可以写下相应的类型方程,但在这种情况下,Go 编译器只是在整个过程中用类型参数替换它们的类型参数,然后这些类型参数就消失了,我们可以忘掉它们。
2. 类型赋值的类型方程
对于每个传递给函数参数p
的函数参数x
,其中p
或x
包含类型参数,x
的类型必须可分配给参数p
的类型。我们可以用以下方程来表示这个关系:
𝑻(p) :≡ 𝑻(x)
这里的𝑻(x)表示 “x 的类型”。如果p
和x
都不包含类型参数,那么就没有类型变量需要解决:这个方程要么为真(因为分配是有效的 Go 代码),要么为假(如果代码无效)。因此,类型推断只考虑包含涉及函数(或函数)的类型参数的类型。
从 Go 1.21 开始,未实例化或部分实例化的函数(但不是函数调用)也可以分配给函数类型的变量,例如:
// 来自 slices 包
// 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 约束, …]…
我们可以写出方程
P ∈ 约束
这里,∈ 表示“必须满足约束”,几乎与成为约束类型集的类型元素相同。稍后我们将看到,某些约束(例如 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 // 等同于使用 myEq[string]
而不需要对myEq进行完全实例化,类型推断会推断出P的类型参数必须为string。
此外,通用函数可以未实例化或部分实例化地用作另一个可能是通用函数的参数:
// 来自slices包
// 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) // 等同于使用 slices.CompactFunc[List, int](list, myEq[int])
在最后的示例中,类型推断确定了CompactFunc和myEq的类型参数。更一般地说,可能需要推断来自任意多个函数的类型参数。涉及多个函数时,类型方程也可能来自多个函数或涉及多个函数。在CompactFunc示例中,我们最终得到了三个类型参数和五个类型方程:
类型参数和约束:
- S ~[]E
- E any
- P comparable
显式类型参数:
- 无
类型方程:
- S :≡ List
- func(E, E) bool :≡ func(P, P) bool
- S ∈ ~[]E
- E ∈ any
- P ∈ comparable
解决方案:
- S ➞ List
- E ➞ int
- P ➞ int
绑定和自由类型参数
在这一点上,我们对类型方程的各种来源有了更清晰的理解,但我们对要解方程的类型参数并不十分明确。让我们考虑另一个示例。在下面的代码中,sortedPrint函数的函数体调用了slices.Sort来进行排序。sortedPrint和slices.Sort都是通用函数,因为它们都声明了类型参数。
// 来自slices包
// func Sort[S ~[]E, E cmp.Ordered](x S)
// sortedPrint按排序顺序打印提供的列表的元素。
func sortedPrint[F any](list []F) {
slices.Sort(list) // 𝑻(list) 是 []F
… // 打印列表
}
我们想要推断slices.Sort调用的类型参数。将list传递给slices.Sort的参数x会产生方程
𝑻(x) :≡ 𝑻(list)
这与以下方程相同
S :≡ []F
在这个方程中,我们有两个类型参数,S和F。我们需要为哪一个解方程?因为调用的函数是Sort,我们关心它的类型参数S,而不是类型参数F。我们说S与Sort绑定,因为它由Sort声明。在这个方程中,S是相关的类型变量。相反,F与sortedPrint绑定。我们说F对于Sort是自由的。它有自己的已知类型。在这个方程中,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 不同,那么方程无法变为真,解决它将失败。
统一类型以实现类型一致性
通过几个具体的示例,我们可以清晰地理解这个算法。考虑两个类型 X 和 Y,它们包含三个绑定类型参数 A、B 和 C,都出现在类型方程 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 结构类型与 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 // 映射键类型 int ≡ C => C ➞ int // 第一个结构字段类型 当我们到达第二个结构字段类型时,我们有
[]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 // 或者 P ∈ ~constraint 并且如果 core(constraint)(或 core(~constraint))存在,那么方程可以简化为
P ≡ core(constraint) under(P) ≡ core(~constraint) // 分别 在所有其他情况下,涉及约束的类型方程将被忽略。
扩展推断类型
如果一致性成功,它会产生从类型参数到推断类型参数的映射。但是仅仅一致性并不能确保推断的类型不包含绑定的类型参数。为了理解为什么会这样,考虑下面的通用函数 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 // 用 *A 替换 C
C ➞ *A
再经过两步,我们将推断类型 []*A 和 *A 中的 A 替换为 A 的推断类型,即 int:
A ➞ int
B ➞ []*int // 用 int 替换 A
C ➞ *int // 用 int 替换 A
只有现在推断完成。就像在高中代数中一样,有时这不起作用。可能会出现如下的情况:
X ➞ Y
Y ➞ *X
进行一轮替代后,我们得到:
X ➞ *X
如果我们继续进行,X 的推断类型将不断增加:
X ➞ **X // 用 *X 替换 X
X ➞ ***X // 用 *X 替换 X
等等。
类型推断在扩展过程中检测到这种循环并报告错误(从而失败)。
无类型常量
到目前为止,我们已经看到了类型推断是如何通过解决类型方程来进行的,其中包括一致性,然后是结果的扩展。但如果没有类型呢?如果函数参数是无类型常量怎么办?
再举一个例子可以帮助我们理解这种情况。让我们考虑一个函数 foo
,它接受任意数量的参数,所有这些参数都必须具有相同的类型。foo
被使用各种无类型常量参数调用,包括一个类型为 int
的变量 x
:
func foo[P any](...P) {}
var x int
foo(x) // P ➞ int,与 foo[int](x) 相同
foo(x, 2.0) // P ➞ int,2.0 转换为 int 时没有精度损失
foo(x, 2.1) // P ➞ int,但参数传递失败:2.1 无法分配给 int
对于类型推断,已经具有类型的参数优先于无类型参数。无类型常量仅在分配给尚未具有推断类型的类型参数时才会被考虑。在这前三次对 foo
的调用中,变量 x
确定了 P
的推断类型:它的类型是 int
。在这种情况下,无类型常量不会被用于类型推断,这些调用的行为与 foo
显式实例化为 int
完全相同。
如果 foo
仅使用无类型常量参数进行调用,情况就变得更有趣了。在这种情况下,类型推断考虑无类型常量的默认类型。作为一个快速提醒,以下是 Go 中可能的默认类型:
示例 | 常量种类 | 默认类型 | 顺序 | |
---|---|---|---|---|
true | 布尔常量 | bool | ||
42 | 整数常量 | int | 在列表中较早 | |
‘x’ | 符文常量 | rune | ||
3.1416 | 浮点常量 | float64 | v | |
-1i | 复数常量 | complex128 | 在列表中较晚 |
“gopher” 字符串常量 string 有了这些信息,让我们考虑函数调用s
foo(1, 2) // P ➞ int(1 和 2 的默认类型都是 int)
无类型常量参数 1 和 2 都是整数常量,它们的默认类型是 int
,因此推断的 foo
的类型参数 P
为 int
。
如果不同的常量(例如,无类型整数和浮点数常量)竞争相同的类型变量,那么它们具有不同的默认类型。在 Go 1.20 之前,这被认为是冲突并导致错误:
foo(1, 2.0) // Go 1.20:推断错误:默认类型 int、float64 不匹配
这种行为在使用时不太方便,而且与表达式中的无类型常量的行为不同。例如,Go 允许常量表达式 1 + 2.0
;结果是浮点常量 3.0
,默认类型为 float64
。
在 Go 1.21 中,相应地更改了行为。现在,如果多个无类型数值常量与同一个类型参数匹配,那么选择出现在 int
、rune
、float64
、complex
列表中较晚的默认类型,与常量表达式的规则相匹配:
foo(1, 2.0) // Go 1.21:P ➞ float64(1 和 2.0 的较大默认类型;与 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)) // 无论参数顺序如何,都期望得到相同的结果
}
从对 foo
的调用中,我们可以提取出相关的类型方程:
- 𝑻(x) :≡ 𝑻(s) => P :≡ struct{} // 方程 1
- 𝑻(x) :≡ 𝑻(t) => P :≡ T // 方程 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-1
,其类型 P
与传入参数 n
的类型相同。在这个调用中,类型参数 P
同时是一个绑定的和自由的类型参数:它是绑定的,因为它是由 fact
声明的,我们正在递归调用的函数。但它也是自由的,因为它是由包含该调用的函数声明的,该函数也是 fact
。
从将参数 n-1
传递给参数 n
中产生的方程将 P
与自身对立起来:
- 𝑻(n) :≡ 𝑻(n-1) => P :≡ P
统一在等式的两边都看到了相同的 P
。统一成功,因为两种类型是相同的,但没有获得信息,P
仍然没有推断出类型。因此,类型推断失败。
幸运的是,解决这个问题的技巧很简单:在调用之前,且仅供类型推断使用,编译器会在所有涉及相应调用的函数的签名(但不包括函数体)中重命名类型参数。这不会改变函数签名的含义:它们不管类型参数的名称如何,都表示相同的通用函数。
为了这个示例,假设 fact
签名中的 P
被重命名为 Q
。重命名的效果就像是通过帮助函数间接进行了递归调用(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)
}
通过重命名或使用辅助函数,从传递给 fact
的递归调用(或辅助函数,分别)中产生的方程会更改为
- 𝑻(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 发行版中可能会看到渐进性改进的领域。重要的是,我们认为推断当前失败的情况要么很少发生,要么在生产代码中不重要,我们当前的实现涵盖了绝大多数有用的代码情况。
话虽如此,如果您遇到了一个情况,认为类型推断应该工作或出现了问题,请提交一个问题!与往常一样,Go 团队非常乐意听到您的声音,尤其是当它有助于我们使 Go 变得更好时。
声明:本作品采用署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0)进行许可,使用时请注明出处。
Author: mengbin
blog: mengbin
Github: mengbin92
cnblogs: 恋水无意