Go在谷歌:以软件工程为目的的语言设计

编译:oschina,原文:Go at Google: Language Design in the Service of Software Engineering

1. 摘要

(本文是根据Rob Pike于2012年10月25日在Tucson, Arizona举行的SPLASH 2012大会上所做的主题演讲进行修改后所撰写的。)

针对我们在Google公司内开发软件基础设施时遇到的一些问题,我们于2007年末构思出Go编程语言。当今的计算领域同创建如今所使用的编程语言(使用最多的有C++、Java和Python)时的环境几乎没什么关系了。由多核处理器、系统的网络化、大规模计算机集群和Web编程模型带来的编程问题都是以迂回的方式而不是迎头而上的方式解决的。此外,程序的规模也已发生了变化:现在的服务器程序由成百上千甚至成千上万的程序员共同编写,源代码也以数百万行计,而且实际上还需要每天都进行更新。更加雪上加霜的是,即使在大型编译集群之上进行一次build,所花的时间也已长达数十分钟甚至数小时。

之所以设计开发Go,就是为了提高这种环境下的工作效率。Go语言设计时考虑的因素,除了大家较为了解的内置并发和内存垃圾自动回收这些方面之外,还包括严格的依赖管理、对随系统增大而在体系结构方面发生变化的适应性、跨组件边界的健壮性(robustness)。

本文将详细讲解在构造一门轻量级并让人感觉愉悦的、高效的编译型编程语言时,这些问题是如何得到解决的。讲解过程中使用的例子都是来自Google公司中所遇到的现实问题。

2. 简介

Go语言开发自Google,是一门支持并发编程和内存垃圾回收的编译型静态类型语言。它是一个开源的项目:Google从公共的代码库中导入代码而不是相反。

Go语言运行效率高,具有较强的可伸缩性(scalable),而且使用它进行工作时的效率也很高。有些程序员发现用它编程很有意思;还有一些程序员认为它缺乏想象力甚至很烦人。在本文中我们将解释为什么这两种观点并不相互矛盾。Go是为解决Google在软件开发中遇到的问题而设计的,虽然因此而设计出的语言不会是一门在研究领域里具有突破性进展的语言,但它却是大型软件项目中软件工程方面的一个非常棒的工具。

3. Google公司中的Go语言

为了帮助解决Google自己的问题,Google设计了Go这门编程语言,可以说,Google有很大的问题。

硬件的规模很大而且软件的规模也很大。软件的代码行数以百万计,服务器软件绝大多数用的是C++,还有很多用的是Java,剩下的一部分还用到了Python。成千上万的工程师在这些代码上工作,这些代码位于由所有软件组成的一棵树上的“头部”,所以每天这棵树的各个层次都会发生大量的修改动作。尽管使用了一个大型自主设计的分布式Build系统才让这种规模的开发变得可行,但这个规模还是太大 了。

当然,所有这些软件都是运行在无数台机器之上的,但这些无数台的机器只是被看做数量并不多若干互相独立而仅通过网络互相连接的计算机集群。

简言之,Google公司的开发规模很大,速度可能会比较慢,看上去往往也比较笨拙。但很效果。

Go项目的目标是要消除Google公司软件开发中的慢速和笨拙,从而让开发过程更加高效并且更加具有可伸缩性。该语言的设计者和使用者都是要为大型软件系统编写、阅读和调试以及维护代码的人。

因此,Go语言的目的不是要在编程语言设计方面进行科研;它要能为它的设计者以及设计者的同事们改善工作环境。Go语言考虑更多的是软件工程而不是编程语言方面的科研。或者,换句话说,它是为软件工程服务而进行的语言设计。

但是,编程语言怎么会对软件工程有所帮助呢?下文就是该问题的答案。

4. 痛之所在

当Go刚推出来时,有人认为它缺乏某些大家公认的现代编程语言中所特有的特性或方法论。缺了这些东西,Go语言怎么可能会有存在的价值?我们回答这个问题的答案在于,Go的确具有一些特性,而这些特性可以解决困扰大规模软件开发的一些问题。这些问题包括:

  • Build速度缓慢
  • 失控的依赖关系
  • 每个程序员使用同一门语言的不同子集
  • 程序难以理解(代码难以阅读,文档不全面等待)
  • 很多重复性的劳动
  • 更新的代价大
  • 版本偏斜(version skew)
  • 难以编写自动化工具
  • 语言交叉Build(cross-language build)产生的问题

一门语言每个单个的特性都解决不了这些问题。这需要从软件工程的大局观,而在Go语言的设计中我们试图致力于解决所有这些问题。

举个简单而独立的例子,我们来看看程序结果的表示方式。有些评论者反对Go中使用象C一样用花括号表示块结构,他们更喜欢Python或Haskell风格式,使用空格表示缩进。可是,我们无数次地碰到过以下这种由语言交叉Build造成的Build和测试失败:通过类似SWIG调用的方式,将一段Python代码嵌入到另外一种语言中,由于修改了这段代码周围的一些代码的缩进格式,从而导致Python代码也出乎意料地出问题了并且还非常难以觉察。 因此,我们的观点是,虽然空格缩进对于小规模的程序来说非常适用,但对大点的程序可不尽然,而且程序规模越大、代码库中的代码语言种类越多,空格缩进造成的问题就会越多。为了安全可靠,舍弃这点便利还是更好一点,因此Go采用了花括号表示的语句块。

5.C和C++中的依赖

在处理包依赖(package dependency)时会出现一些伸缩性以及其它方面的问题,这些问题可以更加实质性的说明上个小结中提出的问题。让我们先来回顾一下C和C++是如何处理包依赖的。

ANSI C第一次进行标准化是在1989年,它提倡要在标准的头文件中使用#ifndef这样的”防护措施”。 这个观点现已广泛采用,就是要求每个头文件都要用一个条件编译语句(clause)括起来,这样就可以将该头文件包含多次而不会导致编译错误。比如,Unix中的头文件<sys/stat.h>看上去大致是这样的:

