Go 在 Google:服务于软件工程的语言设计(翻译)(二)
原文: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 语言最早期的设计者。
译文较长,分三篇推送,这里是第 8~13 小节。
第一篇(1~7 小节):Go 在 Google:服务于软件工程的语言设计(翻译)(一)
8. 包
Go 的包系统设计,将库、命名空间、模块的一些特性结合在一起,变成一个统一的结构。
译者注:2012 年 Go 1.0 ,包管理使用的还是最简单的 GOPATH 模式。之后这种基于 Google 单一代码库的设计造成了各种不便,第三方包管理工具百花齐放。2015 年 Go 1.5 引入 Vendor 机制,到后面发现还是没有解决问题。第三方工具 dep 一度最有希望转正,结果 2018 年官方推出 vgo (后改名 Go Modules 并入
go
工具链)统一了机制,到 2020 年的 1.14 正式宣布 Go Modules "ready for production"。跟 20102 相比,现在 Go 的包管理已经有了很多变化,最主要的是引入了 module 的概念。
每一个 Go 源文件,例如 "encoding/json/json.go"
,都会以一个 package
语句开始,像这样:
package json
其中 json
是 『包名』,一个简单的标识符。包名通常是简明扼要的。
要使用一个包,导入语句里的包路径标识了要导入的文件。『路径』的含义并未在语言中指定,但在实践中,按照惯例,它是源包在代码库里的目录路径,以斜杠 /
分隔,像:
import "encoding/json"
然后,在导入的源文件(importing,调用方)里引用时,用包名(区别于路径)来修饰(qualify)被导入(imported)包的包中成员:
var dec = json.NewDecoder(reader)
这种设计清晰明确。Name
对比 pkg.Name
,人们总是可以从语法中判断出一个名字是否来自本地包。(这一点后面会有更多的介绍。)
在我们的例子中,包的路径是 "encoding/json"
,而包名是 json
。在标准仓库之外,惯例是将项目或公司名称放在命名空间的根部:
import "google/base/go/log"
重要的是要认识到包的路径是唯一的,但对包名却没有这样的要求。路径必须唯一地标识要导入的包,而包名只是一个约定,让包的调用方可以引用它的内容。包名不需要是唯一的,可以在每个导入(importing)的源文件里,通过在导入语句中提供一个本地标识符来重命名。下面两个导入都引用了包名为 log
的包,但要在同一个源文件里导入它们,必须(在本地)重命名其中一个包。
import "log" // 标准包
import googlelog "google/base/go/log" // Google专用包
每个公司可能都有自己的 log
包,没有必要让包名独一无二。恰恰相反:Go 的风格建议保持包名短小精悍、清晰明确,而不是担心重名 。
还有一个例子:在 Google 的代码库里,有很多个 server
包。
9. 远程包
Go 包系统的一个重要特性是,包的路径一般可以是任意字符串,可以用它标识托管代码仓库的站点 URL ,以此来引用远程代码库。
下面是使用 github
上的 doozer
包的方法。go get
命令使用 go
构建工具从站点获取仓库并安装它。一旦安装完毕,它就可以像其他普通的包一样被导入和使用。
$ go get github.com/4ad/doozer // 获取包的 Shell 命令
import "github.com/4ad/doozer" // Doozer 调用方的 import 语句
var client doozer.Conn // 调用方对包的引用
值得注意的是,go get
命令以递归的方式下载依赖,正是因为依赖关系是显式的所以才可以这样实现。另外,区别于其它语言使用的集中式包注册,Go 导入路径的命名空间分配依赖于 URL,这使得包的命名是去中心化的,因而是可扩展的。
10. 语法
语法就是一门编程语言的用户界面。**尽管语法对语义影响有限,而语义很可能才是语言更重要的组成部分,但语法决定了语言的可读性,继而决定了语言的清晰度。**同时,语法对工具链而言至关重要:如果一门语言难以解析,就很难为其编写自动化工具。
因此,Go 在设计时就考虑了语言的清晰度和工具链,并且拥有简洁的语法。与 C 家族的其他语言相比,它的语法规模不大,只有 25 个关键字(C99 有 37 个;C++11 有 84 个;而且这两个数字还在继续增加)。更重要的是,语法很规范,所以很容易解析(应该说大多数规范;也有个别怪异的语法我们本可以改善结果发现得太晚)。与 C 和 Java,尤其是 C++ 不同,Go 可以在没有类型信息或符号表的情况下进行解析;不需要类型相关的上下文。语法容易推导,工具自然就容易编写。
Go 语法里有一个细节会让 C 程序员感到惊讶,那就是声明语法更接近 Pascal 而不是 C。声明的名称出现在类型之前,并且使用了更多关键字(译者注:指 var
和 type
关键字):
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,类型为 T,下同
absT := Abs(t)
方法(method)只是有一个特殊参数的函数,这个特殊参数就是它的接收者(receiver),可以通过点号 .
传递给函数。方法声明的语法将接收者放在函数名前面的括号里。下面是同一个函数,现在定义成 T
类型的方法:
func (x T) Abs() float64
// 译者补充调用示例:
absT := t.Abs()
而这里是一个函数变量(闭包),参数类型为 T
;Go 有一等函数(first-class function)和闭包:
译者注:一等函数是指函数可以作为普通变量,可以作为其他函数的参数和返回值;作为对比, Java 只有类是一等公民,其他语言成分必须作为类的成员。
negAbs := func(x T) float64 { return -Abs(x) }
// 译者补充调用示例:
negT := negAbs(t)
最后,在 Go 里函数可以返回多个值。常见的做法是将函数结果和错误值作为一对返回,就像这样:
func ReadByte() (c byte, err error)
c, err := ReadByte()
if err != nil { ... }
错误处理我们后面再聊。
Go 缺少了一个特性,那就是它不支持函数的默认参数(default function arguments)。这是一个故意的简化。经验告诉我们,默认参数会让修复 API 显得太容易,仿佛只要添加更多参数就可以弥补设计上的缺陷,结果导致添加了过多的参数,参数之间的关系变得难以拆分、甚至无法理解。缺少默认参数的情况下,因为一个函数无法承载整个接口,就需要定义更多的函数或方法,但这会导致 API 更清晰、更容易理解。这些函数也都需要单独命名,这使得有哪些函数、分别接受哪些参数一目了然,同时也鼓励人们对命名进行更多的思考,这是清晰度和可读性的一个关键方面。
作为缺少默认参数的补偿,Go 支持易用的、类型安全的可变参数函数(variadic functions)。
11. 命名
Go 采用了一种不同寻常的方法来定义标识符的可见性(所谓可见性,是指一个包的调用方是否可以通过标识符使用包内的成员)。不同于使用 private
和 public
等关键字,在 Go 里,命名本身就带有信息:标识符首字母的大小写决定了标识符的可见性。如果首字母是大写字母,标识符就会被导出(公共);否则就是私有的:
首字母大写: Name
对包的调用方可见首字母小写: name
(或_Name
)对包的调用方不可见
这条规则适用于变量、类型、函数、方法、常量、字段 ...... 所有一切。这就是全部规则。
这个设计不是一个容易做的决定。我们纠结了一年多的时间,去考虑用什么符号指定标识符可见性。而一旦我们决定使用命名的大小写,我们很快就意识到它已经成为了语言里最重要的特性之一。名称毕竟是给包的调用方使用的;把可见性放在名称里而不是类型里,意味着只要看一眼,就能确定一个标识符是否公共 API 的一部分 。在使用 Go 一段时间之后,再去看其他语言,还要查找声明才能发现这些信息,就会觉得很累赘。
目标仍然是清晰度:程序源码要简单直接地表达程序员的意图。
另一个简化是,Go 有一个非常紧凑的作用域(scope)层次结构:
全局(预先声明的标识符,像 int
和string
)包(包的所有源文件都在同一个作用域) 文件(仅用于导入包的重命名,实践中不是特别重要) 函数(跟其它语言一样) 代码块(跟其它语言一样)
没有什么命名空间(name space)作用域、类(class)作用域或者其它结构的作用域。在 Go 里,名称只来自很少的地方,而且所有名称都遵循相同的作用域层次:在源码的任意位置,一个标识符只表示一个语言对象,和它的用法无关。(唯一的例外是语句标签 (用作 break
等语句的目标);它们总是具有函数作用域。)
这使代码更清晰。例如,请注意到方法声明了一个显式的接收者(explicit receiver),访问该类型的字段和方法必须用到它。没有隐式的(implicit) this
。也就是说,我们总是写:
rcvr.Field
(其中 rcvr
是给接收者变量随便起的名称)所以在词法上(lexically),该类型的所有元素,总是绑定到一个接收者类型的值上。类似地,对于导入的名称,包的限定符总是存在;人们写的是 io.Reader
而不是 Reader
。这样不仅清楚,而且释放了标识符 Reader
作为一个有用的名称,可以在任何包中使用。事实上,在标准库中有多个导出的标识符都叫 Reader
,类似的还有很多 Printf
,但具体引用了哪一个永远不会弄混。
最后,这些规则结合在一起,保证除了顶层的预定义名称如 int
之外,每个名称(点号 .
前的第一部分)总是在当前包中声明。
简而言之,名称总是本地的(local)。在 C、C++ 或 Java 里,名称 y
可以指向任何东西。在 Go 里,y
(甚至大写的 Y
)总是在包内定义,而 x.Y
的解释很清楚:在本地找到x
,Y
就在里面。
这些规则为可伸缩性提供了很重要的特性,因为它们保证了在一个包里添加导出的名称永远不会破坏这个包的调用方。命名规则解耦了包,提供了可伸缩性、清晰度和健壮性。
关于命名还有一个方面需要提及:方法查找总是只按名称,而不是按方法的签名(类型)。换句话说,一个类型永远不可能有两个同名的方法。给定一个方法 x.M
,永远只有一个 M
与 x
关联。同样,这使得只给定名称就能很容易地识别引用了哪个方法。这也使得方法调用的实现变得简单。
译者注:换句话说,Go 不支持函数和方法重载。
Go 的内置函数其实是有重载的。
make
和len
这些函数,参数类型不同,具体的行为也不一样。make
甚至还有一个到三个参数的三个版本。这些函数根据参数不同,在编译时被替换成了不同的函数实现。但为了保持代码清晰,实现简单和运行高效,Go 不支持用户代码的函数重载。
12. 语义
Go 语句的语义一般跟 C 语言类似。它是一种带有指针等特性的、编译型、静态类型的过程式语言。设计上,习惯 C 族语言的程序员应该会感到熟悉。在推出一门新语言时,目标受众能够快速学会它是很重要的;将 Go 植根于 C 家族有助于确保年轻程序员能很容易学会 Go(他们大多数都知道 Java、JavaScript,也许还有 C)。
尽管如此,Go 对 C 的语义还是做了很多小的改变,主要是出于健壮性的考虑。这些变化包括:
没有指针运算 没有隐式数字转换 总是检查数组边界 没有类型别名(声明 type X int
之后,X
和int
是不同的类型,而不是别名)++
和--
是语句(statements)而不是表达式(expressions)赋值不是表达式 对栈上变量取址是合法的(甚至是被鼓励的) 其它
译者注:
Go 在 1.9 还是引入了类型别名,语法是
type X = int
。用来解决迁移、升级等重构场景下,类型重命名的兼容性问题,以及方便引用外部导入的类型。实际上,类型别名仅在代码中存在,编译时会全部替换成实际的类型,不会产生新类型。
语句和表达式的差别是:语句是计算机角度的一个可执行动作,不一定有值;表达式是数学角度的可求值算式,一定有值,这个值可以放在赋值符号的右边,或者成为更大的表达式的一部分。
不再区分语句和表达式,是编程语言演化的其中一个趋势,这可以增强语言的表达能力。一般的做法,是增加求值规则(像语句的值是语句中最后一个表达式的值),给原本没有值的语句提供一个值,这样就可以通过拼接非常复杂的表达式,用很少的代码解决问题。例如,如果赋值语句有值,那么
e = d = c = b = a = 10
就是合法的;因为赋值运算符从右到左结合,这些赋值最后都会成功,都是 10。但这很容易引起表达式的 滥用 和 误用。人们有可能写出非常难以理解的复杂表达式。或者因为不熟悉某些(本来是语句的)表达式的求值规则而制造难以排查的错误。
Go 首先追求代码的清晰明确,而不是追求单纯的表达能力强或者代码行数少,所以反其道而行,反而去掉了某些语句的值。
栈上分配的内存会在函数返回后被回收,对栈上的变量取址并返回,会导致函数外部引用到已被回收的内存。这就是悬挂指针问题,困扰着大多数有指针的语言。Go 的解决方案是,在编译期做逃逸分析,识别出可能超出当前作用域的指针引用,将对应的内存分配到堆上。所以在 Go 里面,取址操作不用考虑变量究竟是栈上还是堆上的,编译器会反过来配合你。当然,如果是高频操作,可能要考虑一下拷贝和 GC 哪个开销大,传值(栈上分配,需要拷贝,不需要 GC)还是 传指针(如果发生逃逸,堆上分配,不需要拷贝,需要 GC)。
还有一些更大的变化,远离了传统的 C、C++ 甚至 Java 的模式。这些包括在语言级别上支持:
并发 垃圾回收 接口类型 反射 类型判断(type switches)
下面的章节主要从软件工程的角度简要讨论 Go 中的两个主题:并发 和 垃圾回收。关于语言语义和用途的完整讨论,请参见 golang.org 网站上的更多资料。
13. 并发
web 服务器运行在多核机器上,并有大量的调用方,这可以称之为一个典型的 Google 程序;而并发对于这种现代计算环境非常重要。C++ 或 Java 都不是特别适合这类软件,它们在语言层面上缺乏足够的并发支持。
Go 有作为一等公民的通道(channel),实现了 CSP (译者注:Communicating Sequential Processes,通信顺序进程)的一个变种。选择 CSP 的部分原因是熟悉(我们其中一个人曾经研究过某种基于 CSP 思想的前辈语言),同时也是因为 CSP 很容易被添加到过程化编程模型中,而无需对模型进行深入的修改。也就是说,给定一个类似于 C 的语言,CSP 基本就能够以正交的方式添加到语言中,提供额外的表达能力,而不限制该语言的其他用途。 总之,语言的其他部分可以保持『普通』。
这个方法就是,将独立执行的函数,与其他普通的过程式代码结合。
这样得到的语言允许我们将 并发 和 计算 平滑地结合起来。假设有一个 web 服务器,必须验证每次客户端调用的安全证书;在 Go 里面,很容易利用 CSP 来构造这样一个软件:用独立的执行过程来管理客户端,同时还能火力全开为昂贵的加密计算提供编译型语言的高执行效率。
综上所述,CSP 对于 Go 和 Google 来说都很实用。在编写 web 服务器这种典型的 Go 程序时,这个模型是再适合不过了。
有一个重要的注意事项:在并发的情况下,Go 并不是纯粹的内存安全(purely memory safe)语言。内存共享是合法的,在通道上传递指针也是符合惯例的(同时也是高效的)。
一些 并发 和 函数式编程 的专家对于 Go 在并发计算的上下文没有采用『只写一次(write-once)』来处理值语义感到失望,看起来没有其它并发语言(如 Erlang)那么像回事。同样地,原因主要还是在于对问题领域的熟悉度和适用性。Go 的并发特性在大多数程序员熟悉的上下文中都能很好地发挥作用。Go 可以实现简单、安全的并发编程,但并不禁止不良的编程方式。 我们提供约定俗成的做法作为弥补,训练程序员将消息传递视为所有权控制的一种实现方式。我们的座右铭是:『不要通过共享内存来通信,要通过通信来共享内存』。
译者注:『只写一次(write-once)』变量,在某些语言的实现里又叫『单次赋值(single-assignment)』变量(Erlang),或者『不可变(immutable)』变量(函数式编程)。换言之,这种变量只能在初始化时赋值(写入)一次,之后不能再修改;如果需要新的值,只能创建新的变量。这样可以避免在并发上下文意外修改了变量的值。
虽然都不能修改,但还是要区分它和常量的区别。常量是在编译期就已经存在并确定了值;而不可变变量虽然赋值后不可修改,但其创建 / 赋值的时机和具体的值还是在运行时决定的。
这其实是来自函数式编程『无副作用(side effect)』和『不修改状态(state)』的概念,虽然可以保证程序的正确性,却跟 C 家族的过程式编程模型差异很大,照搬过来需要对这个模型进行比较大的改动,这就违背 Go 的设计初衷了。
从我们对 Go 和 并发编程 的新手程序员的有限了解来看,这是一种实用的做法。程序员享受着并发支持给网络软件带来的简单性,而简单性产生了健壮性。
译者在网上看到一种说法:『Java 里多种同步方法、各种 Lock、并发调度等一系列复杂的功能在 Golang 里 都不存在,只靠 goroutine 和 channel 去处理并发。』,这种说法是错的。
如上面所说,CSP 模型是以基本正交的方式添加到 C 家族的过程式编程模型里的,增加了新的、简洁的表达方式,但并没有限制原本的做法。
Go 常用的并发控制的工具,除了内置的消息通道
chan
(CSP 模型),还有:
sync
包提供的同步原语(其中包括互斥锁和读写互斥锁sync.Mutex
和sync.RWMutex
,还有其它三个原语sync.WaitGroup
,sync.Once
和sync.Cond
。实际上你去看chan
的源码,也是基于 runtime 内部的mutex
实现的);上下文 context.Context
其它扩展包中提供的工具 可以看到,在 C 家族里常见的并发控制方式,基本都有提供,只是不再像 Java 那样以关键字的方式,而是以内置包的方式提供。
Go 把 CSP 模型实现并把支持上升到内置类型和关键字的层面,却并没有强迫程序员必须使用这个模型。
推荐阅读