Go 在 Google:在软件工程服务中的设计

538 阅读46分钟

by Rob Pike

Abstract

这是Rob Pike在2012年10月25日在亚利桑那州图森举行的SPLASH 2012会议上发表的主题演讲的修改版本。)

Go编程语言是在2007年底构思出来的,它解决了我们在Google开发软件基础架构时遇到的一些问题。今天的计算环境几乎与创建所使用的语言(主要是C ++,Java和Python)的环境无关。多核处理器,网络系统,大规模计算集群和Web编程模型引入的问题正在解决,而不是正面解决。此外,规模发生了变化:今天的服务器程序包含数千万行代码,由数百甚至数千名程序员处理,并且每天都在进行更新。更糟糕的是,即使在大型编译集群上,构建时间也延长到几分钟甚至几小时。

Go的设计和开发旨在提高在此环境中的工作效率。除了内置并发和垃圾收集等众所周知的方面外,Go的设计考虑因素包括严格的依赖关系管理,软件架构随系统增长的适应性以及跨组件边界的稳健性。

本文解释了如何在构建高效,编译的编程语言时解决这些问题,这种语言感觉轻巧愉快。将从Google面临的现实问题中获取示例和解释。

Introduction

Go是Google开发的一种编译的,并发的,垃圾收集的静态类型语言。 这是一个开源项目:谷歌引入公共存储库repository而不是其它。

Go可扩展且高效。 有些程序员觉得工作很有趣; 其他人发现它缺乏想象力,甚至无聊。在本文中,我们将解释为什么这些并非相互矛盾的立场。 Go旨在解决Google软件开发中遇到的问题,这种语言不是一种breakthrough research的语言,但却是工程大型软件项目的优秀工具。

Go at Google

Go是一种由Google设计的编程语言,用于帮助解决Google的问题,Google存在很大问题。

硬件很大,软件很大。 有数百万行软件,服务器主要使用C ++,其他部分使用大量Java和Python。 成千上万的工程师在代码中工作,在包含所有软件的单个树的“头部”,因此每天都会对树的所有级别进行重大更改。 一个大型定制设计的分布式构建系统使得这种规模的开发变得可行,但它仍然很大。

当然,所有这些软件都运行在数以万计的机器上,这些机器被视为适度数量的独立网络计算集群。

简而言之,谷歌的开发很大,可能很慢,而且往往很笨拙。 但它是有效的。

Go项目的目标是消除Google软件开发的缓慢和笨拙,从而使流程更具生产力和可扩展性。 该语言是由读写,调试和维护大型软件系统的人员设计的。 因此,Go的目的不是研究编程语言设计; 它是为了改善设计师及其同事的工作环境。 Go更多地是关于软件工程而不是编程语言研究。 或者重申一下,它是关于软件工程服务中的语言设计。但是语言如何帮助软件工程呢? 本文的其余部分是对该问题的回答。

Pain points

当Go发布时,一些人声称它缺少被认为是现代语言的必要特征或方法。如果没有这些设施,Go怎么可能有价值?我们的答案是,Go确实解决了使大规模软件开发变得困难的问题。这些问题包括:

  • 构建缓慢slow builds
  • 不受控制的依赖uncontrolled dependencies
  • 每个程序员使用不同的语言子集
  • 程序理解友好性差(代码难以阅读,文档记录不当等)
  • 重复劳动duplication of effort
  • 更新代价cost of updates
  • 版本扭曲?version skew
  • 编写自动工具的难度difficulty of writing automatic tools
  • 跨语言构建cross-language builds

语言的各个feature无法解决这些问题。需要更大的软件工程视角,在Go的设计中,我们试图关注这些问题的解决方案。

作为一个简单,自包含(self-contained)的例子,考虑程序结构化的表示。一些观察者反对使用带括号的Go的C-like块结构,更喜欢使用Python或Haskell风格的缩进空格。但是,我们在跟踪由跨语言构建引起的构建和测试失败方面拥有丰富的经验,其中嵌入在另一种语言中的Python片段,例如通过SWIG调用,被周围代码的缩进的变化巧妙地和无形地破坏。因此,我们的立场是,尽管缩缩进对于小的程序来说很好,但是它不能很好地扩展,代码库越大越异,它就越麻烦。最好放弃安全性和可靠性的获得,因此Go有块结构brace-bounded blocks.。

Dependencies in C and C++

在处理包依赖性时出现了规模和其他问题的更实质性的说明。我们开始讨论它们如何在C和C ++中工作。

ANSI C于1989年首次标准化,在标准头文件中提升了#ifndef“guards”的概念。现在无处不在的想法是每个头文件都被条件编译子句括起来,这样文件可以被多次包含而没有错误。例如,Unix头文件<sys / stat.h>看起来像这样:

/* Large copyright and licensing notice */
#ifndef _SYS_STAT_H_
#define _SYS_STAT_H_
/* Types and other definitions */
#endif

目的是C预处理器读入文件但忽略文件的第二次和后续读取的内容。第一次读取文件时定义的符号_SYS_STAT_H_“守护”后面的调用。

这个设计有一些很好的属性,最重要的是每个头文件都可以安全#include它的所有依赖项,即使其他头文件也包含它们。如果遵循该规则,则允许有序的代码,例如,按字母顺序对#include子句进行排序。

