在 Go 语言 14 年的发展历史中,我们做得对的和不对的 [译]

作者:

Rob Pike

这是我在 2023 年 11 月 10 日,也就是 Go 作为开源项目发布 14 周年之际,在悉尼 GopherConAU 会议上所做的闭幕演讲(视频链接)。演讲中穿插使用了一些演示文稿的幻灯片。

我们做得对的和不对的

引言

大家好。

首先,我想感谢 Katie 和 Chewy,很荣幸有机会在这次会议上做闭幕演讲,很抱歉我无法脱稿演讲,因为我想确保每个词都表达得准确。

今天是 Go 作为开源项目发布的第 14 个周年,2023 年 11 月 10 日。

记得那天下午 3 点,加州时间,我和 Ken Thompson、Robert Griesemer、Russ Cox、Ian Taylor、Adam Langley、Jini Kim 一起,满怀期待地看着我们的网站上线。就在那时,全世界开始了解我们一直在忙着的事情。

十四年后,我们有很多经验和教训可以回顾。今天,我想借此机会分享自那天以来我们学到的一些重要的经验教训。即使是最成功的项目,也存在可以改进之处。当然,也有一些事情,在回顾时,我们认为是成功的关键因素。

我必须明确一点,我今天的发言仅代表我个人的观点,不代表 Go 团队,也不代表 Google。Go 的成功是一个敬业的团队和庞大社区共同努力的结果,所以如果你认同我今天说的任何话,请向他们表示感谢。如果你有不同意见,那请责怪我,但还是请保留你的看法 😃。

从这次演讲的标题来看,很多人可能会期待我来分析 Go 语言的优缺点。当然,我会讨论一些相关的内容,但不仅仅限于此,原因有以下几个。

首先,关于编程语言的好坏很大程度上是主观观点,而不是客观事实。尽管很多人对 Go 或其他任何语言的一些微小特性都持有强烈的观点。

此外,关于换行符的位置、nil 的工作方式、使用大写字母导出、垃圾回收、错误处理等问题,已经有很多讨论。尽管这些话题值得一提,但基本上已经被讨论得差不多了。

但我想谈论的不仅仅是语言本身,因为这个项目的目标远不止于此。我们最初的目标不是创造一种新的编程语言,而是寻找一种更好的编写软件的方法。我们对当时使用的语言确实有不满——每个人都有,不论用的是哪种语言——但我们面临的根本问题并不在于这些语言的特性,而在于使用这些语言在 Google 构建软件的过程中形成的问题。

一件 T 恤上的第一只囊鼠形象
一件 T 恤上的第一只囊鼠形象

开发一种新的编程语言为我们探索更多创新想法提供了可能,但这只是一个起点,并非核心目的。正是因为当时我正在编写的二进制文件需要长达 45 分钟的构建时间,才催生了 Go 语言的诞生。不过,这并非因为编译器(compiler)本身速度慢,或者编写编译器的语言有问题,而是其他原因所致。

这些原因正是我们想要解决的问题:构建现代服务器软件时遇到的种种复杂性,如管理依赖关系、大团队协作编程以及人员更迭、软件的易维护性、高效测试、多核 CPU 和网络的有效利用等等。

总结来说,Go 并不仅仅是一种编程语言。虽然它本质上是一种编程语言,但其真正目的在于提供一种相比于 14 年前的环境更佳的高质量软件开发方式。

Go 的目标一直都是简化和提高生产软件构建的效率。

几周前,准备这次演讲时,我只有一个标题,其他内容几乎一片空白。为了启动创作,我在 Mastodon 社交平台上征求了意见。许多人作出了回应,我注意到一个趋势:人们认为我们在语言设计上的失误都体现在语言本身,而成功之处则在于更广泛的背景故事,例如 gofmt、部署和测试等语言周边的内容。这实际上令人振奋,说明我们的努力产生了影响。

不过,我们也必须承认,一开始我们没有清晰地阐述我们的真正目标。或许我们认为这些目标是显而易见的。为了弥补这一不足,我在 2013 年的 SPLASH 会议上做了一次题为《Go at Google: Language Design in the Service of Software Engineering》的演讲。

Google 的 Go
Google 的 Go

