Go语言设计哲学

前言

你是一个 Go 语言程序员,在深度学习完相关知识后,你决定去寻找一份相关的工作。但是当面试官询问你为什么会选择学习 Go 语言时,你愣住了。

我们好像从来没有认真的想过自己为什么会选择学习这个编程语言,更不会去思考这个编程语言的设计哲学是什么。对于一部份的“实用主义者”来说,这些问题是毫无意义的,因为在实际的开发过程中压根用不到这些东西,所以一律是扔在一旁。

笔者之前就是这样面对这些“无用”的问题的,直到一次又一次地被面试官问到这些问题,我才开始正式地思考。

接下来不妨跟着我的脚步,来看看我眼中的 Go 语言的设计哲学是什么样的,以此来帮助你也开始思考相关的问题。

追求简单,少即是多

简单是一种伟大的美德,但我们需要更艰苦地努力才能实现它,并需要经过一个教育的过程才能去欣赏和领会它。但糟糕的是:复杂的东西似乎更有市场。—— Edsger Dijkstra,图灵奖得主

当我们问 Gopher“你为什么喜欢 Go 语言”时,我们通常会得到很多答案。

但在我们得到的众多答案中,排名靠前的总是“简单(Simplicity)”。

不同于那些通过相互借鉴而不断增加新特性的主流语言,Go 的设计者在设计之初就拒绝走语言特性融合的道路,而选择了“做减法”,选择了“简单”,他们把复杂性留给了语言自身的设计与实现,留给了 Go 核心开发组自己,而将简单、易用和清晰留给了广大 Gopher。因此,今天呈现在我们眼前的是这样的 Go 语言。


任何设计都存在权衡与折中。Go 设计者选择的“简单”体现在,站在巨人的肩膀上去除或优化在以往语言中已被证明体验不好或难于驾驭的语法元素和语言机制,并提出自己的一些创新型的设计,比如首字母大小写决定可见性,内存分配初始零值,内置以go关键字实现的并发支持等)。

Go 语言设计者提倡推崇“最小方式”思维,即一件事情仅有一种方式或数量尽可能少的方式去完成,这大大减少了开发人员在选择路径方式及理解他人所选择路径方式上的心智负担。

正如 Go 语言之父 Rob Pike 所说:“Go 语言实际上是复杂的,它只是让大家感觉很简单。”这句话背后的深意就是“简单”选择的背后是 Go 语言自身实现层面的复杂性,而这种复杂性被 Go 语言的设计者“隐藏”起来了。比如并发是复杂的,但我们通过一个简单的关键字“go”就可以实现。这种简单其实是 Go 开发团队缜密设计和持续付出的结果。

此外,Go 的简单哲学还体现在 Go 1 兼容性的提出。对于面对工程问题解决的开发人员来说,Go 1 大大降低了工程层面语言版本升级所带来的消耗,让 Go 的工程实践变得格外简单。

从 Go 1.0 发布起至今,Go 1 的兼容性得到很好的保障,当初使用 Go 1.4 编写的代码如今也可以顺利通过最新的Go 1.16 版本的编译并正常运行起来。

正如前面引用的图灵奖得主 Edsger Dijkstra 的名言,这种创新性的简单设计并不是一开始就能得到程序员的理解的,但在真正使用 Go 之后,这种身处设计哲学层面的简单便延伸到 Go 语言编程应用的方方面面,持续影响着Go 语言编程思维。

在 Go 演化进入关键阶段(走向Go 2)的今天,有人向 Go 开发团队提出过这样一个问题: Go 后续演化的最大难点是什么?Go 开发团队的一名核心成员回答道:“最大的难点是如何继续保持Go语言的简单。

偏好组合,正交解耦

当我们有必要采用另一种方式处理数据时,我们应该有一些耦合程序的方式,就像花园里将浇水的软管通过预制的螺丝扣拧入另一段,这也是 Unix IO 采用的方式。—— Douglas McIlroy,Unix 管道的发明者

