Go 博客

关于类型推断的一切 - 以及更多一点

Robert Griesemer
2023年10月9日

这是我在圣地亚哥 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关键字的能力——组合成一个非常紧凑的语句。考虑以下映射变量声明

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)

如果没有类型推断,调用BinarySearchSort需要显式类型参数

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底层类型必须是切片[]ES的底层类型是[]int,因此[]int必须与[]E匹配,由此我们可以得出结论,E必须是int。我们已经能够找到SE的类型,使得相应的类型匹配。推断成功了!

这是一个更复杂的场景,其中我们有很多类型参数:来自slices.EqualFuncS1S2E1E2,以及来自泛型函数equalE1E2。局部函数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个类型方程。与基本的高中代数不同,对于类型方程来说,nm不必相同才能求解。例如,下面的单个方程允许我们推断两个类型参数的类型参数

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, …)

其中px包含类型参数,x的类型必须可以赋值给参数p的类型。我们可以用以下方程表达这一点

𝑻(p) :≡ 𝑻(x)

其中𝑻(x)表示“x的类型”。如果px都不包含类型参数,则没有类型变量需要求解:如果赋值是有效的 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

绑定类型参数与自由类型参数

在这一点上,我们对类型方程的各种来源有了更清晰的理解,但我们还没有非常精确地说明要为哪些类型参数求解方程。让我们考虑另一个例子。在下面的代码中,`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

结构体类型与 `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。尝试统一这两个类型需要将 []EList 进行比较。这两个类型并不相同,并且在不更改统一方式的情况下,它将失败。但由于我们正在为可赋值性进行统一,因此此初始匹配不需要完全精确。继续使用命名类型 List 的底层类型没有坏处:在最坏的情况下,我们可能会推断出不正确的类型参数,但这将在稍后检查赋值时导致错误。在最佳情况下,我们找到了一个有用且正确的类型参数。在我们的示例中,不精确的统一成功,并且我们正确地推断出 Eint

简化 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

但这会将类型参数 AC 留在了推断的类型中,这没有帮助。就像在高中代数中一样,一旦某个变量 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)

无类型常量参数 12 都是整数常量,它们的默认类型是 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 中,行为相应地发生了变化。现在,如果多个无类型数字常量与同一个类型参数匹配,则选择在 intrunefloat64complex 列表中后面出现的默认类型,这与常量表达式的规则相匹配

foo(1, 2.0)    // Go 1.21: P ➞ float64 (larger default type of 1 and 2.0; behavior like in 1 + 2.0)

特殊情况

到目前为止,我们已经了解了类型推断的大致情况。但是,有一些重要的特殊情况值得关注。

参数顺序依赖项

第一个与参数顺序依赖项有关。我们希望从类型推断中获得的一个重要属性是,无论函数参数的顺序如何(以及该函数每次调用中相应的参数顺序),推断的类型都是相同的。

让我们重新考虑一下我们的可变参数 foo 函数:无论我们传递参数 st 的顺序如何,推断出的 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 开始,它会将 Pstruct 匹配;P 还没有推断出类型,因此统一推断出 P ➞ struct{}。当统一稍后在等式 2 中看到类型 T 时,它将继续使用 T 的底层类型,即 struct{}Punder(T) 统一,并且统一以及推断成功。

反之,如果统一从等式 2 开始,它会将 PT 匹配;P 还没有推断出类型,因此统一推断出 P ➞ T。当统一稍后在等式 1 中看到 struct{} 时,它将继续使用为 P 推断出的类型 T 的底层类型。该底层类型是 struct{},它与等式 1 中的 struct 匹配,并且统一以及推断成功。

因此,根据统一求解这两个类型等式的顺序,推断出的类型要么是 struct{},要么是 T。这当然令人不满意:程序可能会突然停止编译,仅仅因为参数可能在代码重构或清理期间被重新排列。

恢复顺序独立性

幸运的是,补救措施相当简单。我们只需要在某些情况下进行少量修正。

具体来说,如果统一正在求解 P :≡ T 并且

  • P 是一个类型参数,它已经推断出一个类型 AP ➞ A
  • A :≡ T 为真
  • T 是一个命名类型

然后将 P 的推断类型设置为 TP ➞ 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 调用自身,该参数与传入参数 n 具有相同的类型 P。在此调用中,类型参数 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 版本中看到增量改进的领域。重要的是,我们认为当前推断失败的情况要么很少见,要么在生产代码中不重要,并且我们当前的实现涵盖了所有有用代码场景中的绝大多数。

也就是说,如果您遇到认为类型推断应该工作或出现错误的情况,请提交问题!与往常一样,Go 团队很乐意收到您的反馈,尤其是在这有助于我们使 Go 变得更好的时候。

下一篇文章:Go 的十四年
上一篇文章:解构类型参数
博客索引