那次演讲和相关的博客文章可能是对于 Go 语言产生原因的最佳解释。

今天的演讲可以看作是 SPLASH 演讲的一个延续,回顾了我们在完成语言构建后,如何将关注点转移到更广泛的视野,从中学到的经验教训。

现在,让我们来总结一些教训。

首先,当然是:

吉祥物 Gopher(囊鼠)

虽然听起来有些奇怪,但 Go gopher 实际上是 Go 取得成功的早期因素之一。我们在 Go 发布前很长一段时间就已经确定,我们需要一个吉祥物来装饰周边商品——毕竟每个项目都离不开这些商品。Renee French 主动提出为我们设计一个吉祥物,这一决定无疑是正确的。

这是第一个 Go gopher 毛绒玩偶的照片。

Go gopher
Go gopher

下面是 Go gopher 与它的第一个原型版本的照片。

Go gopher 和它的初代原型
Go gopher 和它的初代原型

Go gopher 不仅是一个吉祥物,它更是 Go 程序员们的荣誉象征和认同标志。比如现在,你正在参加一个名为 GopherCon 的会议,这是许多同类会议中的一个。从项目伊始,就有一个可爱而又引人入胜的形象传递信息,对 Go 的发展至关重要。它那既滑稽又聪明的形象——仿佛能够构建任何东西!

Go gopher 正在建造机器人(Renee French 的绘画)
Go gopher 正在建造机器人(Renee French 的绘画)

它不仅为社区成员参与项目增添了趣味性,也代表了技术的卓越追求。最重要的是,作为社区的象征,尤其在 Go 还是编程界的新秀时,Go gopher 成为了大家团结一致的旗帜。

下面是几年前 Go gopher 在巴黎参加会议的照片。看看他们是多么的兴奋!

巴黎的 Go gopher 观众(Brad Fitzpatrick 的照片)
巴黎的 Go gopher 观众(Brad Fitzpatrick 的照片)

虽然如此,将 Gopher 设计以创意共享署名许可证的形式发布可能并不是最明智的选择。一方面,这的确鼓励了人们以创意丰富的方式重塑它,这在一定程度上促进了社区精神的发展。

Gopher 模型图
Gopher 模型图

Renee 设计了一份“模型图”,以便艺术家在创作时既能保留 Gopher 的核心精神,又能加入自己的风格。

许多艺术家乐于探索这些特点,并创作出自己独特版本的 Gopher。其中,Renee 和我最喜欢的是日本设计师 @tottie 的作品:

@tottie 的 Gophers
@tottie 的 Gophers

以及游戏程序员 @tenntenn:

@tenntenn 的 Gopher
@tenntenn 的 Gopher

然而,许可证中的“署名”条款常常引发了一些令人不快的争论,甚至错误地将并非 Renee 创作且偏离原作精神的作品归功于她。坦白说,这种署名要求很难被遵守,有时甚至完全被忽视。举个例子,我对 @tenntenn 是否因其 Gopher 插图的使用而得到了相应的认可或补偿表示怀疑。

gophervans.com: Boo
gophervans.com: Boo

如果我们有机会重新来过,我们会深思熟虑,找到最佳方式确保吉祥物能忠于其初衷。维护一个吉祥物并非易事,寻找到合适的解决方案仍然是个挑战。

但话题转向更技术性的方面。

做得对

回顾起来,以下是我认为我们在 Go 语言项目中做得非常正确的几点。并非每个语言项目都做到了这些,但它们对 Go 的成功至关重要。我将简要说明,因为这些都是你们熟悉的话题。

1. 规范。我们的起点是一份正式的规范。它不仅在编写编译器时确保了行为的一致性,而且还使得不同的实现能够共存并保持一致的行为。仅凭编译器无法成为规范。那你用什么来对编译器进行测试呢?

网络上的规范
网络上的规范

顺便提一下,规范的初稿是在悉尼达令港的一栋大楼 18 楼完成的。我们正在 Go 的诞生地庆祝 Go 的生日。

2. 多重实现。存在多个编译器,它们都遵循相同的规范。有了规范,实现这一目标就变得容易许多。