此举的目的是让C的预处理器在第二次以及以后读到该文件时要完全忽略该头文件。符号_SYS_STAT_H_在文件第一次读到时进行定义,可以“防止”后继的调用。

这么设计有一些好处,最重要的是可以让每个头文件能够安全地include它所有的依赖,即时其它的头文件也有同样的include语句也不会出问题。 如果遵循此规则,就可以通过对所有的#include语句按字母顺序进行排序,让代码看上去更整洁。

但是,这种设计的可伸缩性非常差。

在1984年,有人发现在编译Unix中ps命令的源程序ps.c时,在整个的预处理过程中,它包含了<sys/stat.h>这个头文件37次之多。尽管在这么多次的包含中有36次它的文件的内容都不会被包含进来,但绝大多数C编译器实现都会把”打开文件并读取文件内容然后进行字符串扫描”这串动作做37遍。这么做可真不聪明,实际上,C语言的预处理器要处理的宏具有如此复杂的语义,其势必导致这种行为。

对软件产生的效果就是在C程序中不断的堆积#include语句。多加一些#include语句并不会导致程序出问题,而且想判断出其中哪些是再也不需要了的也很困难。删除一条#include语句然后再进行编译也不太足以判断出来,因为还可能有另外一条#include所包含的文件中本身还包含了你刚刚删除的那条#include语句。

从技术角度讲,事情并不一定非得弄成这样。在意识到使用#ifndef这种防护措施所带来的长期问题之后,Plan 9的library的设计者采取了一种不同的、非ANSI标准的方法。Plan 9禁止在头文件中使用#include语句,并要求将所有的#include语句放到顶层的C文件中。 当然,这么做需要一些训练 —— 程序员需要一次列出所有需要的依赖,还要以正确的顺序排列 —— 但是文档可以帮忙而且实践中效果也非常好。这么做的结果是,一个C源程序文件无论需要多少依赖,在对它进行编译时,每个#include文件只会被读一次。当然,这样一来,对于任何#include语句都可以通过先拿掉然后在进行编译的方式判断出这条#include语句到底有无include的必要:当且仅当不需要该依赖时,拿掉#include后的源程序才能仍然可以通过编译。

Plan 9的这种方式产生的一个最重要的结果是编译速度比以前快了很多:采用这种方式后编译过程中所需的I/O量,同采用#ifndef的库相比,显著地减少了不少。

但在Plan 9之外,那种“防护”式的方式依然是C和C++编程实践中大家广为接受的方式。实际上,C++还恶化了该问题,因为它把这种防护措施使用到了更细的粒度之上。按照惯例,C++程序通常采用每个类或者一小组相关的类拥有一个头文件这种结构,这种分组方式要更小,比方说,同<stdio.h>相比要小。因而其依赖树更加错综复杂,它反映的不是对库的依赖而是对完整类型层次结构的依赖。而且,C++的头文件通常包含真正的代码 —— 类型、方法以及模板声明 ——不像一般的C语言头文件里面仅仅有一些简单的常量定义和函数签名。这样,C++就把更多的工作推给了编译器,这些东西编译起来要更难一些,而且每次编译时编译器都必须重复处理这些信息。当要build一个比较大型的C++二进制程序时,编译器可能需要成千上万次地处理头文件<string>以了解字符串的表示方式。(根据当时的记录,大约在1984年,Tom Cargill说道,在C++中使用C预处理器来处理依赖管理将是个长期的不利因素,这个问题应该得到解决。)

在Google,Build一个单个的C++二进制文件就能够数万次地打开并读取数百个头文件中的每个头文件。在2007年,Google的build工程师们编译了一次Google里一个比较主要的C++二进制程序。该文件包含了两千个文件,如果只是将这些文件串接到一起,总大型为4.2M。将#include完全扩展完成后,就有8G的内容丢给编译器编译,也就是说,C++源代码中的每个自己都膨胀成到了2000字节。 还有一个数据是,在2003年Google的Build系统转变了做法,在每个目录中安排了一个Makefile,这样可以让依赖更加清晰明了并且也能好的进行管理。一般的二进制文件大小都减小了40%,就因为记录了更准确的依赖关系。即使如此,C++(或者说C引起的这个问题)的特性使得自动对依赖关系进行验证无法得以实现,直到今天我们仍然我发准确掌握Google中大型的C++二进制程序的依赖要求的具体情况。

由于这种失控的依赖关系以及程序的规模非常之大,所以在单个的计算机上build出Google的服务器二进制程序就变得不太实际了,因此我们创建了一个大型分布式编译系统。该系统非常复杂(这个Build系统本身也是个大型程序)还使用了大量机器以及大量缓存,藉此在Google进行Build才算行得通了,尽管还是有些困难。 即时采用了分布式Build系统,在Google进行一次大规模的build仍需要花几十分钟的时间才能完成。前文提到的2007年那个二进制程序使用上一版本的分布式build系统花了45分钟进行build。现在所花的时间是27分钟,但是,这个程序的长度以及它的依赖关系在此期间当然也增加了。为了按比例增大build系统而在工程方面所付出的劳动刚刚比软件创建的增长速度提前了一小步。

6. 走进 Go 语言

当编译缓慢进行时,我们有充足的时间来思考。关于 Go 的起源有一个传说,话说正是一次长达45分钟的编译过程中,Go 的设想出现了。人们深信,为类似谷歌网络服务这样的大型程序编写一门新的语言是很有意义的,软件工程师们认为这将极大的改善谷歌程序员的生活质量。

尽管现在的讨论更专注于依赖关系,这里依然还有很多其他需要关注的问题。这一门成功语言的主要因素是:

  • 它必须适应于大规模开发,如拥有大量依赖的大型程序,且又一个很大的程序员团队为之工作。
  • 它必须是熟悉的,大致为 C 风格的。谷歌的程序员在职业生涯的早期,对函数式语言,特别是 C家族更加熟稔。要想程序员用一门新语言快速开发,新语言的语法不能过于激进。
  • 它必须是现代的。C、C++以及Java的某些方面,已经过于老旧,设计于多核计算机、网络和网络应用出现之前。新方法能够满足现代世界的特性,例如内置的并发。

