Go 1_18 泛型
# Go 1.18 泛型
Go 语言为什么要加入泛型语法特性?
Go 官方用户调查结果,在“你最想要的 Go 语言特性”这项调查中,泛型霸榜多年:
什么是泛型?
维基百科提到:最初泛型编程这个概念来自于缪斯·大卫和斯捷潘诺夫. 亚历山大合著的“泛型编程”一文。那篇文章对泛型编程的诠释是:“泛型编程的中心思想是对具体的、高效的算法进行抽象,以获得通用的算法,然后这些算法可以与不同的数据表示法结合起来,产生各种各样有用的软件”。说白了就是将算法与类型解耦,实现算法更广泛的复用。
简单的例子:一个简单得不能再简单的加法函数,这个函数接受两个 int32 类型参数作为加数:
func Add(a, b int32) int32 {
return a + b
}
2
3
上面的函数 Add 仅适用于 int32 类型的加数,如果要对 int、int64、byte 等类型的加数进行加法运算,还需要实现 AddInt、AddInt64、AddByte 等函数。
用泛型编程的思想来解决这个问题,需要将算法与类型解耦,实现一个泛型版的 Add 算法,用 Go 泛型语法实现的泛型版 Add 是这样的(注意这里需要使用 Go 1.18beta1 或后续版本进行编译和运行):
func Add[T constraints.Integer](a, b T) T {
return a + b
}
2
3
这样,就可以直接使用泛型版 Add 函数去进行各种整型类型的加法运算了,比如下面代码:
func main() {
var m, n int = 5, 6
println(Add(m,n)) // Add[int](m, n)
var i,j int64 = 15, 16
println(Add(i,j)) // Add[int64](i, j)
var c,d byte = 0x11, 0x12
println(Add(c,d)) // Add[byte](c, d)
}
2
3
4
5
6
7
8
在没有泛型的情况下,需要针对不同类型重复实现相同的算法逻辑,比如上面例子提到的 AddInt、AddInt64 等。这对于简单的、诸如上面这样的加法函数还可忍受,但对于复杂的算法,比如涉及复杂排序、查找、树、图等算法,以及一些容器类型(链表、栈、队列等)的实现时,缺少了泛型的支持还真是麻烦。在没有泛型之前,Gopher 们通常使用空接口类型 interface{},作为算法操作的对象的数据类型,不过这样做的不足之处也很明显:一是无法进行类型安全检查,二是性能有损失。
为什么 Go 不早点加入泛型?
这个语法特性不紧迫,不是 Go 早期的设计目标;
在 Go 诞生早期,很多基本语法特性的优先级都要高于泛型。此外,Go 团队更多将语言的设计目标定位在规模化(scalability)、可读性、并发性上,泛型与这些主要目标关联性不强。等 Go 成熟后,Go 团队会在适当时候引入泛型。
与简单的设计哲学有悖;
Go 语言最吸睛的地方就是简单,简单也是 Go 设计哲学之首!但泛型这个语法特性会给语言带来复杂性,这种复杂性不仅体现在语法层面上引入了新的语法元素,也体现在类型系统和运行时层面上为支持泛型进行了复杂的实现。
尚未找到合适的、价值足以抵消其引入的复杂性的理想设计方案。
从 Go 开源那一天开始,Go 团队就没有间断过对泛型的探索,并一直尝试寻找一个理想的泛型设计方案,但始终未能如愿。直到近几年 Go 团队觉得 Go 已经逐渐成熟,是时候下决心解决 Go 社区主要关注的几个问题了,包括泛型、包依赖以及错误处理等,并安排伊恩·泰勒和罗伯特·格瑞史莫花费更多精力在泛型的设计方案上,这才有了在即将发布的 Go 1.18 版本中泛型语法特性的落地。
Go 泛型设计的简史
从 2009 年 12 月 3 日Russ Cox 在其博客站点上发表的一篇叫 “泛型窘境” (opens new window) 的文章中,Russ Cox 提出了 Go 泛型实现的三个可遵循的方法,以及每种方法的不足,也就是三个 slow(拖慢):
拖慢程序员:
不实现泛型,不会引入复杂性,需要程序员花费精力重复实现 AddInt、AddInt64 等;
拖慢编译器:
就像 C++ 的泛型实现方案那样,通过增加编译器负担为每个类型实例生成一份单独的泛型函数的实现,这种方案产生了大量的代码,其中大部分是多余的,有时候还需要一个好的链接器来消除重复的拷贝;
拖慢执行性能:
就像 Java 的泛型实现方案那样,通过隐式的装箱和拆箱操作消除类型差异,虽然节省了空间,但代码执行效率低。
伊恩·泰勒主要负责跟进 Go 泛型方案的设计。从 2010 到 2016 年,伊恩·泰勒先后提出了几版泛型设计方案,它们是:
- 2010 年 6 月份,伊恩·泰勒提出的Type Functions (opens new window)设计方案;
- 2011 年 3 月份,伊恩·泰勒提出的Generalized Types (opens new window)设计方案;
- 2013 年 10 月份,伊恩·泰勒提出的Generalized Types 设计方案更新版 (opens new window);
- 2013 年 12 月份,伊恩·泰勒提出的Type Parameters (opens new window)设计方案;
- 2016 年 9 月份,布莱恩·C·米尔斯提出的Compile-time Functions and First Class Types (opens new window)设计方案。
Go 泛型的性能
创建一个性能基准测试的例子 (opens new window),参加这次测试的三位选手分别来自:
- Go 标准库 sort 包(非泛型版)的 Ints 函数;
- Go 团队维护 golang.org/x/exp/slices 中的泛型版 Sort 函数;
- 对 golang.org/x/exp/slices 中的泛型版 Sort 函数进行改造得到的、仅针对[]int 进行排序的 Sort 函数。
使用 Go 1.18beta2 版本在 macOS 上运行该测试的结果:
$go test -bench .
goos: darwin
goarch: amd64
pkg: demo
cpu: Intel(R) Core(TM) i5-8257U CPU @ 1.40GHz
BenchmarkSortInts-8 96 12407700 ns/op 24 B/op 1 allocs/op
BenchmarkSlicesSort-8 172 6961381 ns/op 0 B/op 0 allocs/op
BenchmarkIntSort-8 172 6881815 ns/op 0 B/op 0 allocs/op
PASS
2
3
4
5
6
7
8
9
泛型版和仅支持[]int 的 Sort 函数的性能是一致的,性能都要比目前标准库的 Ints 函数高出近一倍,并且在排序过程中没有额外的内存分配。由此我们可以得出结论:至少在这个例子中,泛型在运行时并未给算法带来额外的负担。
Go 1.18 发布说明 (opens new window)中给出了一个结论:Go 1.18 编译器的性能要比 Go 1.17 下降 15% 左右。不过,Go 核心团队也承诺将在 Go 1.19 中改善编译器的性能,这里也希望到时候的优化能抵消 Go 泛型带来的影响。
Go 团队提出的 Go 泛型使用场景及使用建议
什么情况适合使用泛型:
- **当编写的函数的操作元素的类型为 slice、map、channel 等特定类型的时候:**如果一个函数接受这些类型的形参,并且函数代码没有对参数的元素类型作出任何假设,那么使用类型参数可能会非常有用。在这种场合下,泛型方案可以替代反射方案,获得更高的性能。
- **编写通用数据结构:**所谓的通用数据结构,指的是像切片或 map 这样,但 Go 语言又没有提供原生支持的类型。比如一个链表或一个二叉树,用类型参数替换接口类型通常也会让数据存储的更为高效。
- 在一些场合,使用类型参数替代接口类型,意味着代码可以避免进行类型断言(type assertion),并且在编译阶段还可以进行全面的类型静态检查。
什么情况不宜使用泛型:
如果要对某一类型的值进行的全部操作,仅仅是在那个值上调用一个方法,请使用 interface 类型,而不是类型参数。
比如,io.Reader 易读且高效,没有必要像下面代码中这样使用一个类型参数像调用 Read 方法那样去从一个值中读取数据:
func ReadAll[reader io.Reader](r reader) ([]byte, error) // 错误的作法 func ReadAll(r io.Reader) ([]byte, error) // 正确的作法
1
2如果使用类型参数会让你的代码变得更复杂,就不要使用。
当不同的类型使用一个共同的方法时,如果一个方法的实现对于所有类型都相同,就使用类型参数;相反,如果每种类型的实现各不相同,请使用不同的方法,不要使用类型参数。
如果发现自己多次编写完全相同的代码(样板代码),各个版本之间唯一的差别是代码使用不同的类型,那就请你考虑是否可以使用类型参数。反之,在你注意到自己要多次编写完全相同的代码之前,应该避免使用类型参数。
Go 1.18 仅仅是 Go 泛型的起点,就像 Go Module 构建机制一样,Go 泛型的成熟与稳定还需要几个 Go 发布版本的努力。