go泛型中为什么用中括号?

2022.3.15当天,在国内打假国人声讨奸商的时候,go官方发布了1.18版本。对于该版本来说,可能是近几年发生变化最大的一个版本。因为,有一个千呼万唤始出来的功能:泛型(Generics)。说起泛型,那些熟悉C++、Java的开发人员可能并不陌生。但是,对于那些没有接触过泛型的人来说,就需要花点时间来适应这一特性。

官方博客:
Go 1.18 is released! (https://go.dev/blog/go1.18)

1. go泛型

talk is cheap, show the code。

对于常见的:求两个有符号整数最大值这个功能 准备了两份代码,分别是未使用泛型以及使用泛型的版本,来感受一下泛型的魅力。

go中有符号整数类型,分别为:
int、int8、int16、int32、int64

  • 未使用泛型版本
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package main

func MaxInt(a, b int) int {
if a > b {
return a
}
return b
}

func MaxInt8(a, b int8) int8 {
if a > b {
return a
}
return b
}

func MaxInt16(a, b int16) int16 {
if a > b {
return a
}
return b
}

func MaxInt32(a, b int32) int32 {
if a > b {
return a
}
return b
}

func MaxInt64(a, b int64) int64 {
if a > b {
return a
}
return b
}

通过观察可以发现,针对每一种不同的整数类型都需要写一个相同的实现方法。存在的问题是:代码重复度非常高。

  • 使用泛型版本
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package main

// 写法1
// 通过 interface 定义泛型类型约束 SignIntNumber
type SignIntNumber interface {
int | int8 | int16 | int32 | int64
}

func MaxIntGenerics1[T SignIntNumber](a, b T) T {
if a > b {
return a
}
return b
}

// 写法2
func MaxIntGenerics2[T int | int8 | int16 | int32 | int64](a, b T) T {
if a > b {
return a
}
return b
}

func main() {
fmt.Println(
// 具体类型通过编译器进行推导
MaxIntGenerics1(1, 2),
MaxIntGenerics2(1, 2),

// 在中括号中指定某一特定类型
// MaxIntGenerics1[int]、MaxIntGenerics2[int] 也被称为:泛型类型实例化
MaxIntGenerics1[int](1, 2),
MaxIntGenerics2[int](1, 2),
MaxIntGenerics1[int8](1, 2),
MaxIntGenerics2[int8](1, 2),
MaxIntGenerics1[int16](1, 2),
MaxIntGenerics2[int16](1, 2),
MaxIntGenerics1[int32](1, 2),
MaxIntGenerics2[int32](1, 2),
MaxIntGenerics1[int64](1, 2),
MaxIntGenerics2[int64](1, 2),
)
}

通过观察可以发现,泛型版本的代码清爽非常多。
针对每一种不同的整数类型只有一个实现方法,具体类型取而代之的是使用类型参数列表来进行泛型约束。

2. 泛型提案中的其他备选方案

初次看到go泛型的你不知是否跟我一样有相同的疑惑:go泛型为什么使用中括号?

针对这个问题,笔者查阅了相关资料,在一篇关于go泛型的提案中找到了答案。

Type Parameters Proposal
https://go.googlesource.com/proposal/+/refs/heads/master/design/43651-type-parameters.md

下面是提案中 关于泛型类型参数列表其他3种备选方案 的内容摘录:

  • Why not use the syntax F like C++ and Java?

When parsing code within a function, such as v := F, at the point of seeing the < it’s ambiguous whether we are seeing a type instantiation or an expression using the < operator. This is very difficult to resolve without type information.

For example, consider a statement like

1
a, b = w < x, y > (z)

Without type information, it is impossible to decide whether the right hand side of the assignment is a pair of expressions (w < x and y > (z)), or whether it is a generic function instantiation and call that returns two result values ((w<x, y>)(z)).

It is a key design decision of Go that parsing be possible without type information, which seems impossible when using angle brackets for generics.

  • Why not use the syntax F(T)?

An earlier version of this design used that syntax. It was workable but it introduced several parsing ambiguities. For example, when writing var f func(x(T)) it wasn’t clear whether the type was a function with a single unnamed parameter of the instantiated type x(T) or whether it was a function with a parameter named x with type (T) (more usually written as func(x T), but in this case with a parenthesized type).

There were other ambiguities as well. For []T(v1) and []T(v2){}, at the point of the open parentheses we don‘t know whether this is a type conversion (of the value v1 to the type []T) or a type literal (whose type is the instantiated type T(v2)). For interface { M(T) } we don’t know whether this an interface with a method M or an interface with an embedded instantiated interface M(T). These ambiguities are solvable, by adding more parentheses, but awkward.

Also some people were troubled by the number of parenthesized lists involved in declarations like func F(T any)(v T)(r1, r2 T) or in calls like F(int)(1).

  • Why not use F«T»?

We considered it but we couldn’t bring ourselves to require non-ASCII.

在设计泛型时,官方除了最终确认使用的中括号,还设计了其他3种备选方案。但是,由于一些原因都放弃了。

3. 问题:go泛型类型参数列表为什么使用中括号

笔者针对提案中 关于泛型类型参数列表其他3种备选方案 进行整理,如下:

  • 备选方案1:为什么不使用类似 C++、Java 的 F<T> 语法?

针对该方案,官方给出了一个很有说服力的反面例子:

1
a, b = w < x, y > (z)

这是一个使用等号进行多值赋值的例子,问题出在等号右侧的 w < x, y > (z)
当你第一眼看到这段代码时,会不会有一种错觉:> 是大于号,< 是小于号。那么整个表达的意思是 (w < x and y > (z))。
然而,实际上应该是 ((w<x, y>)(z))

也正是这个关键性的问题,该方案最终被弃用。

  • 备选方案2:为什么不使用 F(T) 语法?

针对该方案,官方也给出了一些很有说服力的反面例子:

1
var f func(x(T))

这是一个变量声明的例子,变量 f 的底层类型为 func(x(T)),问题就出现这个底层类型。
当你第一眼看到这段代码时,会不会有一种错误:x(T) 是在调用方法 x

在这里,笔者再举一个C语言中的反面例子:

1
2
3
int (*(*func)[5])(int *p);

int (*(*func)(int *p))[5];

如果不是写了多年的C代码,你能看出来这是什么吗?

  • 备选方案3:为什么不使用 F«T»?

官方的确考虑过 «»,但是因为它们都不是 ASCII 码,最终被弃用。

ASCII码 在计算机中占用一个字节,类型实际是 uint8

关于 « 符号,有些地方称之为 左燕尾号,Unicode码为 U+00AB

https://www.emojiall.com/zh-hans/emoji/%c2%ab

针对这三种备选方案,被弃用的原因总结如下:
备选方案1、2容易让人产生困惑、误解。
备选方案3中的符号不是 ASCII 码。

提案中官方并没有直接说明使用中括号的原因,而是通过反证法讲解其他几种备选方案存在的一些问题。正是因为这些原因,最终才导致选择了中括号。

4. 总结

到这里,想必你已经对 go中泛型的类型参数列表为什么使用中括号 有了属于自己的答案。
很多人说go官方在对go语言的升级迭代过程中一直遵循:大道至简的原则。尽量在不引入新特性的情况下实现新的功能,从而减小该语言的复杂度与学习成本。笔者认为,目前做的还不错,始终相信go的未来是光明的。