说完了背景,现在让我们从软件工程的角度谈一谈 Go 语言的设计。

7. Go 语言的依赖处理

既然我们谈及了很多C 和 C++ 中依赖关系处理细节,让我们看看 Go 语言是如何处理的吧。在语义和语法上,依赖处理是由语言定义的。它们是明确的、清晰的、且“能被计算的”,就是说,应该很容易被编写工具分析。

在包封装(下节的主题)之后,每个源码文件都或有至少一个引入语句,包括 import 关键词和一个用来明确当前(只是当前)文件引入包的字符串:

使 Go 语言规整的第一步就是:睿智的依赖处理,在编译阶段,语言将未被使用的依赖视为错误(并非警告,是错误)。如果源码文件引入一个包却没有使用它,程序将无法完成编译。这将保证 Go 程序的依赖关系是明确的,没有任何多余的边际。另一方面,它可以保证编译过程不会包含无用代码,降低编译消耗的时间。

第二步则是由编译器实现的,它将通过深入依赖关系确保编译效率。设想一个含有三个包的 Go 程序,其依赖关系如下:

  • A 包 引用 B 包;
  • B 包 引用 C 包;
  • A 包 引用 C 包

这就意味着,A 包对 C 包的调用是由对 B 包的调用间接实现的;也就是说,在 A 包的代码中,不存在 C 包的标识符。例如,C 包中有一个类型定义,它是 B 包中的某个为 A 包调用的结构体中的字段类型,但其本身并未被 A 包调用。具一个更实际的例子,设想一下,A 包引用了一个 格式化 I/O 包 B,B 包则引用了 C 包提供的缓冲 I/O 实现,A 包本身并没有声明缓冲 I/O。

要编译这个程序,首先 C 被编译,被依赖的包必须在依赖于它们的包之前被编译。之后 B 包被编译;最后 A 包被编译,然后程序将被连接。

当 A 包编译完成之后,编译器将读取 B 包的目标文件,而不是代码。此目标文件包含编译器处理 A 包代码中

语句所需的所有类型信息。这些信息也包含着 B 包在编译是所需的 C 包的信息。换句话说,当 B 包被编译时,生成的目标文件包含了所有 B 包公共接口所需的全部依赖的类型信息。

这种设计拥有很重要的意义,当编译器处理 import 语句时,它将打开一个文件——该语句所明确的对象文件。当然,这不由的让人想起 Plan 9 C (非 ANSI C)对依赖管理方法,但不同的是,当 Go 代码文件被编译完成时,编译器将写入头文件。同 Plan 9 C 相比,这个过程将更自动化、更高效,因为:在处理 import 时读取的数据只是“输出”数据,而非程序代码。这对编译效率的影响是巨大的,而且,即便代码增长,程序依然规整如故。处理依赖树并对之编译的时间相较于 C 和 C++ 的“引入被引用文件”的模型将极大的减少。

值得一提的是,这个依赖管理的通用方法并不是原始的;这些思维要追溯到1970年代的像Modula-2和Ada语言。在C语言家族里,Java就包含这一方法的元素。

为了使编译更加高效,对象文件以导出数据作为它的首要步骤,这样编译器一旦到达文件的末尾就可以停止读取。这种依赖管理方法是为什么Go编译比C或C++编译更快的最大原因。另一个因素是Go语言把导出数据放在对象文件中;而一些语言要求程序员编写或让编译器生成包含这一信息的另一个文件。这相当于两次打开文件。在Go语言中导入一个程序包只需要打开一次文件。并且,单一文件方法意味着导出数据(或在C/C++的头文件)相对于对象文件永远不会过时。

为了准确起见,我们对Google中用Go编写的某大型程序的编译进行了测算,将源代码的展开情况同前文中对C++的分析做一对比。结果发现是40倍,要比C++好50倍(同样也要比C++简单因而处理速度也快),但是这仍然比我们预期的要大。原因有两点。第一,我们发现了一个bug:Go编译器在export部分产生了大量的无用数据。第二,export数据采用了一种比较冗长的编码方式,还有改善的余地。我们正计划解决这些问题。

然而,仅需作50分之1的事情就把原来的Build时间从分钟级的变为秒级的,将咖啡时间转化为交互式build。

Go的依赖图还有另外一个特性,就是它不包含循环。Go语言定义了不允许其依赖图中有循环性的包含关系,编译器和链接器都会对此进行检查以确保不存在循环依赖。虽然循环依赖偶尔也有用,但它在大规模程序中会引入巨大的问题。循环依赖要求编译器同时处理大量源文件,从而会减慢增量式build的速度。更重要的是,如果允许循环依赖,我们的经验告诉我们,这种依赖最后会形成大片互相纠缠不清的源代码树,从而让树中各部分也变得很大,难以进行独立管理,最后二进制文件会膨胀,使得软件开发中的初始化、测试、重构、发布以及其它一些任务变得过于复杂。

不支持循环import偶尔会让人感到苦恼,但却能让依赖树保持清晰明了,对package的清晰划分也提了个更高的要求。就象Go中其它许多设计决策一样,这会迫使程序员早早地就对一些大规模程序里的问题提前进行思考(在这种情况下,指的是package的边界),而这些问题一旦留给以后解决往往就会永远得不到满意的解决。 在标准库的设计中,大量精力花在了控制依赖关系上了。为了使用一个函数,把所需的那一小段代码拷贝过来要比拉进来一个比较大的库强(如果出现新的核心依赖的话,系统build里的一个test会报告问题)。在依赖关系方面保持良好状况要比代码重用重要。在实践中有这样一个例子,底层的网络package里有自己的整数到小数的转换程序,就是为了避免对较大的、依赖关系复杂的格式化I/O package的依赖。还有另外一个例子,字符串转换package的strconv拥有一个对‘可打印’字符的进行定义的private实现,而不是将整个大哥的Unicode字符类表格拖进去, strconv里的Unicode标准是通过package的test进行验证的。

8. 包

Go 的包系统设计结合了一些库、命名控件和模块的特性。

每个 Go 的代码文件,例如“encoding/json/json.go”,都以包声明开始,如同:

