Go 在 Google:服务于软件工程的语言设计(翻译)(一)
本文译者:Jayce Chant,来源: 存档SaveAndLoad,原文链接:https://mp.weixin.qq.com/s/f0XkH-rFkgDkQfhsMsJ-uQ
最近在写 Go 语言实战系列的文章,中间想聊一下 Go 的设计原则,发现自己理解得还是不够深入,写得辞不达意。然后找到了 Rob Pike 在 8 年前的演讲稿,拜读学习之后,想推荐给我的读者作为学习资料。
结果在中文互联网只找到了 OSCHINA 上 13 年的众包翻译,再也没找到其他翻译版本。这个版本,不同译者,以及同一译者的不同段落,翻译水平差异极大:个别地方翻译得非常传神,更多时候是忠实的术语翻译,偶有生硬直译和机翻的感觉,同时也能找到一些明显的理解错误和低级的笔误。
总的来说,如果对 Go、C 家族语言 以及 并发、垃圾回收等 涉及的主题有一定了解,翻译的瑕疵不影响理解。译文翻译于 13 年,很早,应该对中文世界早期 Go 的推广起了一定的作用,向这些译者致谢。但是如果是刚接触编程或者 Go 语言的初学者,个别错误可能会让人看得云里雾里。
所以我不自量力地尝试自己翻译一遍。首先是试图提供一个质量稍微高一点点的版本(不一定能成功),其次也是希望通过这样再深入学习一遍。
为了符合中文的阅读习惯,在(尽量)不影响原意的前提下,一些句子(特别是长从句)的语序作了调整,个别不符合中文表达习惯的表述做了删减或者补充。文中的加粗也是我个人划的重点。水平所限,译文在(计算机)专业上和英语理解上不可避免地会有理解偏差乃至错误,存疑的地方请结合原文理解。翻译过程有借助 辞典 和 DeepL 翻译器作为参考,个别表述有借鉴 OSCHINA 版译文。
原文:Go at Google: Language Design in the Service of Software Engineering
地址:https://talks.golang.org/2012/splash.article
作者:Rob Pike
翻译:Jayce Chant(博客:jaycechant.info,公众号ID:jayceio)
Rob Pike:Unix 小组成员,参与了 Plan 9 计划,1992 年和 Ken Thompson 共同开发了 UTF-8。他和 Ken Thompson 也是 Go 语言最早期的设计者。
译文较长,分三篇推送,这里是第 1~7 小节。
1. 摘要
这是 Rob Pike 2012 年 10 月 25 日在 亚利桑那州 图森市 举行的 SPLASH 2012 会议上发表的主题演讲稿的修订版。
我们在 Google 开发软件基础设施时遇到一些问题,针对这些问题,Go 语言在 2007 年末被构思出来。今天的计算环境与正在使用的语言(主要是 C++、Java 和 Python)创建时的环境几乎毫无关系。多核处理器、网络系统、大规模计算集群和网络编程模型所带来的问题,人们只是用变通办法暂时绕开(being worked around),而不是正面解决(addressed head-on)。另外,软件的规模也发生了变化:今天的服务器程序由数千万行代码组成,需要成百上千的程序员共同协作,并且每天都在更新。更糟糕的是,即使是在大型编译集群上,构建(build)时间也会延长到几分钟,甚至几小时。
设计和开发 Go 就是为了在这种环境下提高工作效率。 Go 设计的考虑因素,除了众所周知的像 内置并发 和 垃圾回收,还包括 严格的依赖管理、软件架构在系统增长时的适应性,以及跨组件的健壮性。
本文将解释在构建一个高效的、编译型的、轻量级的、使人愉悦的编程语言的过程中,如何解决这些问题。例子 和 解释 都来自 Google 实际遇到的问题。
2. 简介
Go 是 Google 开发的一种编译型、支持并发、带垃圾回收、静态类型的语言。它是一个开源项目:Google 从公共代码库导入代码,而不是反过来。
Go 运行效率高、可伸缩性强,而且工作效率也高。有些程序员觉得用它干活很有趣;有些则觉得它缺乏想象力,甚至很无聊。在本文中,我们将解释为什么这些观点并不矛盾。Go 是为解决 Google 在软件开发中面临的问题而设计的,这导致 Go 并不是一门在研究领域有突破性的语言;尽管如此它仍是大型软件项目工程化的优秀工具。
译者注:这是 8 年前的演讲。Go 初期确实是为了解决 Google 内部的问题而诞生的。但如今已经是诞生的第 11 个年头,Go 早已被寄予更多的期待。它要解决的问题没变,只是不再局限于 Google 的内部场景。
3. Go 在 Google
Google 设计 Go 用来帮助解决 Google 自己的问题,而 Google 的问题很 大。
硬件大,软件也大。软件有好几百万行,服务器大部分用 C++,剩余的部分大量使用 Java 和 Python。成千上万的工程师在代码上工作,这些代码位于一个包含了所有软件的单棵大树的『头部』,所以树的各个层次一天到晚都有重要变更。使用大型的、定制的分布式构建系统使这种规模的开发变得可行,但它仍然很大。
当然,所有这些软件都运行在无数(zillions)台机器上,这些机器被看作数量不多的独立的、互相联网的计算集群。
简而言之,Google 的开发规模很大,速度可能很慢,而且经常显得很笨拙。但它是有效的。
Go 项目的目标,是消除 Google 软件开发中的缓慢和笨拙,从而使开发过程更加高效和获得更强的可伸缩性。这个语言是由编写、阅读、调试和维护大型软件系统的人设计的,也是为这些人设计的。
因此,Go 的目的不是要做编程语言设计的研究,而是要改善语言设计者及其同事的工作环境。Go 考虑的更多是软件工程的问题,而不是编程语言方面的科研。换句话说,它围绕的是『服务于软件工程的语言设计』。
但是,一门语言如何对软件工程有所助益呢?本文剩下的内容就是对这个问题的回答。
4. 痛点
在 Go 刚推出时,有人声称,它缺少现代语言所必需的某些特性或方法论。缺少这些的 Go 能有什么价值呢?我们的回答是,Go 所具备的某些特性,可以解决严重困扰大规模软件开发的一些问题。这些问题包括
构建速度慢
失控的依赖关系
每个程序员使用相同语言的不同子集
程序难以理解(代码难以阅读,文档不完善等)
重复劳动
更新代价大
版本偏斜(version skew)
难以编写自动化工具
跨语言构建
一门语言的单个特性并不能解决这些问题。这需要有软件工程的大局观(larger view),所以在 Go 的设计中,我们试图把重点放在解决这些问题上。
作为一个简单而且独立的例子,我们来看一下程序结构的表示方式。一些观察者反对 Go 用花括号({...}
)来表示类似于 C 的块状结构,他们更喜欢用 Python 或 Haskell 风格的空格来缩进。然而,我们见过太多由跨语言构建引起的构建和测试失败:嵌入到另一种语言里的 Python 代码段(例如通过 SWIG 调用),会因为周围代码缩进的变化而被意外地破坏,而且非常难以察觉。因此,我们的观点是,虽然空格缩进对于小程序来说是不错的选择,但它并不具有大程序所需要的可伸缩性;而且代码库越大,异构性越强,就会带来越多的麻烦。为了安全和可靠,最好还是放弃这点便利,所以 Go 使用花括号表示的代码块。
5. C 和 C++ 中的依赖关系
更能实质性地说明上面提到的可伸缩性和其他问题的,是包依赖关系的处理。我们从回顾 C 和 C++ 如何处理依赖关系开始讨论。
最早于 1989 年标准化的 ANSI C 在标准头文件里推广了 #ifndef
『防护(guards)』的概念。这个做法现在已经是无处不在,就是每个头文件都要用一个条件编译语句(clause)包裹起来,这样做就算这个头文件被多次包含(include)也不会出错。例如,Unix 头文件
的结构是这样的:
/* 大段的版权和许可证声明 */
#ifndef _SYS_STAT_H_
#define _SYS_STAT_H_
/* 类型和其他定义 */
#endif
这样做的目的,是让 C 语言预处理器在第二次以及后续读到该文件时,忽略被包裹的内容。符号_SYS_STAT_H_
在第一次读取文件时被定义,避免(guards)了后续的调用。
这样设计有一些好处,最重要的是每个头文件可以安全地 #include
它所有的依赖,即使其他头文件也包含这些依赖,都不会有问题。如果遵循这个规则,并且按字母顺序排列 #include
语句,可以写出有条理的代码。
但它的可伸缩性非常差。
1984 年,有人发现编译 ps.c(Unix ps 命令的源码)时,整个预处理过程会遇到 37 次 #include
。尽管后面 36 次头文件的内容都会被忽略,但大多数 C 语言的实现每次都会打开文件、读取文件、完整扫描内容,一连串动作下来,一共 37 次。 这样做非常不聪明,但是 C 预处理器需要处理非常复杂的宏语义,使它只能这样实现。
这对软件造成的影响是, C 程序里 #include
语句会不断累积。添加 #include
语句不会破坏程序,却很难知道什么时候不再需要它们。删除一条 #include
后再编译一次也检查不出来,因为可能另一条 #include
本身就包含你刚刚删除的那条 #include
。
从技术的角度讲,没必要弄成这样子。意识到使用 #ifndef
防护的长期问题,Plan 9 库的设计者们采取了一种不同的、非 ANSI 标准的做法。在 Plan 9 里,头文件禁止包含更多的 #include
语句;所有的 #include
都要放在顶层 C 文件里。当然,这需要一些纪律:程序员需要按照正确的顺序、准确地列出必要的依赖关系;但文档可以帮上忙,而且在实践中效果非常好。这样做的结果是,无论一个 C 源文件有多少依赖,在编译该文件时,每个 #include
文件都只会被读取一次。而且,只要把 #include
语句先删掉就能很容易地看出来它是否必要:当且仅当删除的依赖不是必要的依赖时,编辑后的程序才能通过编译。
Plan 9 做法最重要的结果是编译速度更快:编译所需的 I/O 量比使用带有 #ifndef
防护的库时大大减少。
但在 Plan 9 之外,『防护』法仍是 C 和 C++ 的公认做法。事实上,C++ 在更细的粒度上使用同样的做法还加剧了这个问题 。按照惯例,C++ 程序的结构通常是每个类有一个头文件,也可能是一小组的相关类有一个头文件,这种分组方式比像
这样的头文件要小得多。因此,它的依赖树要复杂得多,反映的不是库之间的依赖关系,而是完整的类型层次结构。此外,C++ 头文件通常包含真正的代码——类型、方法和模板声明——而不仅仅是一般 C 头文件里常见的简单常量和函数签名。因此,C++ 不仅向编译器推送了更多的信息,而且推送的内容更难编译,编译器的每次调用都必须重新处理这些信息。在构建一个大型的 C++ 二进制文件时,编译器可能要成千上万次地处理头文件
去学会如何表示一个字符串。(据记录,1984 年左右,Tom Cargill 就提到,使用 C 预处理器进行依赖管理将是 C++ 的长期负担,应该加以解决。)
在 Google,构建一个 C++ 二进制文件,打开和读取不同的头文件可以达到数百个,次数可以达到数万次。2007 年,Google 的构建工程师对 Google 的一个主要二进制文件的编译进行了检测。这个二进制文件包含了大约两千个源文件,如果简单地连在一起,总共有 4.2 MB。在所有 #include
语句被展开后,超过 8 GB 内容被送到编译器的输入端,也就是源码里的每个字节膨胀了 2000 倍。
另一个数据是,2003 年,Google 的构建系统从单一的 Makefile 转变为每个目录都有 Makefile 的设计,有了更好的管理,更明确的依赖关系。仅仅是因为有了更精确的依赖关系记录,一个典型的二进制文件在文件大小上就缩减了 40%。即便如此,C++ (或 C 语言)的特性使得自动验证这些依赖关系难以实现,直到今天,我们对 Google 的大型 C++ 二进制文件的依赖关系需求仍然没有一个准确的把握。
依赖关系失控和规模太大的后果是,在单台计算机上构建 Google 服务器的二进制文件变得不切实际,一个大型的分布式编译系统应运而生。有了这个加了很多机器、很多缓存、很多复杂的东西的系统(构建系统本身就是一个大程序),Google 的构建总算可以进行,虽然还是很麻烦。
即使采用分布式构建系统,Google 的一次大型构建仍然需要很长时间。前面提到 2007 年的那个二进制程序使用上一版的分布式构建系统花了 45 分钟;同一程序今天的版本花了 27 分钟,当然这期间程序和它的依赖关系也还在增长。扩大构建系统的工程投入,只能勉强比它所构建的软件的增长速度领先一点。
6. 走进 Go
当构建速度很慢时,就有了时间去思考。Go 有那么一个起源传说(origin myth),声称 Go 正是在其中一次 45 分钟的构建过程中被构思出来的。设计一门新的语言,使它适合编写像 Web 服务器这样的大型 Google 程序,同时考虑到软件工程的因素,可以提高 Google 程序员的生活质量。人们相信这个目标值得一试。
虽然到目前为止的讨论都集中在依赖关系上,但还有许多其他问题需要注意。一门语言要想在上述背景下取得成功,主要的考虑因素是:
它必须适应大规模开发。能在有大量依赖关系、大量程序员团队一起协作的大型程序项目上很好地工作。 它必须是大家熟悉的,大致上类似于 C 语言的。在 Google 工作的程序员处于职业生涯的早期,对过程式编程语言(procedural languages),尤其是来自 C 家族的语言最熟悉。要想让程序员在新语言中快速提高工作效率,意味着语言不能太激进。 它必须是现代的。C、C++ 以及 Java 的某些方面都相当老旧,是在多核机器、网络 和 web 应用开发 出现之前设计的。新的做法可以更好地适应现代世界的一些特点,比如内置的并发支持。
那么,在这样的背景下,让我们从软件工程的角度来看看 Go 的设计。
7. Go 的依赖关系
既然我们已经详细了解过 C 和 C++ 中的依赖关系,那么我们可以从 Go 如何处理依赖关系开始。依赖关系是由语言在语法和语义上定义的。它们是明确的、清晰的和『可计算』的,也就是说,很容易写工具来分析。
Go 的语法是,在 package
语句(下一节的主题)之后,每个源文件可以有一个或多个导入语句,每个导入语句由 import
关键字和一个字符串常量组成,标识要导入到当前源文件(且只限当前源文件)的包:
import "encoding/json"
让 Go 可以做到规模化、依赖智能化的第一步,是语言将 未使用的依赖 (unused dependencies)定义为编译期错误(注意不是警告,是错误)。如果源文件导入了一个它不用的包,程序就不会通过编译。这保证了任何 Go 程序构建中的依赖关系树都是精确的,没有多余的边。另一边又保证了在构建程序时不会有多余的代码被编译,从而最大限度地减少了编译时间。
第二步是在编译器的实现上,更进一步保证效率。假设一个有三个包的 Go 程序,依赖关系如下:
A
包导入了B
包;B
包导入了C
包;A
包没有导入C
包。
这意味着 A 包只是在引用 B 包的过程中,间接地引用了 C 包;换句话说,尽管 A 引用的来自 B 的某些代码引用了 C,但在 A 的源码里没有直接涉及来自 C 的标识符。例如, A 包可能会引用一个在 B 里面定义的结构体类型,该结构体有一个字段的类型是在 C 里定义的,但 A 本身并不直接引用 C 里面的类型。一个更具体的例子是,A 导入了一个格式化 I/O 包 B,B 使用了 C 提供的缓冲 I/O 实现,但 A 本身并没有调用缓冲 I/O。
要构建这个程序,首先 C 被编译;被依赖的包必须在依赖它们的包之前构建。然后,B 被编译;最后 A 被编译,然后就可以链接程序。
在 A 被编译时,编译器读取的是 B 的目标文件而不是源代码。B 的目标文件包含了编译器在 A 的源代码里执行 import "B"
语句所需的所有类型信息。这些信息包括 B 的调用方(clients)在编译时需要的任何关于 C 的信息。换句话说,当 B 被编译时,生成的目标文件包含了 B 所有公共接口所需的依赖关系的类型信息。
这种设计的一个重要的效果,就是 当编译器执行一条 import
语句时,只会打开一个文件 ,那就是导入语句里的字符串所标识的目标文件。这让人不由得想起 Plan 9 C(相对于 ANSI C)的依赖管理方法,但实际上编译器在编译 Go 源文件的时候就会写入头文件。考虑到导入时读取的数据只是『导出的(exported)』数据,而不是一般的程序源代码,这个过程比 Plan 9 C 更自动,甚至更高效。这对整体编译时间可以造成巨大的影响,还能随着代码库的增长弹性地伸缩。与 C 和 C++ 的 『include 文件里还有 include』的模式相比,生成依赖图(dependency graph)并编译的时间可以指数级地减少。
值得一提的是,这种通用的依赖管理方法并不是独创的,其思想可以追溯到 20 世纪 70 年代,流传于 Modula-2 和 Ada 等语言中。在 C 语言家族中,Java 也有这种方法的元素。
为了使编译更有效率,目标文件的内容是经过编排的,导出数据就在文件的开头,所以编译器只要读到导出数据的结尾就可以结束,不需要读取整个文件。
这种依赖管理方法是 Go 编译比 C 或 C++ 快的一个最大原因。另一个因素是 Go 把导出数据放在目标文件里,作为对比有些语言需要作者手写或编译器生成包含这些信息的另外的文件。这就需要打开两倍数量的文件。在 Go 里,导入一个包只需要打开一个文件。另外,单文件的方式意味着导出数据(类似 C / C++ 里的头文件)相对于目标文件来说,永远不会过时。
为了做一个对比,我们测量了一个用 Go 编写的大型 Google 程序的编译情况,看看源代码的扇出量与前面做的 C++ 分析相比如何。(译者注:这里指第五节提到的 C++ 头文件展开后的内容量和源代码的比值,为 2000 倍。)我们发现大约是 40 倍,比 C++ 好了 50 倍(同时也更简单,因此处理速度更快),但还是比我们预期的大。这有两个原因。首先,我们发现了一个 bug:Go 编译器在导出部分生成了大量不需要的数据。其次,导出数据使用的是一种冗长的编码,还有改进的余地。我们已经计划解决这些问题。(译者注:Go 在 2012 年 3 月才发布了 1.0 版本,到现在已经过去了 8 年多,到了 1.15 。这中间 Go 团队投入了大量时间在 编译器、运行时 和 工具链的优化上,这两个问题应该已经得到了很大的改善,甚至可能已经彻底解决。)
尽管如此,减少到五十分之一,就足以把几分钟变成几秒钟,把茶歇时间变成交互式构建。
Go 依赖图的另一个特点是它没有依赖环。语言定义了依赖中不能有循环导入,编译器和链接器都会检查确保不存在循环依赖。虽然循环导入偶尔有用,但它在规模上会带来严重的问题。循环导入要求编译器一次性处理更多的源文件,这就减缓了增量构建的速度。更重要的是,根据我们的经验,如果允许这样的导入,最终会把大片的源码树,纠缠成难以独立管理的几大块,使二进制文件膨胀,并使初始化、测试、重构、发布和其他软件开发任务变得复杂。
缺少循环导入偶尔会造成烦恼,但却能保持依赖树的干净,迫使包之间有明确的边界。就像 Go 里的许多设计决策一样,它迫使程序员更早地考虑一个更大范围的问题(在这里,这个问题是包的边界),这些问题如果留到以后,可能永远不会得到令人满意的解决。
Go 设计标准库的过程中,花费了大量精力在控制依赖关系上。如果只是需要一个函数,拷贝一点代码可能比直接拉来一个大库强。(如果出现新的核心依赖关系,系统构建中的测试就会报告问题。)依赖关系清晰胜过代码重用。实践中的一个例子是,(底层的)net
包有自己的 整型 到 小数 的转换程序,以避免依赖更大的、依赖关系更复杂的格式化 I/O 包。另一个例子是字符串转换包 strconv
有一个私有的 『可打印』字符定义的实现,而不是引入大块头的 Unicode 字符类表;strconv
通过包的测试来确保符合 Unicode 标准。
推荐阅读