Go 在 Google:服务于软件工程的语言设计(翻译)(三)

Go语言精选

共 9891字,需浏览 20分钟

 ·

2020-09-24 13:02

原文: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 语言最早期的设计者。

译文较长,分三篇推送,这里是第 14~19 小节。(完)

14. 垃圾回收

对于一门系统级编程语言来说,垃圾回收可能是一个有争议的特性,然而我们只花了很少时间就决定 Go 将是一门带垃圾回收的语言。Go 没有显式的内存释放操作:已分配的内存返回内存池的唯一途径就是垃圾回收器。

这是一个很容易做出的决定,因为内存管理对一门语言的实际工作方式有着深远的影响。在 C 和 C++ 中,编程时太多的精力都花在了内存的分配和释放上。这样的设计倾向于暴露本可以隐藏得很好的内存管理细节;但反过来说,对内存管理的过多顾虑又限制了内存的使用。相比之下,垃圾回收使得编程接口更清晰明确(garbage collection makes interfaces easier to specify)。

此外,在支持并发的面向对象语言中,自动内存管理几乎是必不可少的,因为当一块内存的所有权在并发执行中来回传递时,管理起来是很棘手的。将行为和资源管理分开是很重要的。

一旦有了垃圾回收,语言使用起来就容易多了。

当然,垃圾回收会带来巨大的成本:资源开销、执行延迟和实现的复杂性。尽管如此,我们相信,主要由程序员感受到的好处,要大于主要由语言实现者承担的成本。

用 Java 作为服务器开发语言的经验,让一些人对面向用户的系统中的垃圾回收感到紧张。开销不可控,延迟随时可能变大,而且为了获得良好的性能,还需要进行很多参数调整。然而 Go 却不同,语言的特性能缓解其中一部分的担忧,虽然不是全部。

关键的一点是,Go 为程序员提供了工具,可以通过控制数据结构的布局来限制内存分配 。假设有一个简单的数据结构的类型定义,它包含一个字节型(数组)的缓冲区:

type X struct {
    a, b, c int
    buf [256]byte
}

在 Java 里,buf 字段需要第二次内存分配,对它的访问也需要第二层的间接访问。但在 Go 里面,缓冲区和包含它的结构体一起被分配在一个内存块中,不需要任何间接分配和访问。对于系统编程来说,这种设计可以获得更好的性能,同时减少回收器需要管理的内存块数量。在规模化的情况下,它可以带来显著的差异。

举个更直接的例子,在 Go 里面,提供二阶内存分配器(second-order allocators)是很容易和很高效的,例如一个 arena 内存分配器可以一口气分配一大组的结构体,并用一个空闲链表(free list)将它们连接起来。像这样要反复使用很多小结构体的库,只要做适当的提前安排,就可以不产生垃圾,还能保持高效和快速响应。

译者注:arena 是 Go 里面用来分配内存的连续虚拟地址区域,堆中分配的内存都来自这一区域,可以近似地看作堆。Go 有自主内存管理策略(基于 Thread-Caching Malloc 改进),会一次性向系统预申请一大块内存,并将空闲内存用 free list 连在一起。分配内存时会按照一定策略,根据大小优先从 free list 获取内存;如果对象销毁,则把内存归还 free list。只有空闲内存不够才会向系统申请新的内存,只有空闲内存特别多才会向系统释放内存,减少内存申请和释放的系统调用。

这部分内容根据 Go 实现的改进可能会发生变化,请参考最新的文章,或者直接查看源码。https://github.com/golang/go/blob/master/src/runtime/malloc.go

虽然 Go 是一种带垃圾回收的语言,但是一个资深的程序员可以通过减少施加给回收器的压力,来提高性能。(另外,Go 安装时还附带了很多好用的工具,可以用来分析程序运行时的动态内存性能。)

为了给程序员提供这种灵活性,Go 必须支持指向堆上分配对象的指针,我们称之为内部指针(interior pointers)。上面例子中的 X.buf 字段就存在于结构体内部,但获取这个内部字段的地址是合法的,例如将这个地址传递给一个 I/O 子程序。在 Java 以及很多支持垃圾回收的语言里,构造这样的内部指针是不可能,但在 Go 里面,这是很自然的做法。这个设计点会影响到可以使用哪些回收算法,并且可能会增加算法的实现难度,但是经过仔细考虑,我们决定有必要允许使用内部指针,因为这对程序员有好处,并且能够减少垃圾回收器的压力(尽管这样可能会让垃圾回收器更难实现)。到目前为止,我们将类似的 Go 和 Java 程序进行对比的经验表明,使用内部指针可以对总的 arena 大小、执行延迟 和 回收时间产生显著影响。