“json” 就是“包名称”,一个简单的识别符号。通常包名称都比较精炼。
要使用包,使用 import 声明引入代码,并以 包路径 区分。“路径”的意义并未在语言中指定,而是约定为以/分割的代码包目录路径,如下:

后面用包名称(有别于路径)则用来限定引入自代码文件中包的条目。

这种设计非常清晰,从语法(Namevs.pkg.Name)上就能识别一个名字是否属于某个包(在此之后)。

在我们的示例中,包的路径是“encoding/json”而包的名称是 json。标准资源库以外,通常约定以项目或公司名作为命名控件的根:

确认包路径的唯一性非常重要,而对包名称则不必强求。包必须通过唯一的路径引入,而包名称则为引用者调用内容方式的一个约定。包名称不必唯一,可以通过引入语句重命名识别符。下面有两个自称为“package log”的包,如果要在单个源码文件中引入,需要在引入时重命名一个。

每个公司都可能有自己的 log 包,不必要特别命名。恰恰相反:Go 的风格建议包名称保持简短和清晰,且不必担心冲突。

另一个例子:在 Google 代码库中有很多server 库。

9. 远程包

Go的包管理系统的一个重要特性是包路径,通常是一个字符串,通过识别 网站资源的URL 可以增加远程存储库。

下面就是如何使用储存在 github 上的包。go get 命令使用 go 编译工具获取资源并安装。一旦安装完毕,就可以如同其它包一样引用它。

这是值得注意的,go get 命令递归下载依赖,此特性得以实现的原因就是依赖关系的明确性。另外,由于引入路径的命名空间依赖于 URL,使得 Go 相较于其它语言,在包命名上更加分散和易于扩展。

10. 语法

语法就是编程语言的用户界面。虽然对于一门编程语言来说更重要的是语意,并且语法对于语意的影响也是有限的,但是语法决定了编程语言的可读性和明确性。同时,语法对于编程语言相关工具的编写至关重要:如果编程语言难以解析,那么自动化工具也将难以编写。

Go语言因此在设计阶段就为语言的明确性和相关工具的编写做了考虑,设计了一套简洁的语法。与C语言家族的其他几个成员相比,Go语言的词法更为精炼,仅25个关键字(C99为37个;C++11为84个;并且数量还在持续增加)。更为重要的是,Go语言的词法是规范的,因此也是易于解析的(应该说绝大部分是规范的;也存在一些我们本应修正却没有能够及时发现的怪异词法)。与C、Java特别是C++等语言不同,Go语言可以在没有类型信息或者符号表的情况下被解析,并且没有类型相关的上下文信息。Go语言的词法是易于推论的,降低了相关工具编写的难度。

Go 语法不同于 C 的一个细节是,它的变量声明语法相较于 C 语言,更接近 Pascal 语言。声明的变量名称在类型之前,而有更多的关键词很:

相较于 C 语言

无论是对人还是对计算机,通过关键词进行变量声明将更容易被识别。而通过类型语法而非 C 的表达式语法对词法分析有一个显著的影响:它增加了语法,但消除了歧义。不过,还有一个:你可以丢掉 var 关键词,而只在表达式用使用变量的类型。两种变量声明是等价的;只是第二个更简短且共通用:

golang.org/s/decl-syntax 是一篇更详细讲解 Go 语言声明语句以及为什么同 C 如此不同的文章。

函数声明语法对于简单函数非常直接。这里有一个 Abs 函数的声明示例,它接受一个类型为 T 的变量 x,并返回一个64位浮点值:

一个方法只是一个拥有特殊参数的函数,而它的 接收器(receiver)则可以使用标准的“点”符号传递给函数。方法的声明语法将接收器放在函数名称之前的括号里。下面是一个与之前相同的函数,但它是 T 类型的一个方法:

下面则是拥有 T 类型参数的一个变量(闭包);Go 语言拥有第一类函数和闭包功能:

最后,在 Go 语言中,函数可以返回多个值。通用的方法是成对返回函数结果和错误值,例如:

我们过会儿再说错误。

Go语言缺少的一个特性是它不支持缺省参数。这是它故意简化的。经验告诉我们缺省参数太容易通过添加更多的参数来给API设计缺陷打补丁,进而导致太多使程序难以理清深圳费解的交互参数。默认参数的缺失要求更多的函数或方法被定义,因为一个函数不能控制整个接口,但这使得一个API更清晰易懂。哪些函数也都需要独立的名字, 使程序更清楚存在哪些组合,同时也鼓励更多地考虑命名–一个有关清晰性和可读性的关键因素。一个默认参数缺失的缓解因素是Go语言为可变参数函数提供易用和类型安全支持的特性。

11. 命名

Go 采用了一个不常见的方法来定义标识符的可见性(可见性:包使用者(client fo a package)通过标识符使用包内成员的能力)。Go 语言中,名字自己包含了可见性的信息,而不是使用常见的private,public等关键字来标识可见性:标识符首字母的大小写决定了可见性。如果首字母是大写字母,这个标识符是exported(public); 否则是私有的。

  • 首字母大写:名字对于包使用者可见
  • 否则:name(或者_Name)是不可见的。

这条规则适用于变量,类型,函数,方法,常量,域成员…等所有的东西。关于命名,需要了解的就这么多。

这个设计不是个容易的决定。我们挣扎了一年多来决定怎么表示可见性。一旦我们决定了用名字的大小写来表示可见性,我们意识到这变成了Go语言最重要特性之一。毕竟,包使用者使用包时最关注名字;把可见性放在名字上而不是类型上,当用户想知道某个标示符是否是public接口,很容易就可以看出来。用了Go语言一段时间后,再用那些需要查看声明才知道可见性的语言就会觉得很麻烦。

很清楚,这样再一次使程序源代码清晰简洁的表达了程序员的意图。

另一个简洁之处是Go语言有非常紧凑的范围体系:

  • 全局(预定义的标示符例如 int 和 string)
  • 包(包里的所有源代码文件在同一个范围)
  • 文件(只是在引入包时重命名,实践中不是很重要)
  • 函数(所有函数都有,不解释)
  • 块(不解释)