C++、Java 等主流面向对象(OO)语言通过庞大的自上而下的类型体系、集成、显示接口实现等机制将程序的各个部分耦合器来,但在 Go 语言中我们找不到经典 OO 的语法元素、类型体系和继承机制,或者说 Go 语言本质上就不属于经典 OO 语言范畴。针对这种情况,很多人会问:那 Go 语言是如何将程序的各个部分有机地耦合在一起的呢?就像上面引述的 Douglas McIlroy 那句话中的胶水软管那样,Go 语言尊崇的设计哲学也是组合

在诠释组合之前,我们可以先来了解一下Go在语法元素设计时是如何为组合哲学的应用奠定基础的。

在语言设计层面,Go 提供了正交的语法元素供后续组合使用,包括:

  • Go 语言无类型体系(type hierarchy),类型之间是独立的,没有子类型的概念;
  • 每个类型都可以有自己的方法集合,类型定义与方法实现是正交独立的;
  • 接口(interface)与其实现之间隐式关联;
  • 包(package)之间是相对独立的,没有子包的概念。

我们看到无论是包、接口还是一个个具体的类型定义(包括类型的方法集合),Go 语言为我们呈现了这样一幅图景:一座座没有关联的“孤岛”,但每个岛内又都很精彩。现在摆在面前的工作就是以最适当的方式在这些孤岛之间建立关联(耦合),形成一个整体。Go 采用了组合的方式,也是唯一的方式

Go 语言提供的最为直观的组合的语法元素是类型嵌入(type embedding)。通过类型嵌入,我们可以将已经实现的功能嵌入新类型中,以快速满足新类型的功能需求。这种方式有些类似经典 OO 语言中的继承机制,但在原理上与其完全不同,这是一种 Go 设计者们精心设计的语法糖。被嵌入的类型和新类型之间没有任何关系,甚至相互完全不知道对方的存在,更没有经典 OO 语言中的那种父类、子类的关系以及向上、向下转型(type casting)。在通过新类型实例调用方法时,方法的匹配取决于方法名字,而不是类型。这种组合方式,笔者称之为“垂直组合”,即通过类型嵌入,快速让一个新类型复用其他类型已经实现的能力,实现功能的垂直扩展。

通过在 interface 的定义中嵌入 interface 类型来实现接口行为的聚合,组成大接口,这种方式在标准库中尤为常用,并且已经成为 Go 语言的一种惯用法。

interface 是 Go 语言中真正的“魔法”,是 Go 语言的一个创新设计,它只是方法集合,且与实现者之间的关系是隐式的,他让程序各个部分之间的耦合度降到最低,同时是连接程序各个部分的“纽带”。隐式的 interface 实现会不经意间满足依赖抽象、里氏替换、接口隔离等设计原则,这在其他语言中是需要很刻意的设计谋划才能实现的,但在 Go interface 看来,一切却是自然而然的。

通过 interface 将程序各个部分组合在一起的方法,笔者称之为“水平组合”。水平组合的模式有很多,一种常见的方法是通过接受 interface 类型参数的普通函数进行组合。

此外,Go 语言内置的并发能力也可以通过组合的方式实现对计算能力的串联,比如通过 goroutine+channel 的组合实现类似 Unix Pipe 的能力。

综上,组合原则的应用塑造了 Go 程序的骨架结构。类型嵌入为类型提供垂直扩展能力,interface 是水平组合的关键,它好比程序肌体上的“关节”,给予连接“关节”的两个部分各自“自由活动”的能力,而整体上又实现了某种功能。组合也让遵循简单原则的 Go 语言在表现力上丝毫不逊色于复杂的主流编程语言。

原生并发,轻量高效

并发是有关结构的,而并行是有关执行的。——Rob Pike