总而言之,Go 支持垃圾回收,但给程序员提供了一些工具来控制回收开销。

垃圾回收器仍然是一个活跃的开发领域。目前的设计是一个并行的标记并清理(mark-and-sweep)回收器,仍然有机会改进它的性能甚至设计。(语言规范并没有规定回收器必须要使用任何特定实现。) 不过,如果程序员注意更巧妙地使用内存,目前的实现已经可以在生产环境工作得很好。

译者注:Go 1.3 以前使用 mark-and-sweep 回收器,整个过程需要 STW(stop the world),对于内存的申请和释放量比较大和频繁的程序而言,回收造成的停顿会比较明显。

后续的版本逐渐分离标记和清理过程,引入三色标记法,还有引入混合写屏障。总的趋势是将 GC 分散成多个可以(跟程序执行)并发的过程,将不得不 STW 的阶段和时间压缩到最小(通常小于 1ms),跟演讲发表时相比已经有了很大的改善。

15. 组合,而不是继承

Go 采用了一种不同寻常的面向对象编程方法,它允许在任何类型上添加方法,而不仅仅是类;但没有任何形式的基于类型的继承,比如子类。这意味着没有类型层次体系(type hierarchy)。这是一个有意的设计选择。虽然类型体系已经被用来构建了很多成功的软件,但我们认为这个模型已经被过度使用,应该后退一步。

取而代之的是 Go 的接口,这个想法在其他地方已经被详细讨论过了(例如参见research.swtch.com/interfaces),但这里还是做一个简单的总结。

在 Go 里面,一个接口 仅仅 是一组方法的集合。例如,这里是标准库中 Hash 接口的定义:

type Hash interface {
    Write(p []byte) (n int, err error)
    Sum(b []byte) []byte
    Reset()
    Size() int
    BlockSize() int
}

所有实现这些方法的数据类型都隐式地满足这个接口,没有 implements 声明。尽管如此,是否满足接口是在编译期静态检查的,所以接口是类型安全的。

一个类型通常会满足许多接口,每个接口对应于其方法的一个子集。例如,任何满足 Hash 接口的类型也会满足 Writer 接口:

type Writer interface {
    Write(p []byte) (n int, err error)
}

这种接口满足的流动性鼓励了一种不同的软件构造方法。但在解释这个之前,我们应该先解释一下为什么 Go 没有子类。

面向对象编程提供了一个强大的洞见:数据的行为可以独立于数据的表示进行泛化(generalized)。 当行为(方法集)是固定的时候,这个模型的效果最好,但是一旦你对一个类型进行了子类化,并添加了一个方法,行为就不再相同。相反地如果行为集是固定的,就好像 Go 静态定义的接口那样,行为的统一性使得数据和程序可以统一、正交、安全地组合。

一个极端的例子是 Plan 9 内核,所有的系统数据项都实现了完全相同的接口,即由 14 个方法定义的文件系统 API。这种统一性允许的对象组合水平,即使在今天也极少能在其它系统上看到。这样的例子比比皆是。这里还有一个:一个系统可以将 TCP 协议栈导入(import,这是 Plan 9 的术语)到一台没有 TCP 甚至没有以太网(Ethernet)的计算机上,然后通过这个网络连接到一台 CPU 架构不同的机器上,导入它的 /proc 树,并运行一个本地调试器对远程进程进行断点调试。这种操作在 Plan 9 上简直稀松平常,根本没有任何特别之处。做这种事情的能力完全来自它的设计,不需要特殊的安排(而且都是用普通 C 语言代码完成的)。

我们认为,这种组合式的系统构造风格已经被那些推崇按类型体系设计的语言所忽视。类型体系会造就脆弱易碎的代码。 体系结构必须在早期设计,通常是作为设计程序的第一步,而一旦程序写好就很难改动早期的决定。因此,该模型鼓励在早期做过度设计,程序员试图预测软件可能需要的每一种使用方式,增加多个类型和抽象层,仅仅为了以防万一。这是本末倒置的做法。系统各个部分的交互方式应该随着系统的发展去适配,而不是在一开始就固定下来。

因此,Go 鼓励组合而不是继承,使用简单的、通常只有一个方法的接口来定义琐碎的行为,作为组件之间干净、可理解的边界。

