Go 是一门语法元素少、设计简单的编程语言,简单的设计往往意味着较弱的表达能力,工程师也需要使用更多时间编写重复的逻辑。Go 语言从发布到今天已经过去了 10 多年,向 Go 语言添加泛型的讨论也从 2010 年一直持续到今天。社区对泛型的讨论非常多,呼声也非常高,下这里列举一些泛型相关的讨论和反馈:
proposal: spec: generic programming facilities 有 500 多条泛型相关的讨论1;
Generics · ExperienceReports 列出了一些讨论 Go 语言泛型的文章2;
Go 2 Generics Feedback 包含对 Go 2 泛型草案的反馈和建议3;
很多人都认为 Go 语言永远不会加入泛型,然而这不是正确的结论,Go 语言很可能会在第二个主要版本中加入泛型4。所以本文要分析的问题是 —— 为什么 Go 语言到目前为止都没有泛型,以及这些原因是否已经被解决,又是如何被解决的。
如果你对 Go 语言的标准库稍微有一些了解,你能找到一些如下所示的函数签名:
上述函数都是 sort
包提供的,它们的功能非常相似,底层的实现也使用了近乎相同的逻辑,但是由于传入类型的不同却需要对外提供多个函数。Java 的泛型就解决了这个问题:
这段 Java 代码使用泛型数组作为参数实现了通用的数组排序逻辑,任意类型只要实现了 Comparable
接口,insertionSort
函数就能排序由该对象组成的数组。使用泛型能够减少重复的代码和逻辑,为工程师提供更强的表达能力从而提升效率。
既然泛型能够增强语言的表达能力,提升工程师的效率,那么为什么 Go 语言到目前为止也不支持泛型呢?本文总结了两个原因:
泛型困境使我们必须在开发效率、编译速度和运行速度三者中选择两个;
目前社区中的 Go 语言方案都是有缺陷的,而 Go 团队认为泛型的支持不够紧急;
上述两个原因导致 Go 语言没有在 1.x 版本中加入泛型。
泛型困境
泛型和其他特性一样不是只有好处,为编程语言加入泛型会遇到需要权衡的两难问题。语言的设计者需要在编程效率、编译速度和运行速度三者进行权衡和选择5,编程语言要选择牺牲一个而保留另外两个。
图 1 - 泛型困境
我们以 C、C++ 和 Java 为例,介绍它们在设计上的不同考量:
C 语言是系统级的编程语言,它没有支持泛型,本身提供的抽象能力非常有限。这样做的结果是牺牲了程序员的开发效率,与 Go 语言目前的做法一样,它们都需要手动实现不同类型的相同逻辑。但是不引入泛型的好处也显而易见 —— 降低了编译器实现的复杂度,也能保证源代码的编译速度;
C++ 与 C 语言的选择完全不同,它使用编译期间类型特化实现泛型,提供了非常强大的抽象能力。虽然提高了程序员的开发效率,不再需要手写同一逻辑的相似实现,但是编译器的实现变得非常复杂,泛型展开会生成的大量重复代码也会导致最终的二进制文件膨胀和编译缓慢,我们往往需要链接器来解决代码重复的问题;
Java 在 1.5 版本引入了泛型,它的泛型是用类型擦除实现的。Java 的泛型只是在编译期间用于检查类型的正确,为了保证与旧版本 JVM 的兼容,类型擦除会删除泛型的相关信息,导致其在运行时不可用。编译器会插入额外的类型转换指令,与 C 语言和 C++ 在运行前就已经实现或者生成代码相比,Java 类型的装箱和拆箱会降低程序的执行效率6;
图 2 - 不同语言的决策
当我们面对是否应该支持泛型时,实际上需要考虑的问题是:我们应该牺牲工程师的开发效率、牺牲编译速度和更大的编译产物还是牺牲运行速度。
泛型的引入一定会影响编译速度和运行速度,同时也会增加编译器的复杂度,所以社区在考虑泛型时也非常谨慎。Go 2 的泛型提案在面对这个问题时没有进行选择,让具体实现决定是应该影响编译速度(单独编译不同的类型参数)还是运行时间(使用方法调用在运行时决定具体执行的函数)。
不紧急不完善
Go 语言团队认为加入泛型并不紧急7,更重要的是完善运行时机制,包括 调度器8、垃圾收集器等功能。作者在使用 Go 语言时,对泛型没有特别多的需求,只是在提供一些通用的抽象逻辑时不得不使用 interface{}
作为方法的参数,这不是一种很好的做法,但也是在当前语言限制下为数不多的方法。
社区中的大部分泛型提案都有各自的缺陷,所以不会被 Go 团队采纳,在这里我们为大家列出一部分提案,感兴趣的读者可以访问下面的链接了解更多的内容:
正是因为向 Go 语言中加入泛型并不是团队的首要工作,而过去的提案都有明显的缺陷,所以从 Go 语言发布 10 多年以来一直都没有支持泛型。
2019 年 7 月底,Go 团队发布了 Go 2 泛型设计的草稿 Contracts - Draft Design9,这个设计草稿建议增加参数多态来扩展 Go 语言,有了参数多态,函数能够接收的参数不再仅限于子类型关系(Subtyping),还可以有显式的结构约束(Structural constraint),下面的代码就约束了切片中的类型 T
需要满足 stringer
合约:
该提案从语法(Syntax)、类型约束(Type constraint)、类型推导(Type inference)和实现(Implementation)四个方面提出 Go 语言应该如何支持泛型:
语法 —— 泛型、函数和方法是如何声明和使用的?
类型约束 —— 如何定义类型约束?
类型推导 —— 什么时候函数调用可以忽略类型参数?
实现 —— 使用编译期替换还是运行时替换?
与之前的提案相比,这是 Go 团队目前能给出的最好方案,cmd/compile/internal/syntax: parse/print support for type parameters and contracts10 展示了如何通过修改编译器来支持提案中的语法,然而这也只是一个简单的原型,最终的实现和草案本身都需要经过社区的讨论。
总结
Go 语言从来没有旗帜鲜明地反对向语言中加入泛型这一特性,很多人对于 Go 的这一决策都有误解。到目前为止,Go 语言没有泛型的原因也可以简单总结成两点:
泛型困境是所有编程语言都需要面对的,也是加入泛型之前不得不深思熟虑的;
目前的多数泛型提案都有明显的缺陷,而且在 1.x 版本中,提升语言其他方面性能带来的收益比泛型带来的更多;
Go 2 的泛型草案暂时也没有解决这两个问题。它只是决定了引入泛型来增强语言的表达能力,提高程序员的生产力,但是却绕过了编译速度和运行速度的抉择问题,我们还不清楚最终到底会如何决策;最新的草案与之前的版本相比已经相对完善,但是还有很多的问题需要解决,例如:隐式约束(Implied constraints)、双重实现(Dual implementation)等。
作者相信 Go 社区能够做出相对合理的决策,并解决引入泛型带来的问题。到最后,我们还是来看一些比较开放的相关问题,有兴趣的读者可以仔细思考一下下面的问题:
Go 草案中的泛型设计与 Java 或者其他语言有哪些不同?
Go 语言中的哪些标准库可以被泛型重写?
本文转载自 Draveness 技术网站。
原文链接:https://draveness.me/whys-the-design-go-generics
评论