将时钟回拨到 2007 年,那时 Go 语言的三位设计者 Rob Pike、Robert Griesemer 和 Ken Thompson 都在 Google 使用 C++ 语言编写服务端代码。当时 C++ 标准委员会正在讨论下一个 C++ 标准(C++0x,也就是后来的 C++11 标准),委员会在标准草案中继续增加大量语言特性的行为让 Go 的三位设计者十分不满,尤其是带有原子类型的新 C++ 内存模型,给本已负担过重的 C++ 类型系统又增加了额外负担。三位设计者认为 C++ 标准委员会在思路上是短视的,因为硬件很可能在未来十年内发生重大变化,将语言与当时的硬件紧密耦合起来是十分不明智的,是没法给开发人员在编写大规模并发程序时带去太多帮助的。

多年来,处理器生产厂商一直遵循着摩尔定律,在提高时钟频率这条跑道上竞争,各行业对计算能力的需求推动了处理器处理能力的提高。CPU 的功耗和节能问题成为人们越来越关注的焦点。CPU 仅靠提高主频来改进性能的做法遇到了瓶颈。主频提高导致 CPU 的功耗和发热量剧增,反过来制约了 CPU 性能的进一步提高。依靠主频的提高已无法实现性能提升,人们开始把研究重点转向把多个执行内核放进一个处理器,让每个内核在较低的频率下工作来降低功耗同时提高性能。2007 年处理器领域已开始进入一个全新的多核时代,处理器厂商的竞争焦点从主频转向了多核,多核设计也为摩尔定律带来新的生命力。与传统的单核 CPU 相比,多核 CPU 带来了更强的并行处理能力、更高的计算密度和更低的时钟频率,并大大减少了散热和功耗。Go 的设计者敏锐地把握了 CPU 向多核方向发展的这一趋势,在决定不再使用 C++ 而去创建一门新语言的时候,果断将面向多核、原生内置并发支持作为新语言的设计原则之一。

Go语言原生支持并发的设计哲学体现在以下几点。

轻量的并发模型

Go 语言采用轻量级协程并发模型,使用 Go 应用在面向多核硬件时更具可拓展性传统编程语言(如 C、C++ 等)的并发实现实际上就是基于操作系统调度的,即程序负责创建线程(一般通过 pthread 等函数库调用实现),操作系统负责调度。这种传统支持并发的方式主要有两大不足:复杂难于扩展

复杂主要体现在以下方面:

  1. 创建容易,退出难:使用 C 语言的开发人员都知道,创建一个线程时(比如利用 pthread 库)虽然参数也不少,但还可以接受。而一旦涉及线程的退出,就要考虑线程是不是分离的(detached)?是否需要父线程去通知并等待子线程退出(join)?是否需要在线程中设置取消点(cancel point)以保证进行 join 操作时能顺利退出?
  2. 并发单元间通信困难,易错:多个线程之间的通信虽然有多种机制可选,但用起来相当复杂;并且一旦涉及共享内存(shared memory),就会用到各种锁(lock),死锁便成为家常便饭。
  3. 线程栈大小的设定:是直接使用默认的,还是设置得大一些或小一些呢?

难于拓展主要体现在以下方面:

  1. 虽然线程的代价比进程小了很多,但我们依然不能大量创建线程,因为不仅每个线程占用的资源不小,操作系统调度切换线程的代价也不小。
  2. 对于很多网络服务程序,由于不能大量创建线程,就要在少量线程里做网络的多路复用,即使用epoll/kqueue/IoCompletionPort 这套机制。即便有了 libeventlibev 这样的第三方库的帮忙,写起这样的程序也是很不容易的,存在大量回调(callback),会给程序员带来不小的心智负担。

为了解决这些问题,Go 果断放弃了传统的基于操作系统线程的并发模型,而采用了用户层轻量级线程或者说是类协程(coroutine),Go 将之称为 goroutine。goroutine 占用的资源非常少,Go 运行时默认为每个 goroutine 分配的栈空间仅 2KB。goroutine 调度的切换也不用陷入(trap)操作系统内核层完成,代价很低。因此,在一个 Go 程序中可以创建成千上万个并发的 goroutine。所有的 Go 代码都在 goroutine 中执行,哪怕是 Go 的运行时代码也不例外。