当伊恩·泰勒(Ian Taylor)有一天给我们发来邮件,告诉我们他阅读了我们的规范草案后自己编写了一个编译器,这让我们感到惊讶。

主题:Go 的一个 gcc 前端

来自:Ian Lance Taylor

日期:2008 年 6 月 7 日星期六下午 7:06

收件人:Robert Griesemer, Rob Pike, Ken Thompson

我的一个同事向我推荐了 http://.../go_lang.htmlid:sx38h8c3648w2x2i。这是一种很有趣的语言,我为它制作了一个 gcc 前端。虽然还缺少很多功能,但已经能够编译网页上的素数筛选代码了。

这是一个惊人的突破,随后有更多人加入,所有这些都得益于正式规范的存在。

许多编译器
许多编译器

多个编译器的存在不仅帮助我们完善了语言和规范,还为那些不太喜欢我们的 Plan-9 风格工作方式的人提供了另一种选择。

(关于这点我们稍后再详细讨论。)

如今,有许多兼容的实现存在,这是非常好的事情。

3. 可移植性。我们使跨平台编译变得极为简单,

程序员们可以自由选择他们喜欢的开发平台,并且能轻松地部署他们的代码到任何目标平台。在这方面,Go 语言可能比其他任何编程语言都来得更加简单。我们通常会认为编译器是针对其运行的机器本地化的,但实际上并非必然如此。

突破这种固有假设非常有影响力,这对许多开发者来说是一个全新的概念。

可移植性
可移植性

4. 兼容性。在 Go 语言的 1.0 版本开发过程中,我们投入了大量努力,随后通过一个兼容性承诺来确保其稳定性。考虑到这对 Go 的普及产生了显著的影响,我很惊讶其他许多项目并未采纳类似的做法。尽管维护高度兼容性确实需要成本,但它有效地防止了功能过度膨胀,并在一个几乎一切都在不断变化的世界里,不必担心新版本的 Go 会破坏你的项目,这是一件非常令人欣慰的事情。

Go 兼容性承诺
Go 兼容性承诺

5. 库。尽管 Go 代码库的形成初期有些偶然,因为最初并没有其他地方可以放置 Go 代码,但这个坚固、精心构建的代码库成了编写 21 世纪服务端代码的重要资源。它使得社区成员在充分了解哪些额外内容值得提供之前,都使用同一套工具。这种做法极为成功,它帮助避免了多种不同的库的出现,有助于统一社区。

库

6. 工具。我们确保了 Go 语言易于解析,这极大地促进了工具开发。最初我们认为可能需要为 Go 开发一个专用的集成开发环境(IDE),但由于语言本身的易用性,随着时间的推移,开发者们自然而然地为现有 IDE 添加了对 Go 的支持。如今,配合 gopls 的加入,IDE 对 Go 的支持已变得非常出色。

工具
工具

除此之外,我们还提供了一系列与编译器并行的辅助工具,如自动化测试、代码覆盖率检测和代码审查。特别是 go 命令,它整合了整个构建过程,对许多项目而言,它是构建和维护 Go 代码的一站式解决方案。

快速构建
快速构建

此外,Go 因其快速的构建过程而赢得了良好声誉,这一点同样对其受欢迎程度有所助益。

7. Gofmt。我特别提出 gofmt,因为它不仅对 Go 产生了重大影响,还在整个编程界引起了共鸣。在 Robert 开发 gofmt 之前(他从一开始就坚决要完成这个项目),自动格式化工具普遍质量较低,几乎不被使用。

Gofmt 格言
Gofmt 格言

但 gofmt 的成功展示了,优秀的自动格式化是可行的。如今,几乎所有主流编程语言都配备了标准的格式化工具。这不仅节省了围绕代码格式(如空格和换行)的无尽争论时间,更重要的是,它通过定义标准格式并自动化这一过程,节约了大量的开发时间和精力。

gofmt 还使得其他许多工具的开发成为可能,包括代码简化器、分析器,甚至是代码覆盖率工具。由于 gofmt 的核心代码被开发成了一个可供任何人使用的库,开发者可以解析程序,编辑抽象语法树(AST),然后生成完美无缺的、适合人类和机器使用的输出。

感谢你,Robert。

但我们不能只满足于此。接下来,让我们探讨一些更有争议的主题。