但它的体积非常糟糕。

1984年,在完成所有预处理时,观察到ps.c的汇编(Unix ps命令的源代码)#include <sys / stat.h> 37次。即使内容在执行此操作时被丢弃36次,大多数C实现也会打开文件,读取并扫描37次。事实上,如果没有很好的cleverness,C预处理器的潜在复杂宏语义就需要这种行为。

对软件的影响是C程序中#include子句的逐渐积累。它不会破坏添加它们的程序,并且很难知道何时不再需要它们。删除#include并再次编译程序甚至不足以测试它,因为另一个#include本身可能包含一个#include来提取它。

从技术上讲,它不一定是那样的。通过使用#ifndef防护来实现长期问题,Plan 9库的设计者采用了不同的非ANSI标准方法。在Plan 9中,头文件被禁止包含其他#include子句;所有#includes都必须位于顶级C文件中。当然,这需要一些规则 - 程序员必须按正确的顺序列出必要的依赖项一次 - 但文档有所帮助,并且在实践中它工作得非常好。结果是,无论C源文件有多少依赖关系,每个#include文件在编译该文件时都只读取一次。当然,通过将其删除也很容易看出#include是否必要:当且仅当依赖是不必要时,编辑的程序才会编译。

Plan 9方法最重要的结果是编译速度更快:编译所需的I / O数量远远少于使用#ifndef guards编译程序的程序。

但是,在Plan 9之外,“守卫”方法是C和C ++的最佳实践。实际上,C ++通过在更精细的粒度上使用相同的方法来加剧问题。按照惯例,C ++程序通常由每个类的一个头文件构成,或者可能是一小组相关类,一个比<stdio.h>小得多的分组。因此,依赖关系树更复杂,反映了库依赖关系,而不是完整的类型层次结构。此外,C ++头文件通常包含真正的代码类型,方法和模板声明 - 而不仅仅是C头文件中典型的简单常量和函数签名。因此,不仅C ++向编译器推送更多内容,它推送的内容更难编译,并且每次调用编译器都必须重新处理此信息。在构建大型C ++二进制文件时,编译器可能会被教授数千次如何通过处理头文件<string>来表示字符串。 (据记载,1984年左右,Tom Cargill观察到使用C预处理器进行依赖管理将是C ++的长期责任,应予以解决。)

在Google上构建单个C ++二进制文件可以打开和读取数百个单独的头文件数万次。 2007年,Google的构建工程师负责编译主要的Google二进制文件。该文件包含大约两千个文件,如果简单地连接在一起,总计4.2兆字节。当扩展#includes时,超过8千兆字节被传送到编译器的输入,每个C ++源字节爆发2000字节。

作为另一个数据点,2003年Google的构建系统从单个Makefile转移到具有更好管理,更明确的依赖关系的每个目录设计。典型的二进制文件缩小了大约40%的文件大小,只是记录了更准确的依赖项。即便如此,C ++(或C语言)的属性使得自动验证这些依赖关系变得不切实际,而今天我们仍然无法准确理解大型Google C ++二进制文件的依赖性要求。

这些不受控制的依赖性和大规模的结果是在单个计算机上构建Google服务器二进制文件是不切实际的,因此创建了一个大型分布式编译系统。有了这个系统,涉及很多机器,很多缓存和很多复杂性(构建系统本身就是一个大型程序),谷歌的构建虽然仍然很麻烦,但却很实用。

即使使用分布式构建系统,大型Google构建仍然需要很长时间。使用前体分布式构建系统,2007二进制文件需要45分钟;今天版本的同一个程序需要27分钟,但当然程序及其依赖性在过渡期间已经增长。扩展构建系统所需的工程工作几乎无法保持领先于正在构建的软件的增长。

Enter Go

当构建缓慢时,有时间思考。 Go的起源神话表明,正是在这45分钟构建之一中,构思了Go。人们认为值得尝试设计一种适合编写大型Google程序(如Web服务器)的新语言,其软件工程考虑因素可以提高Google程序员的生活质量。

虽然到目前为止的讨论都集中在依赖性上,但还有许多其他问题需要注意。在这种情况下,任何语言成功的主要考虑因素是:

它必须大规模地work,对于具有大量依赖性的大型程序,以及大型程序员团队。 它必须貌似,大致类似于C。在谷歌工作的程序员在职业生涯的早期阶段,最熟悉程序语言,尤其是来自C家族的程序语言。使程序员以新语言快速生产的需要意味着语言不能过于激进。 它必须是现代的。 C,C ++,在某种程度上,Java是相当陈旧的,是在多核机器,网络和Web应用程序开发出现之前设计的。现代世界的某些功能可以通过更新的方法更好地满足,例如内置并发。 有了这样的背景,让我们从软件工程的角度来看看Go的设计。

Dependencies in Go

既然我们已经详细了解了C和C ++中的依赖关系,那么开始我们之旅的一个好地方就是看看Go如何处理它们。依赖关系是由语言在语法和语义上定义的。它们是明确的,清晰的,“可计算的”,也就是说,易于编写分析工具。

语法是,在package子句(下一节的主题)之后,每个源文件可能有一个或多个import语句,包含import关键字和一个字符串常量,用于标识要导入到此源文件中的包(仅限) :