不过,一个 Go 程序对于操作系统来说只是一个用户层程序。操作系统的眼中只有线程,它甚至不知道goroutine 的存在。goroutine 的调度全靠 Go 自己完成,实现 Go 程序内 goroutine 之间公平地竞争 CPU 资源的任务就落到了 Go 运行时头上。而将这些 goroutine 按照一定算法放到 CPU 上执行的程序就称为 goroutine 调度器(goroutine scheduler)。关于 goroutine 调度的原理,我们将在后面详细说明,这里就不赘述了。

语法和机制

Go 语言为开发者提供的支持并发的语法元素和机制

我们先来看看那些设计并诞生于单核年代的编程语言(如 C、C++、Java)在语法元素和机制层面是如何支持并发的。

  • 执行单元:线程
  • 创建和销毁的方式:调用库函数或调用对象方法
  • 并发线程间的通信:多基于操作系统提供的 IPC机制(进程间通信),比如共享内存、Socket、Pipe 等,当然也会使用有并发保护的全局变量。

与上述传统语言相比,Go 提供了语言层面内置的并发语法元素和机制。

  • 执行单元:goroutine
  • 创建和销毁的方式:go + 函数调用;函数退出即 goroutine 退出
  • 并发线程间的通信:通过语言内置的channel传递消息或实现同步,并通过select实现多路channel的并发控制。

对比来看,Go 对并发的原生支持将大大降低开发人员在开发并发程序时的心智负担。

并发原则

并发原则对 Go 开发者在程序结构设计层面的影响

由于 goroutine 的开销很小(相对线程),Go 官方鼓励大家使用 goroutine 来充分利用多核资源。但并不是有了 goroutine 就一定能充分利用多核资源,或者说即便使用 Go 也不一定能写出好的并发程序。

为此 Rob Pike 曾做过一次关于“并发不是并行”的主题分享,图文并茂地讲解了并发(Concurrency)和并行(Parallelism)的区别。Rob Pike 认为:

  • 并发是有关结构的,它是一种将一个程序分解成多个小片段并且每个小片段都可以独立执行的程序设计方法;并发程序的小片段之间一般存在通信联系并且通过通信相互协作。
  • 并行是有关执行的,它表示同时进行一些计算任务。

采用并发方案设计的程序在单核处理器上也是可以正常运行的(在单核上的处理性能可能不如非并发方案),并且随着处理器核数的增多,并发方案可以自然地提高处理性能,提升吞吐量。而非并发方案在处理器核数提升后,也仅能使用其中的一个核,无法自然扩展,这一切都是程序的结构所决定的。这告诉我们:并发程序的结构设计不要局限于在单核情况下处理能力的高低,而要以在多核情况下充分提升多核利用率、获得性能的自然提升为最终目的。

除此之外,并发与组合的哲学是一脉相承的,并发是一个更大的组合的概念,它在程序设计层面对程序进行拆解组合,再映射到程序执行层面:goroutine 各自执行特定的工作,通过 channel+select 将 goroutine 组合连接起来。并发的存在鼓励程序员在程序设计时进行独立计算的分解,而对并发的原生支持让 Go 语言更适应现代计算环境。

面向工程,自带电池

软件工程指引着 Go 语言的设计。——Rob Pike

要想理解这条设计哲学,我们依然需要回到三位 Go 语言之父在设计 Go 语言时的初衷:面向真实世界中 Google 内部大规模软件开发存在的各种问题,为这些问题提供答案。主要的问题包括:

  • 程序构建慢;
  • 失控的依赖管理;
  • 开发人员使用编程语言的不同子集(比如 C++ 支持多范式,这样有些人用 OO,有些人用泛型);
  • 代码可理解性差(代码可读性差、文档差等);
  • 功能重复实现;
  • 升级更新消耗大;
  • 实现自动化工具难度高;
  • 版本问题;
  • 跨语言构建问题。