并发

并发真的有争议吗?确实如此,至少在 2002 年我加入 Google 的时候是这样。John Ousterhout 曾明确指出,他认为线程(thread)不是一个好选择,许多人因为觉得线程难以驾驭而赞同他的观点。

John Ousterhout 对线程的看法
John Ousterhout 对线程的看法

在 Google,我们几乎不用线程,甚至几乎完全禁止使用。负责这项禁令的工程师们也引用了 Ousterhout 的观点。这让我开始思考。自 20 世纪 70 年代以来,我一直在做与并发相关的工作,有时甚至我自己都没意识到。对我而言,这是一个强大的工具。但仔细思考后,我发现 Ousterhout 实际上犯了两个错误:他将线程的应用领域过度泛化,而且他的不满主要来自于使用像 pthread 这样笨拙的低级工具包,并不是针对并发的基本概念。

工程师们通常会混淆问题和解决方案,这是一个常见的误区。有时,提出的解决方案比问题本身还要复杂,我们很难发现更简单的方法。但这有点跑题了。

根据我的经验,我知道有更好的方法来使用线程,或者无论我们怎么称呼它们。我甚至在 Go 问世前就做过一次关于它们的演讲。

Newsqueak 中的并发实践
Newsqueak 中的并发实践

并且,我并不是唯一一个了解这一点的人。很多其他的编程语言、学术论文,甚至是书籍都探讨了并发编程,展示了其优秀的实践方式。只不过这个概念还没有成为主流思想。而 Go 语言的部分诞生原因,就是为了改变这一现状。记得那次传奇的 45 分钟编译尝试中,我想在一个非线程的二进制程序中加入一个线程,但由于我们使用了不合适的工具,这变得异常困难。

现在回头看,我认为可以公平地说,Go 语言在让编程界认识到并发是一个强大的工具方面发挥了重要作用,特别是在多核和网络化的世界中。它展示了与传统的 pthreads 相比,有更好的并发实现方式。现在,大多数主流编程语言都很好地支持了并发。

Google 3.0
Google 3.0

Go 语言在并发处理方面的创新之一是它的 goroutine 设计,简洁而统一,没有区分成协程、任务、线程等多种概念,只有 goroutine。我们特地创造了“goroutine”这个词,因为当时没有任何现有的术语能够准确描述它。直到今天,我仍然希望 Unix 的 spell 命令能够认识这个词。

顺便提一下,我经常被问到关于 async/await 的问题。尽管我对许多语言选择使用 async/await 模型及其风格来实现并发感到些许遗憾,但必须承认,这比 pthreads 方式有了巨大的进步。

相较于 goroutine、通道和选择机制,async/await 对于编程语言的开发者来说,实现起来更为简单,更容易集成到现有的系统中。但这种方式又把一些复杂性转嫁给了程序员,经常导致出现所谓的“有色函数”,就像 Bob Nystrom 曾经描述的那样。

你的函数是什么颜色?

我认为 Go 通过 CSP(一种经典但稍显老旧的模型)的实践,展示了如何将并发模型融入到过程式编程语言中,而无需引入复杂性。我见过多次将 CSP 作为库来实现的例子。但是,如果要做得好,CSP 的实现确实需要较高的运行时复杂性,我可以理解为什么有些开发者不愿意把这种复杂性纳入他们的系统。然而,无论选择哪种并发模型,关键是只实现一次,因为一个环境中提供多种并发实现会导致问题。Go 通过将并发机制内置于语言本身,而不是作为一个库,巧妙地解决了这个问题。

关于这些内容,我还可以展开更多讨论,但今天就谈这么多。

[讲解结束]

并发不仅是技术上的创新,它还为 Go 带来了新鲜感。正如我之前提到的,尽管其他一些语言早已支持并发,但它们从未真正流行起来。而 Go 对并发的支持,特别是它的简洁与强大,成为了早期吸引开发者的一大亮点,尤其是那些以前没怎么接触过并发编程、对其充满好奇的程序员。

正是在这个过程中,我们犯了两个关键性的错误。

耳语的地鼠们(协作顺序处理过程)
耳语的地鼠们(协作顺序处理过程)