导入“encoding / json” 制定Go scale(依赖性)的第一步是语言定义未使用的依赖项是编译时错误(不是警告,错误)。如果源文件导入它不使用的包,则程序将无法编译。这通过构造保证任何Go程序的依赖树是精确的,它没有无关的边缘。反过来,这可以保证在构建程序时不会编译额外的代码,从而最大限度地缩短编译时间。

还有另一个步骤,这次是编译器的实现,这进一步保证了效率。考虑一个带有三个包和这个依赖图的Go程序:

package A imports package B; package B imports package C; package A does not import package C

这意味着包A仅通过使用B来传递使用C;也就是说,在A的源代码中没有提到来自C的标识符,即使A中使用的一些项目确实提到了C.例如,包A可能引用在B中定义的具有字段的结构类型。在C中定义的类型,但A不引用自身。作为一个激励性的例子,假设A导入一个格式化的I / O包B,它使用C提供的缓冲I / O实现,但A本身不调用缓冲的I / O.

要构建这个程序,首先要编译C;依赖包必须在依赖它们的包之前构建。然后编译B;最后A编译,然后程序可以链接。

编译A时,编译器会读取B的目标文件,而不是源代码。 B的该目标文件包含编译器执行所需的所有类型信息

import "B" A的源代码中的子句。该信息包括B的客户端在编译时需要的有关C的任何信息。换句话说,当编译B时,生成的目标文件包括影响B的公共接口的B的所有依赖关系的类型信息。

这种设计具有如下重要影响:当编译器执行import子句时,它只打开一个文件,该文件由import子句中的字符串标识。当然,这是让人想起Plan 9 C(而不是ANSI C)依赖管理的方法,除了实际上编译器在编译Go源文件时编写头文件。尽管如此,该过程比Plan 9 C更自动,更高效:评估导入时读取的数据只是“导出”数据,而不是一般程序源代码。对整体编译时间的影响可能很大,并且随着代码库的增长而扩展。执行依赖图并因此编译的时间可以比C和C ++的“包含文件”模型指数地少。

值得一提的是,这种依赖管理的一般方法并非原创;这些想法可以追溯到20世纪70年代,流经Modula-2和Ada等语言。在C系列中,Java具有这种方法的元素。

为了使编译更加高效,主体文件的排列使得导出数据是文件中的第一件事,因此编译器可以在到达该部分的末尾时立即停止读取。

这种依赖管理方法是Go编译比C或C ++编译更快的最大原因。另一个因素是Go将导出数据放在目标文件中;某些语言需要作者编写或编译器生成包含该信息的第二个文件。这是打开文件的两倍。在Go中,只有一个文件可以打开以导入包。此外,单文件方法意味着导出数据(或C / C ++中的头文件)永远不会相对于目标文件过时。

为了记录,我们测量了用Go编写的大型Google程序的编译,以了解源代码扇出与之前完成的C ++分析相比如何。我们发现它大约是40倍,比C ++好五十倍(并且更简单,因此处理速度更快),但它仍然比我们预期的要大。有两个原因。首先,我们发现了一个错误:Go编译器在导出部分生成了大量数据,而不需要在那里。其次,导出数据使用可以改进的详细编码。我们计划解决这些问题。

尽管如此,要做的事情要少五十分钟才会变成几秒钟,咖啡就会变成互动的构建。

Go依赖图的另一个特性是它没有循环。该语言定义图中不能有循环导入,编译器和链接器都会检查它们是否不存在。虽然它们偶尔会有用,但循环进口会引起大规模的重大问题。它们要求编译器同时处理更大的源文件集,这会减慢增量构建。更重要的是,在我们的经验允许的情况下,这些导入最终会将源代码树的大量内容纠缠成难以独立管理的大型子元素,膨胀二进制文件以及复杂化初始化,测试,重构,发布和其他软件开发任务。

缺乏循环imports会导致偶尔的烦恼,但保持构建树清晰,迫使包之间划清界限。与Go中的许多设计策略一样,它迫使程序员更早地思考一个更大规模的问题(在这种情况下,包边界),如果留到以后可能永远不会令人满意地解决。

通过标准库的设计,花费了大量精力来控制依赖关系。复制一些代码比为一个函数拉入一个大型库更好。 (如果出现新的核心依赖关系,系统构建中的测试会抱怨。)依赖性卫生胜过代码重用。实践中的一个例子是(低级)网络包具有其自己的整数到十进制转换例程,以避免依赖于较大且依赖性较大的格式化I / O包。另一个是字符串转换包strconv具有'printable'字符定义的私有实现,而不是拉入大的Unicode字符类表; strconv尊重Unicode标准由包的测试验证。

Packages

Go的包系统的设计将库,命名空间和模块的一些属性组合到一个构造中。

每个Go源文件,例如“encoding / json / json.go”,都以package子句开头,如下所示:

package json 其中json是“包名”,一个简单的标识符。包名称通常简洁。

要使用包,导入源文件通过import子句中的包路径来标识它。 “path”的含义不是由语言指定的,但在实践中,按照惯例,它是存储库中源包的斜杠分隔目录路径,此处:

import "encoding/json" 然后使用包名称(与路径不同)来限定导入源文件中包的项目:

var dec = json.NewDecoder(reader) 这种设计提供了清晰度人们可能总是从语法中判断一个名称是否是本地的:name vs pkg.Name。 (稍后会详细介绍。)