Go语言没有命名空间,类或者其他范围。名字只来源于很少的地方,而且所有名字都遵循一样的范围体系:在源码的任何位置,一个标示符只表示一个语言对象,而独立于它的用法。(唯一的例外是语句标签(label)-break和其他类似跳转语句的目标地址;他们总是在当前函数范围有效)。

这样就使Go语言很清晰。例如,方法总是显式(expicit)的表明接受者(receiver)-用来访问接受者的域成员或者方法,而不是隐式(impliciti)的调用。也就是,程序员总是写

(rcvr 代表接受者变量) 所以在词法上(lexically),每个元素总是绑定到接受者类型的某个值。 同样,包命修饰符(qualifier)总是要写在导入的名字前-要写成io.Reader而不是Reader。除了更清晰,这样Reader这种很常用的名字可以使用在任何包中。事实上,在标准库中有多个包都导出Reader,Printf这些名字,由于加上包的修饰符,这些名字引用于那个包就很清晰,不会被混淆。

最终,这些规则组合起来确保了:除了顶级预先定义好的名字例如 int,每一个名字(的第一个部分-x.y中的x)总是声明在当前包。

简单说,名字是本地的。在C,C++,或者Java名字 y 可以指向任何事。在Go中,y(或Y)总是定义在包中, x.Y 的解释也很清晰:本地查找x,Y就在x里。

这些规则为可伸缩性提供了一个很重要的价值,因为他们确保为一个包增加一个公开的名字不会破坏现有的包使用者。命名规则解耦包,提供了可伸缩性,清晰性和强健性。

关于命名有一个更重要的方面要说一下:方法查找总是根据名字而不是方法的签名(类型) 。也就是说,一个类型里不会有两个同名的方法。给定一个方法 x.M,只有一个M在x中。这样,在只给定名字的情况下,这种方法很容易可以找到它指向那个方法。这样也使的方法调用的实现简单化了。

12. 语意

Go语言的程序语句在语意上基本与C相似。它是一种拥有指针等特性的编译型的、静态类型的过程式语言。它有意的给予习惯于C语言家族的程序员一种熟悉感。对于一门新兴的编程语言来说,降低目标受众程序员的学习门槛是非常重要的;植根于C语言家族有助于确保那些掌握Java、JavaScript或是C语言的年轻程序员能更轻松的学习Go语言。

尽管如此,Go语言为了提高程序的健壮性,还是对C语言的语意做出了很多小改动。它们包括:

  • 不能对指针进行算术运算
  • 没有隐式的数值转换
  • 数组的边界总是会被检查
  • 没有类型别名(进行type X int的声明后,X和int是两种不同的类型而不是别名)
  • ++和–是语句而不是表达式
  • 赋值不是一种表达式
  • 获取栈变量的地址是合法的(甚至是被鼓励的)
  • 其他

还有一些很大的改变,同传统的C 、C++ 、甚至是JAVA 的模型十分不同。它包含了对以下功能的支持:

  • 并发
  • 垃圾回收
  • 接口类型
  • 反射
  • 类型转换

下面的章节从软件工程的角度对 Go 语言这几个主题中的两个的讨论:并发和垃圾回收。对于语言的语义和应用的完整讨论,请参阅 golang.org 网站中的更多资源。

13. 并发

运行于多核机器之上并拥有众多客户端的web服务器程序,可称为Google里最典型程序。在这样的现代计算环境中,并发很重要。这种软件用C++或Java做都不是特别好,因为它们缺在与语言级对并发支持的都不够好。

Go采用了一流的channel,体现为CSP的一个变种。之所以选择CSP,部分原因是因为大家对它的熟悉程度(我们中有一位同事曾使用过构建于CSP中的概念之上的前任语言),另外还因为CSP具有一种在无须对其模型做任何深入的改变就能轻易添加到过程性编程模型中的特性。也即,对于类C语言,CSP可以一种最长正交化(orthogonal)的方式添加到这种语言中,为该语言提供额外的表达能力而且还不会对该语言的其它用它施加任何约束。简言之,就是该语言的其它部分仍可保持“通常的样子”。

这种方法就是这样对独立执行非常规过程代码的组合。

结果得到的语言可以允许我们将并发同计算无缝结合都一起。假设Web服务器必须验证它的每个客户端的安全证书;在Go语言中可以很容易的使用CSP来构建这样的软件,将客户端以独立执行的过程来管理,而且还具有编译型语言的执行效率,足够应付昂贵的加密计算。

总的来说,CSP对于Go和Google来说非常实用。在编写Web服务器这种Go语言的典型程序时,这个模型简直是天作之合。

有一条警告很重要:因为有并发,所以Go不能成为纯的内存安全(memory safe)的语言。共享内存是允许的,通过channel来传递指针也是一种习惯用法(而且效率很高)。

有些并发和函数式编程专家很失望,因为Go没有在并发计算的上下文中采用只写一次的方式作为值语义,比如这一点上Go和Erlang就太象。其中的原因大体上还是在于对问题域的熟悉程度和适合程度。Go的并发特性在大多数程序员所熟悉的上下文中运行得很好。Go让使得简单而安全的并发编程成为可能,但它并不阻止糟糕的编程方式。这个问题我们通过惯例来折中,训练程序员将消息传递看做拥有权限控制的一个版本。有句格言道:“不要通过共享内存来通信,要通过通信来共享内存。”

在对Go和并发编程都是刚刚新接触的程序员方面我们经验有限,但也表明了这是一种非常实用的方式。程序员喜欢这种支持并发为网络软件所带来的简单性,而简单性自然会带来健壮性。

14. 垃圾回收

对于一门系统级的编程语言来说,垃圾回收可能会是一项非常有争议的特性,但我们还是毫不犹豫地确定了Go语言将会是一门拥有垃圾回收机制的编程语言。Go语言没有显式的内存释放操作,那些被分配的内存只能通过垃圾回收器这一唯一途径来返回内存池。