首先,虽然我们对并发性充满热情,但实际上,我们更多考虑的是服务端应用,例如在像 net/http 这样的核心库中应用,并发并不需要在每个程序的每个部分都使用。许多程序员在尝试使用并发时,往往不明确它到底如何有助于他们的工作。我们原本应该明确指出,语言中并发支持的核心价值在于简化服务器软件的开发。这个概念对许多人而言至关重要,但并非所有尝试 Go 的开发者都能领会,这种指导的缺失是我们的疏忽。

第二个相关点是,我们用了太长时间才明确阐述并行性(多核机器上多重计算的并行执行)和并发性(一种使代码结构化以有效执行这些操作的方法)之间的区别。

并发不等于并行

许多程序员尝试通过使用 goroutines 来并行化他们的代码以提高运行速度,却常常对加速失败感到迷惑。只有在并行化的底层问题本质上适合并行处理,比如处理 HTTP 请求时,并发代码才能加速。我们在解释这一点上做得不够好,这使得许多程序员感到困惑,甚至可能导致一些人放弃使用。

为了解释这一点,我于 2012 年在 Heroku 的开发者大会 Waza 上做了一次演讲,题为并发不等于并行。这是一次精彩的演讲,但我们应该更早做出这样的阐释。

对于这个延迟,我表示歉意。但值得肯定的是,Go 促进了将并发作为构建服务器软件的一种方式。

接口

接口与并发一样,是 Go 中的一个显著特点。它们代表了 Go 对面向对象设计的理解,追求的是原始的、以行为为中心的风格,尽管不断有新手试图让结构体承担这一角色。

接口的动态性质,即无需提前声明哪些类型实现了它们,最初让一些批评者感到不安,甚至直至今日仍有人对此表示不满,但这对于 Go 培养的编程风格至关重要。标准库的很大一部分就是建立在它们的基础之上的,而像测试和依赖性管理这样的更广泛主题,也极度依赖于它们包容的、对所有人开放的特性。

我认为接口是 Go 中设计得最出色的特性之一。

除了最初一些关于是否应在接口定义中包含数据的讨论外,它们实际上是在讨论的第一天就已经成型的。

Rob Pike 和 Nigel Tao 在 2011 年提出的一个 Go 接口练习:GIF 解码器
Rob Pike 和 Nigel Tao 在 2011 年提出的一个 Go 接口练习:GIF 解码器

在这背后,还有一个值得一提的故事。

就在我们在罗伯特的办公室经历的那个开创性的第一天,我们面临了一个问题:如何处理多态性。由于 Ken 和我对 C 语言中的 qsort 有所了解,知道它是一个挑战性的测试案例,我们三人开始探讨我们初出茅庐的语言如何实现一个安全的类型排序程序。

罗伯特和我几乎同时产生了一个想法:通过在类型上定义方法,来提供排序所需的各种操作。这个想法迅速发展成了一种理念:值类型拥有以方法形式定义的行为,而一组方法则构成了接口,函数可以基于这些接口进行操作。这就是 Go 的接口概念的诞生。

sort.Interface
sort.Interface

有一点经常被忽视:Go 的排序功能实际上是通过一个操作接口的函数来实现的。这种方式与大多数人熟悉的面向对象编程风格不同,但它体现了一个强大且具有创新性的思想。

这个想法让我们感到兴奋,它有可能成为编程的一个基础构建块,这种可能性让我们着迷。当 Russ 加入我们时,他很快发现 I/O(输入/输出)能够完美地融入这一概念。基于此,我们迅速发展出了基于三个著名接口——empty、Writer 和 Reader 的库,这些接口平均每个只有两个三分之二的方法。这些精简的方法已成为 Go 的典型特征,并广泛应用于各处。

Go 中接口的运作方式不仅是其独特之处,更成为了我们构思库、通用性和组合的新方式。这是一场思维的革新。

但我们可能忽视了一个问题,那就是过早地结束了这场讨论。

我们选择了这条路径,部分原因是我们过去常常看到,泛型编程倾向于先关注类型而非算法,这导致了过早的抽象化,而不是自然的设计过程,更多关注容器而非功能本身。