很多编程语言的设计者或拥趸认为这些问题并不是编程语言应该解决的,但 Go 语言的设计者并不这么看,他们以更高、更广阔的视角审视软件开发领域尤其是大规模软件开发过程中遇到的各种问题,并在 Go 语言最初设计阶段就将解决工程问题作为 Go 的设计原则之一去考虑 Go 语法、工具链与标准库的设计,这也是 Go 与那些偏学院派、偏研究性编程语言在设计思路上的一个重大差异。

Go 语言取得阶段性成功后,这种思路开始影响后续新编程语言的设计,并且一些现有的主流编程语言也在借鉴 Go 的一些设计,比如越来越多的语言认可统一代码风格的优越之处,并开始提供官方统一的 fmt 工具(如 Rust 的 rustfmt),又如 Go 创新提出的最小版本选择(Minimal Version Selection,MVS)被其他语言的包依赖工具所支持(比如 Rust 的 cargo 支持 MVS)。

Go 设计者将所有工程问题浓缩为一个词:scale(笔者总觉得将 scale 这个词翻译为任何中文词都无法传神地表达其含义,暂译为“规模”吧)。从 Go1 开始,Go 的设计目标就是帮助开发者更容易、更高效地管理两类规模。

  • 生产规模:用 Go 构建的软件系统的并发规模,比如这类系统并发关注点的数量、处理数据的量级、同时并发与之交互的服务的数量等。
  • 开发规模:包括开发团队的代码库的大小,参与开发、相互协作的工程师的人数等。

Go 设计者期望 Go 可以游刃有余地应对生产规模和开发规模变大带来的各种复杂问题。Go 语言的演进方向是优化甚至消除 Go 语言自身面对规模化问题时应对不好的地方,比如:Go 1.9 引入类型别名(type alias)以应对大型代码仓库代码重构,Go 1.11 引入 go module 机制以解决不完善的包依赖问题等。这种设计哲学的落地让 Go 语言具有广泛的规模适应性:既可以被仅有5人的初创团队用于开发终端工具,也能够满足像 Google 这样的巨型公司大规模团队开发大规模网络服务程序的需要。

那么 Go 是如何解决工程领域规模化所带来的问题的呢?我们从语言、标准库和工具链三个方面来看一下。

语言

语法是编程语言的用户接口,它直接影响开发人员对于一门语言的使用体验。Go 语言是一门简单的语言,简单意味着可读性好,容易理解,容易上手,容易修复错误,节省开发者时间,提升开发者间的沟通效率。但作为面向工程的编程语言,光有简单的设计哲学还不够,每个语言设计细节还都要经过“工程规模化”的考验和打磨,需要在细节上进行充分的思考和讨论。

  1. 从工程的安全性和可靠性角度考虑,选择使用大括号代码块结构;
  2. 重新设计编译单元和目标文件格式,实现Go源码快速构建,将大工程的构建时间缩短到接近于动态语言的交互式解释的编译时间;
  3. 不能导入没有使用过的包,否则程序将无法编译。这既可以充分保证Go程序的依赖树是精确的,也可以保证在构建程序时不会编译额外的代码,从而最大限度地缩短编译时间。
  4. 去除包的循环依赖。循环依赖会在大规模的代码中引发问题,因为它们要求编译器同时处理更大的源文件集,这会减慢增量构建速度。
  5. 在处理依赖关系时,有时会通过允许一部分重复代码来避免引入较多依赖关系。
  6. 包路径是唯一的,而包名不必是唯一的。导入路径必须唯一标识要导入的包,而名称只是包的使用者对如何引用其内容的约定。包名不必是唯一的约定大大降低了开发人员给包起唯一名字的心智负担。
  7. 故意不支持默认函数参数。因为在规模工程中会导致函数拥有太多的参数,降低清晰度和可读性。
  8. 首字母大小写定义标识符可见性,这是Go的一个创新。它让开发人员通过名称即可知晓其可见性,而无须回到标识符定义的位置查找并确定其可见性,这提升了开发人员阅读代码的效率。
  9. 在语义层面,相对于C,Go做了很多改动,提升了语言的健壮性,比如去除指针算术,去除隐式类型转换等。
  10. 内置垃圾收集。这对于大型工程项目来说,大大降低了程序员在内存管理方面的负担,程序员使用GC感受到的好处超过了付出的成本,并且这些成本主要由语言实现者来承担。
  11. 内置并发支持,为网络软件带来了简单性,而简单又带来了健壮,这是大型工程软件开发所需要的。增加类型别名,支持大规模代码库的重构。