做出这个决定并不难,因为内存管理对于一门编程语言的实际使用方式有着深远的影响。在C和C++中,程序员们往往需要花费大量的时间和精力在内存的分配和释放上,这样的设计有助于暴露那些本可以被隐藏得很好的内存管理的细节;但反过来说,对于内存使用的过多考量又限制了程序员使用内存的方式。相比之下,垃圾回收使得接口更容易被指定。

此外,拥有自动化的内存管理机制对于一门并发的面向对象的编程语言来说很关键,因为一个内存块可能会在不同的并发执行单元间被来回传递,要管理这样一块内存的所有权对于程序员来说将会是一项挑战。将行为与资源的管理分离是很重要的。

垃圾回收使得Go语言在使用上显得更加简单。

当然,垃圾回收机制会带来很大的成本:资源的消耗、回收的延迟以及复杂的实现等。尽管如此,我们相信它所带来的好处,特别是对于程序员的编程体验来说,是要大于它所带来的成本的,因为这些成本大都是加诸在编程语言的实现者身上。

在面向用户的系统中使用Java来进行服务器编程的经历使得一些程序员对垃圾回收顾虑重重:不可控的资源消耗、极大的延迟以及为了达到较好的性能而需要做的一大堆参数优化。Go语言则不同,语言本身的属性能够减轻以上的一些顾虑,虽然不是全部。

有个关键点在于,Go为程序员提供了通过控制数据结构的格式来限制内存分配的手段。请看下面这个简单的类型定义了包含一个字节(数组)型的缓冲区:

在Java中,buffer字段需要再次进行内存分配,因为需要另一层的间接访问形式。然而在Go中,该缓冲区同包含它的struct一起分配到了一块单独的内存块中,无需间接形式。对于系统编程,这种设计可以得到更好的性能并减少回收器(collector)需要了解的项目数。要是在大规模的程序中,这么做导致的差别会非常巨大。

有个更加直接一点的例子,在Go中,可以非常容易和高效地提供二阶内存分配器(second-order allocator),例如,为一个由大量struct组成的大型数组分配内存,并用一个自由列表(a free list)将它们链接起来的arena分配器(an arena allocator)。在重复使用大量小型数据结构的库中,可以通过少量的提前安排,就能不产生任何垃圾还能兼顾高效和高响应度。

虽然Go是一种支持内存垃圾回收的编程语言,但是资深程序员能够限制施加给回收器的压力从而提高程序的运行效率(Go的安装包中还提供了一些非常好的工具,用这些工具可以研究程序运行过程中动态内存的性能。)

要给程序员这样的灵活性,Go必需支持指向分配在堆中对象的指针,我们将这种指针称为内部指针。上文的例子中X.buff字段保存于struct之中,但也可以保留这个内部字段的地址。比如,可以将这个地址传递给I/O子程序。在Java以及许多类似的支持垃圾回收的语音中,不可能构造象这样的内部指针,但在Go中这么做很自然。这样设计的指针会影响可以使用的回收算法,并可能会让算法变得更难写,但经过慎重考虑,我们决定允许内部指针是必要的,因为这对程序员有好处,让大家具有降低对(可能实现起来更困难)回收器的压力的能力。到现在为止,我们的将大致相同的Go和Java程序进行对比的经验表明,使用内部指针能够大大影响arena总计大型、延迟和回收次数。

总的说来,Go是一门支持垃圾回收的语言,但它同时也提供给程序员一些手段,可以对回收开销进行控制。

垃圾回收器目前仍在积极地开发中。当前的设计方案是并行的边标示边扫描(mark-and-sweep)的回收器,未来还有机会提高其性能甚至其设计方案。(Go语言规范中并没有限定必需使用哪种特定的回收器实现方案)。尽管如此,如果程序员在使用内存时小心谨慎,当前的实现完全可以在生产环境中使用。

15. 要组合,不要继承

Go 采用了一个不寻常的方法来支持面向对象编程,允许添加方法到任意类型,而不仅仅是class,但是并没有采用任何类似子类化的类型继承。这也就意味着没有类型体系(type hierarchy)。这是精心的设计选择。虽然类型继承已经被用来建立很多成功的软件,但是我们认为它还是被过度使用了,我们应该在这个方向上退一步。

Go使用接口(interface), 接口已经在很多地方被详尽的讨论过了 (例如 research.swtch.com/interfaces ), 但是这里我还是简单的说一下。

在 Go 中,接口只是一组方法。例如,下面是标准库中的Hash接口的定义。

实现了这组方法的所有数据类型都满足这个接口;而不需要用implements声明。即便如此,由于接口匹配在编译时静态检查,所以这样也是类型安全的。

一个类型往往要满足多个接口,其方法的每一个子集满足每一个接口。例如,任何满足Hash接口的类型同时也满足Writer接口:

这种接口满足的流动性会促成一种不同的软件构造方法。但在解释这一点之前,我们应该先解释一下为什么Go中没有子类型化(subclassing)。

面向对象的编程提供了一种强大的见解:数据的行为可以独立于数据的表示进行泛化。这个模型在行为(方法集)是固定不变的情况下效果最好,但是,一旦你为某类型建立了一个子类型并添加了一个方法后,其行为就再也不同了。如果象Go中的静态定义的接口这样,将行为集固定下来,那么这种行为的一致性就使得可以把数据和程序一致地、正交地(orthogonally)、安全地组合到一起了。

有个极端一点的例子,在Plan 9的内核中,所有的系统数据项完全都实现了同一个接口,该接口是一个由14个方法组成的文件系统API。即使在今天看来,这种一致性所允许的对象组合水平在其它系统中是很罕见的。这样的例子数不胜数。这里还有一个:一个系统可以将TCP栈导入(这是Plan 9中的术语)一个不支持TCP甚至以太网的计算机中,然后通过网络将其连接到另一台具有不同CPU架构的机器上,通过导入其/proctree,就可以允许一个本地的调试器对远程的进程进行断点调试。这类操作在Plan 9中很是平常,一点也不特殊。能够做这样的事情的能力完全来自其设计方案,无需任何特殊安排(所有的工作都是在普通的C代码中完成的)。