对于我们的示例,包路径是“encoding / json”,而包名称是json。在标准存储库之外,约定是将项目或公司名称放在名称空间的根目录下:

import "google/base/go/log" 重要的是要认识到包路径是唯一的,但是对包名没有这样的要求。路径必须唯一标识要导入的包,而名称只是包的客户端如何引用其内容的约定。包名称不必是唯一的,可以通过在import子句中提供本地标识符来覆盖每个导入源文件。这两个引用既调用自己的包日志的引用包,但要将它们导入单个源文件,必须(本地)重命名:

import“log”//标准包 导入googlelog“google / base / go / log”// Google特定的包 每个公司都可能有自己的日志包,但不需要使包名称唯一。恰恰相反:Go风格建议保持包装名称简短,清晰明显,而不是担心碰撞。

另一个例子:Google的代码库中有许多服务器软件包。

Remote packages

Go的包系统的一个重要特性是,包路径通常是一个任意字符串,可以通过识别为存储库提供服务的站点的URL来选择引用远程存储库。

以下是如何使用github的doozer包。 go get命令使用go build工具从站点获取存储库并进行安装。 安装后,可以像任何常规包一样导入和使用它。

$ go get github.com/4ad/doozer //用于获取包的Shell命令

import“github.com/4ad/doozer”// Doozer客户端的import语句

var client doozer.Conn //客户端使用包 值得注意的是,go get命令以递归方式下载依赖项,只有因为依赖项是显式的才能实现属性。 此外,导入路径空间的分配被委托给URL,这使得包的命名分散并因此可扩展,与其他语言使用的集中式注册表相反。 Go缺少的一个功能是它不支持默认函数参数。 这是故意的简化。 经验告诉我们,默认的参数使得通过添加更多参数来修补API设计缺陷变得太容易,导致过多的参数与难以解开甚至理解的交互。 缺少默认参数需要定义更多的函数或方法,因为一个函数不能保存整个接口,但这会导致更容易理解的更清晰的API。 这些功能也需要单独的名称,这清楚地表明存在哪些组合,以及鼓励更多地考虑命名,这是清晰度和可读性的关键方面。

缺少默认参数的一个缓解因素是Go对可变函数具有易于使用的类型安全支持。

Syntax

语法是编程语言的用户界面。虽然它对语言的语义影响有限,这可能是更重要的组成部分,但语法决定了语言的可读性和清晰度。此外,语法对于工具至关重要:如果语言难以解析,则自动化工具很难编写。

因此,Go的设计考虑了清晰度和工具,并且语法清晰。与C系列中的其他语言相比,它的语法大小适中,只有25个关键字(C99有37个; C ++ 11有84个;数字继续增长)。更重要的是,语法是规则的,因此易于解析(大多数情况下;我们可能已经修复了一些怪癖,但没有及早发现)。与C和Java特别是C ++不同,Go可以在没有类型信息或符号表的情况下进行解析;没有特定类型的上下文。语法易于推理,因此工具易于编写。

Go语法的一个细节令C程序员惊讶,声明语法更接近Pascal而不是C语言。声明的名称出现在类型之前,并且有更多关键字: var fn func([] int)int type T struct {a,b int} 与C相比 int(* fn)(int []); struct T {int a,b; } 通过关键字引入的声明更容易为人和计算机解析,并且类型语法不是表达式语法,因为它在C中对解析有显着影响:它增加了语法但消除了歧义。但是也有一个很好的副作用:对于初始化声明,可以删除var关键字,只从表达式中获取变量的类型。这两个声明是等价的;第二个是较短且惯用的: var buf * bytes.Buffer = bytes.NewBuffer(x)//显式 buf:= bytes.NewBuffer(x)//派生 golang.org/s/decl-syntax上有一篇博客文章,其中详细介绍了Go中声明的语法以及为什么它与C有如此不同。

函数语法对于简单函数来说很简单。此示例声明函数Abs,它接受类型为T的单个变量x并返回单个float64值: func Abs(x T)float64 方法只是一个带有特殊参数的函数,它的接收器可以使用标准的“点”表示法传递给函数。方法声明语法将接收器放在函数名称前面的括号中。这是相同的函数,现在作为类型T的方法:

func(x T)Abs()float64 这是一个带有类型T参数的变量(闭包); Go拥有一流的功能和闭包:

negAbs:= func(x T)float64 {return -Abs(x)} 最后,在Go函数中可以返回多个值。一种常见的情况是将函数结果和错误值作为一对返回,如下所示: ` func ReadByte() (c byte, err error)

c, err := ReadByte() if err != nil { ... } ` 我们稍后会更多地讨论错误。

Go缺少的一个功能是它不支持默认函数参数。这是故意的简化。经验告诉我们,默认的参数使得通过添加更多参数来修补API设计缺陷变得太容易,导致过多的参数与难以解开甚至理解的交互。缺少默认参数需要定义更多的函数或方法,因为一个函数不能保存整个接口,但这会导致更容易理解的更清晰的API。这些功能也需要单独的名称,这清楚地表明存在哪些组合,以及鼓励更多地考虑命名,这是清晰度和可读性的关键方面。

缺少默认参数的一个缓解因素是Go对可变函数具有易于使用的类型安全支持。

Naming