上面提到的 Writer 接口,它被定义在 io 包里:任何有 Write 方法的类型,只要有以下这个方法签名,就可以和补充的 Reader 接口一起工作:

type Reader interface {
    Read(p []byte) (n int, err error)
}

这两个互补的方法可以类型安全地跟丰富的行为进行连接(chaining),就像通用的 Unix 管道(pipes)一样。文件、缓冲区、网络、加密器、压缩器、图像编码器等都可以连接在一起。格式化 I/O 子程序 Fprintf 采用一个 io.Writer 接口作为参数,而不是像在 C 语言里那样采用 FILE* 。格式化输出程序并不了解内容是写到了哪里,它可能是一个图像编码器,背后又输出给一个压缩器,压缩器又输出给一个加密器,加密器又输出给一个网络连接。

接口组合是一种不同的编程风格,习惯了类型层次体系的人需要调整思路才能适应,但这样可以获得设计的适应性,这是通过类型体系很难实现的。

还要注意的是,消除类型层次结构也消除了一种形式的依赖层次结构。接口的满足允许程序有机地生长,而不需要预先确定的合约。而且它是一种线性的增长形式,对一个接口的改变只影响该接口的直接用户,不需要再更新子树。缺乏 implements 声明会让一些人感到不安,但它能让程序自然、优雅、安全地生长。

Go 的接口对程序设计有重大影响。其中一个地方是用接口作为参数的函数的使用。这些不是方法,而是函数。一些例子应该可以说明它们的力量。ReadAll 返回一个字节切片(数组),包含了所有可以从 io.Reader 接口读取的数据:

func ReadAll(r io.Reader) ([]byte, error)

封装器(指接受一个接口参数并返回一个接口的函数)的使用也很普遍。下面是一些原型。LoggingReader 记录传入的 Reader 的每个 Read 调用。LimitingReader 在读取 n 个字节后停止。ErrorInjector 通过模拟 I/O errors 来辅助测试。我们还能找到更多例子。

func LoggingReader(r io.Reader) io.Reader
func LimitingReader(r io.Reader, n int64) io.Reader
func ErrorInjector(r io.Reader) io.Reader

这些设计与分层的、子类型继承的方法完全不同,它们是更松散的(甚至是临时的)、有机的、解耦的、独立的,因此是可弹性伸缩的。

16. 错误处理

Go 没有传统意义上的异常机制,也就是说,没有与错误处理相关的控制结构。(Go 确实提供了处理异常情况(例如除零异常)的机制。一对名为 panicrecover 的内置函数允许程序员处理类似的情况。然而,这些函数是故意设计得不好用,也很少使用,而且没有像 Java 库使用异常那样集成到代码库中。)

错误处理的关键语言特性是一个预先定义的接口类型 error ,它代表了一个有 Error 方法可以返回字符串的值:

type error interface {
    Error() string
}

代码库使用 error 类型来返回错误的描述。结合函数多值返回的能力,很容易将计算结果与错误值(如果有)一起返回。例如,Go 里等价于 C 的 getchar 的函数不会在遇到 EOF 时返回一个超出范围的值,也不会抛出一个异常;它只是在字符旁返回一个 error 值, nil error 值表示成功。下面是缓冲 I/O 包的 bufio.Reader 接口类型的 ReadByte 方法的签名:

func (b *Reader) ReadByte() (c byte, err error)

这是一个简单清晰的设计,很容易理解。错误只是值,程序用它们来计算,就像用任意其他类型的值来计算一样。

在 Go 中不加入异常是一个刻意的选择。虽然有很多批评者不同意这个决定,但有几个原因让我们相信它可以让软件变得更好。

首先,计算机程序中的错误并不是真的『异常』(nothing truly exceptional,译者注:也可以翻译成:没有什么特别,平平无奇,这里翻译成异常,是为了跟 exception 的中文术语对应)。例如,无法打开文件是一个常见的问题,不值得使用特殊的语言结构;ifreturn 就可以了:

f, err := os.Open(fileName)
if err != nil {
    return err
}

另外,如果使用特殊的控制结构,错误处理会扭曲(distorts)程序处理错误的控制流(control flow)。Java 风格的 try-catch-finally 块跟多个重叠的控制流互相交错,而这些控制流本身的交互就很复杂。相比之下,虽然 Go 使代码在检查错误时更加啰嗦,但显式的设计使控制流保持了真正的(literally)简单直接。

毫无疑问,由此产生的代码可能会更长,但这种代码的清晰和简单可以弥补它的啰嗦。明确的错误检查迫使程序员在错误出现时就考虑并处理它们。 异常太容易让人们忽略而不是处理它们,将责任推给调用栈,直到为时已晚,无法很好地修复乃至诊断问题。