我们认为,这种系统构建中的组合风格完全被推崇类型层次结构设计的语言所忽略了。类型层次结构造成非常脆弱的代码。层次结构必需在早期进行设计,通常会是程序设计的第一步,而一旦写出程序后,早期的决策就很难进行改变了。所以,类型层次结构这种模型会促成早期的过度设计,因为程序员要尽力对软件可能需要的各种可能的用法进行预测,不断地为了避免挂一漏万,不断的增加类型和抽象的层次。这种做法有点颠倒了,系统各个部分之间交互的方式本应该随着系统的发展而做出相应的改变,而不应该在一开始就固定下来。

因此,通过使用简单到通常只有一个方法的接口来定义一些很细小的行为,将这些接口作为组件间清晰易懂的边界, Go鼓励使用组合而不是继承

上文中提到过Writer接口,它定义于io包中。任何具有相同签名(signature)的Write方法的类型都可以很好的同下面这个与之互补的Reader接口共存:

这两个互补的方法可以拿来进行具有多种不同行为的、类型安全的连接(chaining),比如,一般性的Unix管道。文件、缓冲区、加密程序、压缩程序、图像编码程序等等都能够连接到一起。与C中的FILE*不同,Fprintf格式化I/O子程序带有anio.Writer。格式化输出程序并不了解它要输出到哪里;可能是输出给了图像编码程序,该程序接着输出给了压缩程序,该程序再接着输出给了加密程序,最后加密程序输出到了网络连接之中。

接口组合是一种不同的编程风格,已经熟悉了类型层次结构的人需要调整其思维方式才能做得好,但调整思维所得到的是类型层次结构中难以获得的具有高度适应性的设计方案。

还要注意,消除了类型层次结构也就消除了一种形式的依赖层次结构。接口满足式的设计使得程序无需预先确定的合约就能实现有机增长,而且这种增长是线性的;对一个接口进行更改影响的只有直接使用该接口的类型;不存在需要更改的子树。 没有implements声明会让有些人感觉不安但这么做可以让程序以自然、优雅、安全的方式进行发展。

Go的接口对程序设计有一个主要的影响。我们已经看到的一个地方就是使用具有接口参数的函数。这些不是方法而是函数。几个例子就应该能说明它们的威力。ReadAll返回一段字节(数组),其中包含的是能够从anio.Reader中读出来的所有数据:

封装器 —— 指的是以接口为参数并且其返回结果也是一个接口的函数,用的也很广泛。这里有几个原型。LoggingReader将每次的Read调用记录到传人的参数r这个Reader中。LimitingReader在读到n字节后便停止读取操作。ErrorInjector通过模拟I/O错误用以辅助完成测试工作。还有更多的例子。

这种设计方法同层次型的、子类型继承方法完全不同。它们更加松散(甚至是临时性的),属于有机式的、解耦式的、独立式的,因而具有强大的伸缩性。

16. 错误

Go不具有传统意义上的异常机制,也就是说,Go里没有同错误处理相关的控制结构。(Go的确为类似被零除这样的异常情况的提供了处理机制。 有一对叫做panic和recover的内建函数,用来让程序员处理这些情况。然而,这些函数是故意弄的不好用因而也很少使用它们,而且也不像Java库中使用异常那样,并没有将它们集成到库中。)

Go语言中错误处理的一个关键特性是一个预先定义为error的接口类型,它具有一个返回一个字符串读到Error方法,表示了一个错误值。:

Go的库使用error类型的数据返回对错误的描述。结合函数具有返回多个数值的能力, 在返回计算结果的同时返回可能出现的错误值很容易实现。比如,Go中同C里的对应的getchar不会在EOF处返回一个超范围的值,也不会抛出异常;它只是返回在返回读到的字符的同时返回一个error值,以error的值为nil表示读取成功。以下所示为带缓冲区的I/O包中bufio.Reader类型的ReadByte方法的签名:

这样的设计简单清晰,也非常容易理解。error仅仅是一种值,程序可以象对其它别的类型的值一样,对error值进行计算。

Go中不包含异常,是我们故意为之的。虽然有大量的批评者并不同意这个设计决策,但是我们相信有几个原因让我们认为这样做才能编写出更好的软件。

首先,计算机程序中的错误并不是真正的异常情况。例如,无法打开一个文件是种常见的问题,无需任何的特殊语言结构,if和return完全可以胜任。

再者,如果错误要使用特殊的控制结构,错误处理就会扭曲处理错误的程序的控制流(control flow)。象Java那样try-catch-finally语句结构会形成交叉重叠的多个控制流,这些控制流之间的交互方式非常复杂。虽然相比较而言,Go检查错误的方式更加繁琐,但这种显式的设计使得控制流更加直截了当 —— 从字面上的确如此。

毫无疑问这会使代码更长一些,但如此编码带来的清晰度和简单性可以弥补其冗长的缺点。显式地错误检查会迫使程序员在错误出现的时候对错误进行思考并进行相应的处理。异常机制只是将错误处理推卸到了调用堆栈之中,直到错过了修复问题或准确诊断错误情况的时机,这就使得程序员更容易去忽略错误而不是处理错误了。

17. 工具

软件工程需要工具的支持。每种语言都要运行于同其它语言共存的环境,它还需要大量工具才能进行编译、编辑、调试、性能分析、测试已经运行。

Go的语法、包管理系统、命名规则以及其它功能在设计时就考虑了要易于为这种语言编写工具以及包括词法分析器、语法分析器以及类型检测器等等在内的各种库。

操作Go程序的工具非常容易编写,因此现在已经编写出了许多这样的工具,其中有些工具对软件工程来讲已经产生了一些值得关注的效果。

其中最著名的是gofmt,它是Go源程序的格式化程序。该项目伊始,我们就将Go程序定位为由机器对其进行格式化, 从而消除了在程序员中具有争议的一大类问题:我要以什么样的格式写代码?我们对我们所需的所有Go程序运行Gofmt,绝大多数开源社区也用它进行代码格式化。 它是作为“提交前”的例行检查运行的,它在代码提交到代码库之前运行,以确保所有检入的Go程序都是具有相同的格式。