Go采用一种不寻常的方法来定义标识符的可见性,即包的客户端使用标识符指定的项的能力。例如,与私有和公共关键字不同,在Go中,名称本身包含信息:标识符的首字母大小写的情况决定了可见性。如果初始字符是大写字母,则导出标识符(公共);否则不是:

大写首字母:名称对包的客户可见 否则:包的客户端看不到名称(或_Name) 此规则适用于变量,类型,函数,方法,常量,字段......所有内容。这里的所有都是它的。

这不是一个简单的设计决定。我们花了一年多的时间来努力定义符号来指定标识符的可见性。一旦我们决定使用该名称的情况,我们很快意识到它已成为该语言最重要的属性之一。毕竟,该名称是该套餐的客户使用的名称;将可见性放​​在名称而不是其类型中意味着在查看标识符时它是否总是清晰的,它是否是公共API的一部分。使用Go一段时间后,回到需要查找声明以发现此信息的其他语言时,会感到很麻烦。

结果再次清晰:程序源文本简单地表达了程序员的意思。

另一个简化是Go具有非常紧凑的范围层次结构:

universe(预先声明的标识符,如int和string) package(包的所有源文件都在同一范围内) 文件(仅用于包导入重命名;在实践中不是很重要) 功能(通常) 块(通常) 名称空间或类或其他包装结构没有空间。名称来自Go中很少的地方,并且所有名称都遵循相同的范围层次结构:在源中的任何给定位置,标识符恰好表示一个语言对象,与其使用方式无关。 (唯一的例外是语句标签,break语句的目标等;它们总是具有函数范围。)

这具有清晰的后果。例如,请注意方法声明了一个显式接收器,并且必须使用它来访问该类型的字段和方法。没有暗示这一点。也就是说,总是写道

rcvr.Field (其中rcvr是为接收器变量选择的任何名称)因此该类型的所有元素总是在词法上绑定到接收器类型的值。同样,对于导入的名称,始终存在包限定符;一个人写io.Reader而不是Reader。这不仅清楚,它还将标识符Reader释放为在任何包中使用的有用名称。事实上,在标准库中有多个导出的标识符,名称为Reader,或者就此而言是Printf,但是哪一个被引用始终是明确的。

最后,这些规则结合起来保证除了顶级预定义名称(例如int)(每个名称的第一个组件)之外,每个名称总是在当前包中声明。

简而言之,名字是本地的。在C,C ++或Java中,名称y可以引用任何内容。在Go中,y(或甚至Y)总是在包中定义,而x.Y的解释是明确的:在本地查找x,Y属于它。

这些规则提供了一个重要的扩展属性,因为它们保证向包中添加导出的名称永远不会破坏该包的客户端。命名规则将包解耦,提供缩放,清晰度和健壮性。

还有一个要提到的命名方面:方法查找始终只是名称,而不是方法的签名(类型)。换句话说,单个类型永远不会有两个具有相同名称的方法。给定方法x.M,只有一个M与x相关联。同样,这样可以很容易地识别出仅使用名称引用的方法。它还使方法调用的实现变得简单。

Semantics

Go语句的语义通常是C语言。它是一个编译的,静态类型的过程语言,带有指针等等。按照设计,对于习惯于C系列语言的程序员来说,它应该是熟悉的。在推出新语言时,目标受众能够快速学习它是很重要的; root in Go in the C family有助于确保年轻的程序员(大多数人都懂Java,JavaScript和C语言)应该让Go易于学习。

也就是说,Go对C语义进行了许多小改动,主要是为了提供稳健性。这些包括:

  • 没有指针运算

  • 没有隐式数字转换

  • 始终检查数组边界

  • 没有类型别名(在类型X int之后,X和int是不同的类型而不是别名)

  • ++和 - 是语句而不是表达式

  • 赋值不是表达式

  • 获取堆栈变量的地址是合法的(甚至鼓励) 还有很多 还有一些更大的变化,远离传统的C,C ++甚至Java模型。这些包括语言支持:

  • 并发

  • 垃圾收集

  • 界面类型

  • 反射

  • 类型开关 以下部分简要讨论了Go,并发和垃圾收集中的两个主题,主要是从软件工程的角度。有关语言语义和用法的完整讨论,请参阅golang.org网站上的许多资源。

Concurrency

并发性对于现代计算环境非常重要,其多核计算机运行具有多个客户端的Web服务器,这可称为典型的Google程序。这种软件并不是特别适合C ++或Java,它在语言层面缺乏足够的并发支持。

Go体现了具有一流渠道的CSP变体。选择CSP部分是由于熟悉(我们中的一个人已经研究了基于CSP思想的前任语言),但也因为CSP具有很容易添加到过程编程模型而不需要对该模型进行深刻更改的特性。也就是说,给定类C语言,CSP可以以大多数正交方式添加到语言中,提供额外的表达能力而不会限制语言的其他用途。简而言之,语言的其余部分可以保持“普通”。

因此,该方法是独立执行其他常规程序代码的功能的组合。

生成的语言允许我们顺利地将并发与计算结合起来。考虑一个必须验证每个传入客户端调用的安全证书的Web服务器;在Go中,很容易使用CSP构建软件来管理客户端作为独立执行的过程,但是具有高效编译语言的全部功能可用于昂贵的加密计算。

总之,CSP适用于Go和Google。在编写Web服务器,规范的Go程序时,该模型非常适合。