17. 工具

软件工程需要工具。每一种语言都是在一个有其他语言和大量工具的环境中运行,这些工具用来编译、编辑、调试、性能分析、测试和运行程序。

Go 的语法、包系统、命名惯例和其他特性在设计时就已经将工具易于编写考虑在内,库里面包括了 Go 的词法分析器、解析器和类型检查器。

控制 Go 程序的工具非常容易编写,以至于这样的工具现在已经有很多,有些工具对软件工程产生了很有趣的影响。

其中最著名的是 gofmt ,Go 的源代码格式化工具。从项目一开始,我们就打算用机器来格式化 Go 程序代码,从而消除程序员之间争论的一整个问题分类:该如何排版代码?gofmt 运行在我们编写的所有 Go 程序上,大多数开源社区也在用它。它是作为代码仓库的『提交前(presubmit)』检查来运行的,以确保所有检入(check-in)的 Go 程序格式都是一样的。

gofmt 经常被用户推崇为 Go 最好的特性之一,尽管它根本不是 Go 语言的一部分。gofmt 的存在和使用意味着,从一开始,社区里看到的代码总是按照 gofmt 的格式,所以 Go 程序有一个现在大家都很熟悉的统一风格。统一的表现形式使得代码更容易阅读,因此工作起来也更快。不用花时间格式化代码,时间就可以节省下来干别的。gofmt 还影响了可伸缩性:既然所有的代码看起来都是一样的,团队就更容易一起合作,也更容易使用其他人的代码

译者注:

这个功能虽然看起来不起眼,但在实际的团队开发中其实是很实用的。在使用别的没有统一风格的语言时,总是要为统一团队的代码风格付出额外的精力(尤其是有新成员加入时)。

我们要么给团队制定统一的风格规范,并落实到每个人(最好使用格式化插件并应用相同的配置);要么忍受代码里同时存在好几种风格穿插,影响阅读。

更糟糕的情况是,几个人都启用了格式化插件,却应用了不同的配置,先后修改同一份代码,提交时就很容易出现大量差异乃至冲突,实际上仅仅是代码风格的差异。这些无关紧要的差异如果不小心提交到仓库,真正重要的修改就将被淹没其中,干扰我们日后查看历史分析问题。

gofmt 还使另一类我们之前没有清晰预见到的工具得以实现。gofmt 的工作原理是解析源代码,并从解析树本身重新格式化。这使得在格式化之前可以编辑解析树,于是一套自动重构工具应运而生。这些工具很容易编写,由于它们直接在解析树上工作,所以语义丰富,可以自动生成规范格式化的代码。

第一个例子是 gofmt 本身的 -r (rewrite)标志参数,它使用简单的模式匹配语言来实现表达式级别的重写。比如有一天,我们为切片表达式的右侧引入了一个默认值:切片本身的长度。只需一条命令,整个 Go 源代码树就被更新为使用这个默认值:

gofmt -r 'a[b:len(a)] -> a[b:]'

这个转换的一个关键点是,因为输入和输出都是规范格式,所以对源代码所做的唯一改变是语义上的改变。

一个类似但更复杂的处理是,当 Go 语言里以换行结束的语句,不再需要分号作为终止符时, gofmt 可以用来更新源码树。

另一个重要的工具是 gofix,它可以运行用 Go 本身编写的『源码树重写模块(tree-rewriting modules)』,因此能够进行更高级的重构。gofix 工具让我们在 Go 1 发布之前对 API 和 语言特性 进行了全面的修改,包括修改 map 删除条目的语法,为操作时间值引入全新的 API ,等等。随着这些变化的推出,用户只需要运行简单的命令就能更新他们的所有代码:

gofix

请注意,这些工具允许我们,在旧代码仍然可以正常工作的前提下,更新代码。因此,Go 的代码仓库很容易随着库的演化保持更新。旧的 API 可以快速自动地被废弃,因此只需要维护一个版本的 API。例如,我们最近改变了 Go 的协议缓冲区实现,改为使用 "getter" 函数,而之前的接口并没有这些函数。我们在 Google 所有 Go 代码上运行 gofix 来更新所有使用协议缓冲区的程序,现在只有一个版本的 API 在使用。在 Google 的代码库规模下,对 C++ 或 Java 库进行类似的全面修改几乎是不可能实现的。