Go fmt 往往被其使用者推崇为Go最好的特性之一,尽管它本身并属于Go语言的一个部分。 存在并使用gofmt意味着,从一开始社区里看到的Go代码就是用它进行格式化过的代码,因此Go程序具有现在已为人熟知的单一风格。同一的写法使得代码阅读起来更加容易,因而用起来速度也快。没有在格式化代码方面浪费的时间就是剩下来的时间。Gofmt也会影响伸缩性:既然所有的代码看上去格式完全相同,团队就更易于展开合作,用起别人的代码来也更容易。

Go fmt 还让编写我们并没有清晰地预见到的另一类工具成为可能。Gofmt的运行原理就是对源代码进行语法分析,然后根据语法树本身对代码进行格式化。这让在格式化代码之前对语法树进行更改成为可能,因此产生了一批进行自动重构的工具。这些工具编写起来很容易,因为它们直接作用于语法分析树之上,因而其语义可以非常多样化,最后产生的格式化代码也非常规范。

第一个例子就是gofmt本身的a-r(重写)标志,该标志采用了一种很简单的模式匹配语言,可以用来进行表达式级的重写。例如,有一天我们引入了一段表达式右侧缺省值:该段表达式的长度。整个Go源代码树要使用该缺省值进行更新,仅限使用下面这一条命令:

该变换中的一个关键点在于,因为输入和输出二者均为规范格式(canonical format),对源代码的唯一更改也是语义上的更改

采用与此类似但更复杂一些的处理就可以让gofmt用于在Go语言中的语句以换行而不再是分号结尾的情况下,对语法树进行相应的更新。

gofix是另外一个非常重要的工具,它是语法树重写模块,而且它用Go语言本身所编写的,因而可以用来完成更加高级的重构操作。 gofix工具可以用来对直到Go 1发布为止的所有API和语言特性进行全方位修改,包括修改从map中删除数据项的语法、引入操作时间值的一个完全不同的API等等很多更新。随着这些更新一一推出,使用者可以通过运行下面这条简单的命令对他们的所有代码进行更新

注意,这些工具允许我们即使在旧代码仍旧能够正常运行的情况下对它们进行更新。 因此,Go的代码库很容易就能随着library的更新而更新。弃用旧的API可以很快以自动化的形式实现,所以只有最新版本的API需要维护。例如,我们最近将Go的协议缓冲区实现更改为使用“getter”函数,而原本的接口中并不包含该函数。我们对Google中所有的Go代码运行了gofix命令,对所有使用了协议缓冲区的程序进行了更新,所以,现在使用中的协议缓冲区API只有一个版本。要对C++或者 Java库进行这样的全面更新,对于Google这样大的代码库来讲,几乎是不可能实现的。

Go的标准库中具有语法分析包也使得编写大量其它工具成为可能。例如,用来管理程序构建的具有类似从远程代码库中获取包等功能的gotool;用来在library更新时验证API兼容性协约的文档抽取程序godoc;类似还有很多工具。

虽然类似这些工具很少在讨论语言设计时提到过,但是它们属于一种语言的生态系统中不可或缺的部分。事实上Go在设计时就考虑了工具的事情,这对该语言及其library以及整个社区的发展都已产生了巨大的影响。

18. 结论

Go在google内部的使用正在越来越广泛。

很多大型的面向用户的服务都在使用它,包括youtube.comanddl.google.com(为chrome、android等提供下载服务的下载服务器),我们的golang.org也是用go搭建的。当然很多小的服务也在使用go,大部分都是使用Google App Engine上的内建Go环境。

还有很多公司也在使用Go,名单很长,其中有一些是很有名的:

  • BBC国际广播
  • Canonical
  • Heroku
  • 诺基亚
  • SoundCloud

看起来Go已经实现了它的目标。虽然一切看起来都很好,但是现在就说它已经成功还太早。到目前为止我们还需要更多的使用经验,特别是大型的项目(百万航代码级),来表明我们已经成功搭建一种可扩展的语言。

相对规模比较小,有些小问题还不太对,可能会在该语言的下一个(Go 2?)版本中得以纠正。例如,变量定义的语法形式过多,程序员容易被非nil接口中的nil值搞糊涂,还有许多library以及接口的方面的细节还可以再经过一轮的设计。

但是,值得注意的是,在升级到Go版本1时,gofix和gofmt给予了我们修复很多其它问题的机会。今天的Go同其设计者所设想的样子之间的距离因此而更近了一步,要是没有这些工具的支持就很难做到这一点,而这些工具也是因为该语言的设计思想才成为可能的。

不过,现在不是万事皆定了。我们仍在学习中(但是,该语言本身现在已经确定下来了。)

该语言有个最大的弱点,就是它的实现仍需进一步的工作。特别是其编译器所产生的代码以及runtime的运行效率还有需要改善的地方,它们还在继续的改善之中。现在已经有了一些进展;实际上,有些基准测试表明,同2012年早期发布的第一个Go版本1相比,现在开发版的性能已得到双倍提升。

19. 总结

软件工程指导下的Go语言的设计。同绝大多数通用型编程语言相比,Go语言更多的是为了解决我们在构建大型服务器软件过程中所遇到的软件工程方面的问题而设计的。 乍看上去,这么讲可能会让人感觉Go非常无趣且工业化,但实际上,在设计过程中就着重于清晰和简洁,以及较高的可组合性,最后得到的反而会是一门使用起来效率高而且很有趣的编程语言,很多程序员都会发现,它有极强的表达力而且功能非常强大。

造成这种效果的因素有:

  • 清晰的依赖关系
  • 清晰的语法
  • 清晰的语义
  • 偏向组合而不是继承
  • 编程模型(垃圾回收、并发)所代理的简单性
  • 易于为它编写工具(Easy tooling )(gotool、gofmt、godoc、gofix)

如果你还没有尝试过用Go编程,我们建议你试一下。

http://golang.org

 

1 收藏 2 评论

相关文章

可能感兴趣的话题



直接登录
最新评论
跳到底部
返回顶部