有一个重要的警告:在并发存在的情况下,Go并不是纯粹的内存安全。共享是合法的,并且在通道上传递指针是惯用的(并且有效)。

一些并发和函数式编程专家对Go在并发计算的上下文中没有采用一次写入方法来估计语义而感到失望,例如Go并不像Erlang。同样,原因主要是关于问题领域的熟悉性和适用性。 Go的并发功能在大多数程序员熟悉的环境中运行良好。 Go支持简单,安全的并发编程,但不禁止编程错误。我们根据惯例进行补偿,培训程序员将消息传递视为所有权控制的一个版本。座右铭是“不要通过共享内存进行通信,通过通信共享内存”。

我们对Go和并发编程都不熟悉的程序员的经验有限,这表明这是一种实用的方法。程序员喜欢简单性,支持并发性为网络软件带来了简单性,并且简化了强大的功能。

Garbage collection

对于系统语言,垃圾收集可能是一个有争议的feature,但我们花了很少的时间就决定Go将是一个GC语言。 Go没有明确的内存释放操作:分配的内存返回池的唯一方法是通过垃圾收集器。

这是一个容易做出的决定,因为内存管理对语言在实践中的运作方式产生了深远的影响。在C和C ++中,过多的编程工作花费在内存分配和释放上。由此产生的设计倾向于暴露可能隐藏的内存管理细节;相反,内存考虑限制了它们的使用方式。相比之下,垃圾收集使界面更容易指定。

此外,在一个并发的面向对象语言中,拥有自动内存管理几乎是必不可少的,因为一块内存的所有权在并发执行中传递时可能很难管理。将行为与资源管理分开是很重要的。

由于垃圾收集,语言更容易使用。

当然,垃圾收集带来了巨大的成本:一般的开销,延迟和实现的复杂性。尽管如此,我们认为程序员最常感受到的好处超过了成本,这些成本主要由语言实现者承担。

特别是Java作为服务器语言的经验使得一些人对面向用户的系统中的垃圾收集感到紧张。开销是无法控制的,延迟可能很大,并且需要进行大量参数调整才能获得良好的性能。然而,去是不同的。该语言的属性可以缓解这些问题。当然不是全部,而是一些。

关键是Go为程序员提供了通过控制数据结构布局来限制分配的工具。考虑这个包含字节缓冲区(数组)的数据结构的简单类型定义: type X struct {     a,b,c int     buf [256]byte } 在Java中,buf字段需要第二次分配并访问第二级间接。但是,在Go中,缓冲区与包含结构一起分配在单个内存块中,并且不需要间接寻址。对于系统编程,此设计可以具有更好的性能以及减少收集器已知的项目数量。在规模上它可以产生显着的差异。

作为一个更直接的例子,在Go中提供二阶分配器是简单而有效的,例如竞技场分配器,它分配大量结构并将它们与空闲列表链接在一起。反复使用这种小型结构的图书馆可以通过适度的预先安排,不会产生垃圾,而且效率高,反应灵敏。

虽然Go是一种垃圾收集语言,但是,知识渊博的程序员可以限制对收集器施加的压力,从而提高性能。 (另外,Go安装附带了很好的工具来研究正在运行的程序的动态内存性能。)

为了给程序员这种灵活性,Go必须支持我们称之为堆中分配的对象的内部指针。上面示例中的X.buf字段位于结构体内,但捕获此内部字段的地址是合法的,例如将其传递给I / O例程。在Java中,就像许多垃圾收集语言一样,不可能像这样构造一个内部指针,但在Go中它是惯用的。这个设计点会影响哪些采集算法可以使用,并且可能会使它们变得更加困难,但仔细考虑后我们认为有必要允许内部指针,因为程序员的好处和减轻压力的能力(也许更难)实施)收藏家。到目前为止,我们比较类似Go和Java程序的经验表明,使用内部指针会对总体竞技场大小,延迟和收集时间产生重大影响。

总之,Go是垃圾收集,但为程序员提供了一些控制收集开销的工具。

垃圾收集器仍然是一个活跃的发展领域。目前的设计是一个并行的标记和扫描收集器,仍有机会改善其性能甚至可能改进其设计。 (语言规范并没有要求收集器的任何特定实现。)但是,如果程序员小心谨慎地使用内存,那么当前的实现对于生产使用来说效果很好。

Composition not inheritance

Go采用了一种不寻常的方法来进行面向对象的编程,允许任何类型的方法,而不仅仅是类,但没有任何形式的基于类型的继承,如子类化。这意味着没有类型层次结构。这是一个有意的设计选择。虽然已经使用类型层次结构来构建非常成功的软件,但我们认为该模型已被过度使用并且值得退一步。

相反,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 } 现这些方法的所有数据类型都隐式地满足此接口;没有工具声明。也就是说,在编译时静态检查接口满意度,所以尽管这种解耦接口是类型安全的。

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

type Writer interface { Write(p []byte) (n int, err error) } 界面满意度的这种流动性鼓励了一种不同的软件构建方法。但在解释之前,我们应该解释为什么Go没有子类化。

面向对象的编程提供了强大的洞察力:数据的行为可以独立于该数据的表示而被推广。当行为(方法集)被修复时,该模型效果最好,但是一旦您对类型进行子类化并添加方法,行为就不再相同。如果相反,行为集是固定的,例如在Go的静态定义的接口中,行为的一致性使数据和程序能够统一,正交和安全地组合。