Go 标准库里的解析包,让其他一些工具也得以实现。例如 go 工具,它可以管理程序的构建,包括从远程代码仓库获取包;godoc 文档提取器,是一个验证 API 兼容性合约是否随着库的更新而得到维护的程序;等等。

虽然像这样的工具在语言设计中很少被提及,但它们是语言生态系统中不可缺少的一部分,事实上,Go 在设计时就考虑到了工具的问题,这对语言、库和社区的发展都有巨大的影响。

18. 结论

Go 在 Google 内部用得越来越多。

一些面向用户的大型服务都在使用它,包括 youtube.comdl.google.com (提供 Chrome、Android 和其他下载的下载服务器),以及我们自己的 golang.org 。当然也有很多小的服务,大多是使用 Google App Engine 对 Go 的原生支持构建的。

很多其他公司也在使用 Go;这个名单很长,但其中比较著名的几个是:

  • BBC Worldwide
  • Canonical
  • Heroku
  • Nokia
  • SoundCloud

看来,Go 正在实现它的目标。不过,现在宣布成功还为时过早。我们还没有足够的经验,尤其是在大型程序(数百万行代码那种)方面的经验,去断言我们已经成功创造了一门弹性可伸缩的语言。尽管所有的指标都是正面的。

在较小的范围内,一些小事情还不够好,可能会在 Go 以后的版本里微调(Go 2?)。例如,变量声明语法的形式太多,程序员很容易被非 nil 接口里面的 nil 值的行为搞糊涂,还有很多库和接口的细节可以再进行一轮设计。

不过值得注意的是, gofixgofmt 在 Go 1 的前期给了我们修复许多其他问题的机会。正因为有这些工具,Go 在今天得以更接近它的设计者的期待,而这些工具本身也是由于语言的设计才得以实现。

不过,不是所有事情都已经确定。我们还在学习中(但语言暂时是冻结的)。

译者注:根据译者的理解,这里的语言冻结,应该是指为了兑现 Go 1 backwards compatibility 的承诺,Go 1.x 的 API 已经基本固定,后续只会新增特性和对现有特性做兼容的微调,更多是在底层实现上做改进。破坏兼容性的修改,只能等到 Go 2。

Go 语言的一个主要的弱点,是它的实现仍需努力改进。编译器生成的代码,尤其是运行时的性能应该更好,这方面的工作还在继续。目前已经取得了一些进展;事实上,一些基准测试显示,与 2012 年初发布的第一版 Go 相比,当前开发版的性能已经翻了一番。

19. 小结

软件工程指导了 Go 的设计。与大多数通用编程语言相比,Go 的设计是为了解决我们在构建大型服务器软件时接触到的一系列软件工程问题。这么一说,这可能会让 Go 听起来相当沉闷和工业化,但事实上,在整个设计过程中,对清晰、简单和可组合性的关注反而导致了一门工作效率高且有趣的语言,很多程序员都觉得它表达力强而且功能强大。

造就这个结果的特性包括:

  • 清晰的依赖关系
  • 清晰的语法
  • 清晰的语义
  • 组合而非继承
  • 编程模型提供的简单性(垃圾回收、并发)
  • 易用的工具(go 工具、gofmtgodocgofix

如果你还没有尝试过 Go,我们建议你去尝试:

https://golang.org

译者小结:

一万八千多字,终于翻译完了。

一开始我没有留意原文的字数,以为最多花两个晚上,就能翻译完。实际上,如果不涉及那么多专业概念,没有那么多上下文省略和带歧义的表述,这个长度两晚也是可以勉强完成的。

可本文就是有很多地方,需要有相关的背景知识,无法单纯从原文确定作者的准确意思。没办法,有歧义又了解不够的地方,只好查资料、翻源码,猜测原作者最大可能想表达什么。所以导致翻译进度又慢又累。而即使这样,如开头所说,仍然无法避免会有理解偏差和错误。

这样一来,对之前的译文就变得更加宽容了。笔误和排版混乱仍然不应该。但那些在我看来像是显而易见的错误,也许只是刚好落入了我的知识范围;而我的译文里,可能也有落在我知识盲区最后只好瞎蒙的地方,成为别人眼里的低级错误。

欢迎留言指出错误,或者提出你不同的见解。



推荐阅读


福利

我为大家整理了一份从入门到进阶的Go学习资料礼包(下图只是部分),同时还包含学习建议:入门看什么,进阶看什么。

关注公众号 「polarisxu」,回复 ebook 获取;还可以回复「进群」,和数万 Gopher 交流学习。



浏览 35
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报