标准库

Go 被称为“自带电池”(battery-included)的编程语言。“自带电池”原指购买了电子设备后,在包装盒中包含了电池,电子设备可以开箱即用,无须再单独购买电池。如果说一门编程语言“自带电池”,则说明这门语言标准库功能丰富,多数功能无须依赖第三方包或库,Go 语言恰是这类编程语言。

由于诞生年代较晚,且目标较为明确,Go 在标准库中提供了各类高质量且性能优良的功能包,其中的 net/httpcrypto/xxencoding/xx 等包充分迎合了云原生时代关于 API/RPC Web 服务的构建需求。Go 开发者可以直接基于这些包实现满足生产要求的 API 服务,从而减轻对第三方包或库的依赖,降低工程代码依赖管理的复杂性,也降低开发人员学习第三方库的心智负担。

仅使用标准库来构建系统,这对于开发人员是很有吸引力的。在很多关于选用何种 Go Web 开发框架的调查中,选择标准库的依然占大多数,这也是Go社区显著区别于其他编程语言社区的一点。

Go语言目前在 GUI、机器学习(Machine Learning)等开发领域占有的份额较低,这很可能与Go 标准库没有内置这类包有关。

工具链

开发人员在做工程的过程中需要使用工具。而 Go 语言提供了十分全面、贴心的编程语言官方工具链,涵盖了编译、编辑、依赖获取、调试、测试、文档、性能剖析等的方方面面。

  1. 构建和运行:go build/go run
  2. 依赖包查看与获取:go list/go get/go mod xx
  3. 编辑辅助格式化:go fmt/gofmt
  4. 文档查看:go doc/godoc
  5. 单元测试/基准测试/测试覆盖率:go test
  6. 代码静态分析:go vet
  7. 性能剖析与跟踪结果查看:go tool pprof/go tool trace
  8. 升级到新Go版本API的辅助工具:go tool fix
  9. 报告Go语言bug:go bug

值得重点提及的是 gofmt 统一了 Go 语言的编码风格,在其他语言开发者还在为代码风格争论不休的时候,Go 开发者可以更加专注于领域业务。同时,相同的代码风格让以往困扰开发者的代码阅读、理解和评审工作变得容易了很多,至少 Go 开发者再也不会有那种因代码风格的不同而产生的陌生感。

结语

“高内聚、低耦合”是软件开发领域亘古不变的管理复杂性的准则。Go 在语言设计层面也将这一准则发挥到极致。Go 崇尚通过组合的方式将正交的语法元素组织在一起来形成应用程序骨架,接口就是在这一哲学下诞生的语言精华。

不同于 C、C++、Java 等诞生于 20 世纪后段的面向单机的编语言,Go语言是面向未来的。Go 设计者对硬件发展趋势做出了敏锐且准确的判断——多核时代是未来主流趋势,于是将并发作为语言的“一等公民”,提供了内置于语言中的简单并发原语—— go(goroutine)、channel 和 select,大幅降低了开发人员在云计算多核时代编写大规模并发网络服务程序时的心智负担。Go 生来就肩负着解决面向软件工程领域问题的使命,我们看到的开箱即用的标准库、语言自带原生工具链以及开放的工具链生态的建立都是这一使命落地的结果,Go 在面向工程领域的探索也引领着编程语言未来发展的潮流。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值