一个极端的例子是Plan 9内核,其中所有系统数据项都实现了完全相同的接口,一个由14种方法定义的文件系统API。即使在今天,这种均匀性也允许在其他系统中很少达到一定程度的物体成分。例子比比皆是。这是一个:系统可以将TCP堆栈(在Plan 9术语中)导入到没有TCP甚至以太网的计算机上,并通过该网络连接到具有不同CPU架构的计算机,导入其/ proc树,以及运行本地调试器来对远程进程进行断点调试。这种行动在计划9上是可行的,没有什么特别的。做这些事情的能力落在了设计之外;它不需要特殊的安排(并且都是在简单的C中完成的)。

我们认为,这种构成风格的系统构造被类型层次推动设计的语言所忽略。类型层次结构导致脆弱的代码。层次结构必须尽早设计,通常作为设计程序的第一步,一旦编写程序,早期决策可能难以改变。因此,该模型鼓励早期过度设计,因为程序员试图预测软件可能需要的每种可能的用途,并在以下情况下添加类型和抽象层。这是颠倒的。系统交互的方式应该随着它的增长而适应,而不是在时间的早晨得到修复。

因此,Go鼓励组合而不是继承,使用简单的,通常是单方法的接口来定义琐碎的行为,这些行为充当组件之间清晰,易于理解的界限。

考虑上面显示的Writer接口,它在包io中定义:任何具有此签名的Write方法的项都适用于补充的Reader接口:

type Reader interface { Read(p []byte) (n int, err error) } 这两种互补方法允许类型安全链接具有丰富的行为,如通用Unix管道。文件,缓冲区,网络,加密器,压缩器,图像编码器等都可以连接在一起。 Fprintf格式化的I / O例程采用io.Writer而不是像C一样使用FILE *。格式化的打印机不知道它写的是什么;它可以是图像编码器,其又写入压缩器,该压缩器又写入加密器,该加密器又写入网络连接。

接口组合是一种不同的编程风格,习惯于类型层次结构的人需要调整他们的思路才能做好,但结果是设计的适应性很难通过类型层次结构来实现。

另请注意,消除类型层次结构也会消除一种依赖关系层次结构。界面满意度允许程序在没有预定合同的情况下有机增长。它是一种线性增长形式;对接口的更改仅影响该接口的直接客户端;没有子树可以更新。缺乏工具声明会扰乱一些人,但它使程序能够自然,优雅和安全地发展。

Go的接口对程序设计有重大影响。我们看到的一个地方是使用带有接口参数的函数。这些不是方法,而是功能。一些例子应该说明它们的力量。 ReadAll返回一个字节切片(数组),其中包含可从io.Reader读取的所有数据: func ReadAll(r io.Reader)([] byte,error) Wrappers-接口和返回接口的功能也很普遍。这是一些原型。 LoggingReader记录传入Reader上的每个Read调用。 LimitingReader在n个字节后停止读取。 ErrorInjector通过模拟I / O错误来辅助测试。还有更多。 func LoggingReader(r io.Reader)io.Reader func LimitingReader(r io.Reader,n int64)io.Reader func ErrorInjector(r io.Reader)io.Reader 这些设计与分层的,子类型继承的方法完全不同。它们更宽松(甚至是临时的),有机的,分离的,独立的,因此可扩展。

Errors

Go没有传统意义上的异常工具,也就是说,没有与错误处理相关的控制结构。 (Go确实提供了处理异常情况的机制,例如除以零。一对称为恐慌和恢复的内置函数允许程序员防止这些事情。但是,这些函数故意笨拙,很少使用,并且没有集成到例如,Java库使用异常的方式。)

错误处理的关键语言功能是一个名为error的预定义接口类型,它表示一个返回字符串的Error方法的值:

type error interface { Error() string } 使用错误类型返回错误的描述。结合函数返回多个值的能力,很容易返回计算结果和错误值(如果有的话)。例如,等效于C的getchar不会返回EOF的带外值,也不会抛出异常;它只是在字符旁边返回一个错误值,nil错误值表示成功。以下是缓冲I / O包的bufio.Reader类型的ReadByte方法的签名: func (b *Reader) ReadByte() (c byte, err error) 这是一个简单明了的设计,易于理解。错误只是值和程序使用它们计算,因为它们将使用任何其他类型的值进行计算。

故意选择不在Go中加入例外。虽然许多批评者不同意这一决定,但我们认为有几个原因可以促成更好的软件。

首先,计算机程序中的错误没有什么特别之处。例如,无法打开文件是一个常见的问题,不值得特殊的语言结构;如果和返回都没问题。

f, err := os.Open(fileName) if err != nil { return err } 此外,如果错误使用特殊控制结构,则错误处理会扭曲处理错误的程序的控制流。 try-catch-finally块的类似Java的样式交织了多个重叠的控制流,这些控制流以复杂的方式进行交互。虽然相比之下,Go使检查错误更加冗长,但显式设计使控制流程直截了当 - 字面意义。

毫无疑问,生成的代码可以更长,但这些代码的清晰度和简单性抵消了它的冗长。显式错误检查会强制程序员在出现错误时考虑错误并处理错误。异常使得忽略它们而不是处理它们太容易了,将调试堆栈向上传递,直到解决问题或诊断问题为时已晚。