我们在语言核心中定义了泛型容器——映射、切片、数组、通道,但却没有为程序员提供访问这些泛型的途径。这可能是我们的失误。我们确信,而且我依然认为,大多数简单的编程任务可以通过这些类型得到有效处理。然而,总有一些任务做不到这一点,而语言提供的功能和用户能控制的范围之间的差距,的确让一些人感到不满。

简言之,虽然我不打算改变接口在 Go 语言编程中的运作方式,但我们必须承认,接口的存在曾使我们的思维方式偏离了正确的轨道,直到十多年后我们才逐渐纠正。从一开始,Ian Taylor 就不断督促我们正视这一问题,但由于接口是 Go 语言编程的核心,要改变这一点并不容易。

我们经常会听到批评声音,他们建议我们直接实现泛型,因为在他们看来,这在某些语言中似乎很简单。然而,接口的存在意味着我们在引入任何新的多态性形式时,都必须考虑到它们的影响。为了找到一个既能与 Go 语言其它部分协调,又能有效工作的解决方案,我们经历了多次尝试、几个未完成的实现方案,以及长时间的讨论。最终,我们请来了一些类型理论专家,包括 Phil Wadler,来协助我们。即便是在今天,Go 语言中已经拥有了一个稳定的泛型模型,但仍然存在一些与接口作为方法集相关的遗留问题。

一种泛型排序的示例图
一种泛型排序的示例图

最终我们找到的答案是设计了一种更通用的接口,能够整合更多形式的多态性,这一转变从“方法集”到“类型集”虽然微妙,但却是深刻的。大多数社区成员似乎都能接受这一变化,尽管我认为对此的抱怨可能永远不会停止。

有时,我们需要多年时间才能理解某件事,或者甚至才能意识到有些事情我们无法完全理解。但我们仍需坚持不懈地探索。

顺便说一下,我个人希望我们能有一个比“泛型”更合适的术语,因为“泛型”这个词最初是用来描述一种不同的、以数据结构为中心的多态性风格。Go 语言实际上提供的是“参数多态性”,这个术语虽然准确,但读起来并不那么顺口。尽管如此,我们还是习惯性地使用“泛型”这个词,虽然它并不完全准确。

编译器

编程语言社区对早期 Go 编译器使用 C 语言编写这一事实感到不满。他们认为,正确的做法应该是使用 LLVM 或类似的工具,或者直接用 Go 语言自己编写编译器,这个过程被称为“自托管”。然而,我们没有采取这些做法,原因有几个。

首先,要启动一种新的编程语言,至少在最初阶段,其编译器必须用现有的语言来编写。对我们来说,C 语言是显而易见的选择,因为 Ken 已经开发过一个 C 语言编译器,它的内部结构非常适合作为 Go 编译器的基础。此外,用一种语言自身来编写它的编译器,同时还在开发这种语言,往往会导致这种语言特别适合编写编译器,但这并不是我们想要的语言类型。

早期的编译器发挥了它的作用,为这门语言的发展打下了基础。但这个编译器有些特别,它采用了 Plan 9 风格,依赖一些旧的编译技术,而不是诸如静态单赋值(一种现代编译技术)之类的新方法。尽管它生成的代码质量一般,内部结构也不够完美,但这个编译器实用高效,代码简洁易懂,这让我们能够迅速尝试新想法并做出调整。其中一个关键改进是添加了自动增长的分段栈(一种程序内存管理技术)。这一功能在我们的编译器中易于实现,但如果我们使用了像 LLVM 这样的复杂工具包,将这个改变整合进完整的编译器套件将非常困难,因为这需要对应用二进制接口(ABI)和垃圾回收支持做出大量修改。

交叉编译也是一个成功的领域,这一点直接继承自原始的 Plan 9 编译器套件。

尽管我们的方法不同寻常,但它帮助我们快速前进。虽然有些人对此感到不满,但对我们来说,那时候这是正确的决定。

Go 编译器架构 1.5 版后的情况
Go 编译器架构 1.5 版后的情况

对于 Go 1.5 版,Russ(一位开发者)开发了一个工具,将编译器从 C 语言半自动地转换为 Go 语言。那时,Go 语言已趋于成熟,关于编译器指导语言设计的问题已不再是关注点。网上有关于这一转换过程的讲座值得一看。例如,我在 2016 年的 GopherCon(一个专注于 Go 语言的技术会议)上关于汇编器的演讲,这是我一生追求软件可移植性中的一个高峰。