Tools

软件工程需要工具。每种语言都在具有其他语言和无数工具的环境中运行,以编译,编辑,调试,配置,测试和运行程序。

Go的语法,包系统,命名约定和其他功能旨在使工具易于编写,并且库包括词法分析器,解析器和类型检查器。

操作Go程序的工具很容易编写,已经创建了许多这样的工具,其中一些工具对软件工程产生了有趣的影响。

其中最着名的是gofmt,Go源代码格式化程序。从项目开始,我们打算用机器格式化Go程序,消除程序员之间的整个论点:我如何布置代码? Gofmt在我们编写的所有Go程序上运行,大多数开源社区也使用它。它作为代码存储库的“预提交”检查运行,以确保所有签入的Go程序的格式相同。

Gofmt经常被用户称为Go的最佳功能之一,即使它不是语言的一部分。 gofmt的存在和使用意味着社区从一开始就一直将Go代码视为gofmt格式化,因此Go程序只有一种风格,现在每个人都很熟悉。统一的表示使代码更易于阅读,因此可以更快地进行操作。不花费在格式上的时间是节省时间。 Gofmt还会影响可伸缩性:由于所有代码看起来都相同,因此团队可以更轻松地协同工作或与其他代码协同工作。

Gofmt启用了另一类我们没有预见到的工具。该程序通过解析源代码并从解析树本身重新格式化它来工作。这使得在格式化之前编辑解析树成为可能,因此出现了一套自动重构工具。它们易于编写,可以在语义上丰富,因为它们直接在解析树上工作,并自动生成规范格式的代码。

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

gofmt -r'a [b:len(a)] - > a [b:]' 关于这种转换的一个关键点是,因为输入和输出都是规范格式,所以对源代码所做的唯一更改是语义的。

如果语句在换行符处结束,当语言不再需要分号作为语句终止符时,类似但更复杂的过程允许使用gofmt来更新树。

另一个重要的工具是gofix,它运行用Go编写的树重写模块,因此能够进行更高级的重构。 gofix工具允许我们对API和语言功能进行全面更改,直到Go 1的发布,包括更改从地图中删除条目的语法,用于操作时间值的完全不同的API等等。随着这些更改的推出,用户可以通过运行simple命令更新所有代码

gofix 请注意,即使旧代码仍然有效,这些工具也允许我们更新代码。因此,随着库的发展,Go存储库很容易保持最新。可以快速自动地弃用旧API,因此只需要维护一个版本的API。例如,我们最近改变了Go的协议缓冲区实现以使用“getter”函数,这些函数之前不在接口中。我们在所有Google的Go代码上运行gofix来更新所有使用协议缓冲区的程序,现在只有一个版本的API在使用中。在Google的代码库中,对C ++或Java库进行类似的彻底更改几乎是不可行的。

标准Go库中存在解析包也启用了许多其他工具。例子包括go工具,它管理程序构建,包括从远程存储库获取包; godoc文档提取程序,用于验证在库更新时是否维护API兼容性合同的程序等等。

尽管在语言设计的背景下很少提及这些工具,但它们是语言生态系统中不可或缺的一部分,而Go的设计考虑了工具,这对语言,库和它的开发产生了巨大的影响。社区。

Conclusion

Go的用途正在Google内部增长。

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

许多其他公司也使用Go;列表很长,但一些更为人所知的是:

BBC全球 典范 Heroku的 诺基亚 的SoundCloud 看起来Go正在实现其目标。尽管如此,宣布它成功还为时尚早。我们还没有足够的经验,尤其是大型程序(数百万行代码),以了解构建可扩展语言的尝试是否得到了回报。但所有指标都是正面的。

在较小的范围内,一些小的东西不太正确,可能会在以后的(Go 2?)版本的语言中进行调整。例如,有太多形式的变量声明语法,程序员很容易被非零接口内的nil值行为所迷惑,并且有许多库和接口细节可以使用另一轮设计。

值得注意的是,gofix和gofmt让我们有机会在Go版本1的引导期间解决许多其他问题。因为它现在比设计师想要的更接近于没有这些工具的情况,这些都是由语言设计启用的。

但并非所有事情都得到了解决。我们还在学习(但现在语言已被冻结)。

语言的一个重要缺点是实现仍然需要工作。编译器生成的代码和运行时的性能应该更好,并继续工作。已经取得了进展;实际上,与2012年初的Go版本1的第一个版本相比,今天的开发版本的一些基准测试显示性能翻了一番。

Summary

软件工程指导了Go的设计。除了大多数通用编程语言之外,Go还旨在解决我们在构建大型服务器软件时遇到的一系列软件工程问题。另一方面,这可能会让Go听起来相当沉闷和工业化,但事实上,整个设计中对清晰度,简洁性和可组合性的关注反而产生了一种高效,有趣的语言,许多程序员都觉得这种语言具有表现力和强大功能。

导致这种情况的属性包括:

  • 清除依赖关系
  • 语法清晰
  • 清晰的语义
  • 继承的构成
  • 编程模型提供的简单性(垃圾收集,并发)
  • 简单的工具(go工具,gofmt,godoc,gofix) 如果您还没有尝试过Go,我们建议您这样做。 golang.org