Go 汇编器的设计 (2016 年 GopherCon)
Go 汇编器的设计 (2016 年 GopherCon)

最初我们选择用 C 语言开始,但最终将编译器转换为 Go 语言,这使我们能够将 Go 的所有优势——包括测试、工具使用、自动代码重写、性能分析等——都应用于其开发中。如今的编译器比最初的版本更加简洁,生成的代码也更优秀。当然,这正是自举过程的本质。

记住,我们的目标不仅仅是创造一种编程语言,而是更多。

我们的这种不寻常的方法绝不是对 LLVM 或编程语言社区的任何成员的不敬。我们只是选择了最适合我们当时任务的工具。当然,今天,有基于 LLVM 的 Go 编译器,以及许多其他选择,这也是应有之意。

项目管理

我们一开始就清楚,要让 Go 取得成功,必须将其打造成一个开源项目。但同时,我们也意识到,在确定核心理念并开发出初步可行的实现之前,私下进行开发会更加高效。最初两年的时间,对于我们在无干扰的环境中明确目标,至关重要。

Go 转向开源,这个变化巨大且充满教育意义。社区的反馈超乎我们的想象。与社区互动耗费了大量时间和精力,尤其是 Ian,他总能惊人地抽出时间回答每一个问题。这一过程不仅耗时,也带来了意想不到的收获。我至今仍对社区在 Alex Brainman 指导下迅速完成的 Windows 版本感到惊叹。

我们花了相当长的时间才理解转为开源项目所带来的影响,以及如何有效管理这一转变。

尤其是,我们花了较长时间才弄清楚如何更好地与社区合作。这次演讲的一个重点是我们在沟通上的不足——即使我们认为沟通得很好——由于误解和期望不匹配,浪费了大量时间。我们本可以做得更好。

然而,随着时间的推移,我们至少说服了与我们同行的社区成员,认识到我们的某些想法,虽然与传统的开源方式不同,但是很有价值。其中最重要的是,我们坚持通过必要的代码审查和对细节的细致关注来保持代码的高质量。

任务控制中心 (Renee French 绘制)
任务控制中心 (Renee French 绘制)

相比之下,有些项目采取的做法是先快速接受代码,然后再对其进行优化。但 Go 项目恰恰相反,我们力求先确保代码质量。我相信这是更有效的方法,虽然这意味着社区成员需要承担更多工作,但只有他们理解这种做法的价值,才能感到真正受到欢迎。在这方面我们仍有许多可学习的地方,但我相信情况已经有所改善。

此外,有一个不太为人所知的历史细节。Go 项目历经了四种不同的源代码管理系统:SVN、Perforce、Mercurial 和 Git。Russ 做了一项艰巨的工作,保存了所有历史记录,使得即使到今天,Git 仓库中也能找到最早在 SVN 中进行的更改。我们都认为保留这些历史非常重要,我要感谢他为此付出的努力。

还有一点要澄清,人们常误以为 Google 指导 Go 团队的工作方向。实际上并非如此。虽然 Google 对 Go 提供了极大的支持,但并未设定其发展方向。相反,社区对此有更大的发言权。Google 内部有庞大的 Go 代码库,团队通过将公共仓库的代码导入到 Google 来测试和验证版本,而不是反过来操作。简而言之,虽然 Go 核心团队的薪资来自 Google,但他们行事却是独立的。

包管理

Go 的包管理系统的开发过程并不顺利。我认为,语言本身的包设计是卓越的,在最初的讨论阶段占据了我们大量的时间和精力。如果你感兴趣,我之前提到的 SPLASH 演讲详细讲解了其工作原理。

关键的一点是,我们使用了简单的字符串来指定代码导入的路径,这种做法的灵活性非常重要,事实证明我们的预判是正确的。然而,从仅依赖“标准库”到能够从网络导入代码,这个过程并不顺畅。

修复云(由 Renee French 绘制的图画)
修复云(由 Renee French 绘制的图画)

我们面临了两个主要问题。

首先,我们 Go 语言核心团队的初期成员都很熟悉 Google 的运作模式,比如使用统一的代码仓库(monorepo)和实时更新的构建方式(building at head)。但我们在处理多版本包管理和解决依赖关系图的复杂问题方面经验不足。即使到现在,很少有人能真正理解这些技术上的复杂性,我们一开始没有积极应对这些问题,这是我们的失误。更尴尬的是,我之前负责过 Google 内部的一个类似项目,却没能意识到我们面临的挑战。

deps.dev
deps.dev

我在 deps.dev 的投入有如为过去的错误赎罪。

其次,我们试图让社区参与解决依赖管理问题的做法本是好意,但当我们最终提出设计方案时,尽管提供了大量文档和理论解释,许多社区成员仍然感到被忽略。

pkg.go.dev
pkg.go.dev

这次失败让我们团队深刻认识到,与社区的互动应该如何进行。自那以后,我们在许多方面都有了显著的改进。

现在,一切已经稳定下来。我们最终形成的设计在技术上非常出色,对大多数用户来说运行良好。只是这个过程花费了太多时间,而且经历了不少波折。

文档和示例

另一个我们最初没有处理好的方面是文档编写。我们撰写了大量文档,认为做得不错,但很快就发现社区期望的文档水平和我们的预期有所差异。

固定图灵机器的 Gopher(Renee French 绘图)
固定图灵机器的 Gopher(Renee French 绘图)

我们曾缺乏甚至最基础功能的示例。原本认为只需解释功能即可,却没意识到提供实际使用示例更为重要,这个认识来得有些晚。

可执行示例
可执行示例

不过,我们终于吸取了这个教训。如今,文档中充满了众多示例,主要由开源社区的贡献者提供。我们很早就让这些示例能在网络上运行。例如,我在 2012 年的 Google I/O 上展示了并发性,Andrew Gerrand 则创造了一段精巧的网络代码,使得人们能够直接在浏览器中运行这些代码片段。这或许不是首次实现,但对于之前未曾见识过这一技巧的编译语言用户而言,这确实是个新鲜事。随后,这项技术被应用于博客和在线包文档。

Go PlayGround

尤其重要的是,我们将这项技术应用于 Go PlayGround——一个免费、开放的沙盒环境,供人们自由尝试和开发代码。

结论

我们已取得长足进步。

回顾过去,我们做对了很多事,这些都助力了 Go 的成功。当然,我们也有许多可以改进之处,正视并吸取这些教训至关重要。无论是成功还是失败,对于任何托管重要开源项目的人来说,都蕴含着宝贵的经验。

我希望通过分享我们的历史经验和其中的教训,能对那些对我们的做法有所保留的人提供一些帮助,也许这也能算作是一种对他们的解释和道歉。

GopherConAU 2023 的吉祥物,由 Renee French 设计
GopherConAU 2023 的吉祥物,由 Renee French 设计

14 年后的今天,我们可以自豪地说,Go 语言的发展已经取得了令人瞩目的成就。

这一切,得益于在设计和发展 Go 语言的过程中做出的一系列明智选择——Go 不仅仅是一种编程语言,更是一种软件开发方式。这些选择带领我们进入了一个创新的领域。

我们之所以能够取得这样的成就,部分归功于:

  • 一个功能强大的标准库,涵盖了服务器编程的大部分基础需求
  • 把并发处理作为语言核心的一部分
  • 倾向于使用组合而不是继承的编程方法
  • 一个清晰的依赖管理的打包系统
  • 集成了快速构建和测试的工具
  • 严格而统一的代码格式化标准
  • 注重代码的可读性,而非炫技
  • 对代码向后兼容的承诺

最重要的,是因为有一个极其热心且多元化的 Gopher 社区的支持。

多元化的社区(由 @tenntenn 绘制)
多元化的社区(由 @tenntenn 绘制)

或许这些措施最有趣的成果是,Go 代码具有高度的一致性,不管是谁编写的,其样式和功能都保持一致,几乎不存在使用不同语言特性的派别,并且随着时间推移,这些代码依然能够持续编译和运行。这在主流编程语言中可能尚属首次。

我们在这方面做得非常对。

谢谢。