[Go语言系列] 3. Go 的设计哲学

Go语言系列知识快速查看入口

:point_down::point_down::point_down:



本章内容

一个人追求的目标越高,他的才力就发展得越快,对社会就越有益。 ——高尔基

任何一门编程语言都有它的优雅之处,它的优雅之处得益于它的设计哲学,Go语言也不例外。关于Go语言的设计哲学,Go语言之父们以及Go核心团队的开发者们并没有给出明确的官方说法。经过一番查阅相关资料和之前的Go编程经验,把自己对Go语言的设计哲学进行整理、分析和总结,分享在下面。:point_down:

Screen Shot 2021-12-22 at 9.27.25 PM

Go语言的设计哲学之一:简单

简单是一种伟大的美德,但我们需要更艰苦地努力才能实现它,并需要经过一个教育的过程才能去欣赏和领会它。但糟糕的是:复杂的东西似乎更有市场。 - 图灵奖获得者迪杰·斯特拉(1984)

人们一般都喜欢复杂的东西,似乎越复杂的东西越具有挑战性。对于编程语言而言,能吸引程序员眼球的往往是那些通过相互借鉴而不断增加的新特性,但是,Go的设计者们在语言设计之初就选择拒绝走语言特性融合的道路,选择了“做减法”,选择了“简单”。

Go 设计者选择的“简单”体现在站在巨人肩膀上去除或优化了以往语言中已被开发者证明为体验不好或难于驾驭的语法元素和语言机制,并提出了自己的一些创新性的设计(比如:内存分配初始零值、内置以 go 关键字实现的并发支持等)

今天呈现在我们眼前的是这样一门Go语言:

  • 简洁、常规的语法(不需要解析符号表),它仅有 25 个关键字

  • 内置垃圾收集,降低开发人员内存管理的心智负担

  • 没有头文件

  • 显式依赖(package)

  • 没有循环依赖(package)

  • 常量只是数字

  • 任何类型都可以拥有方法(没有类)

  • 没有子类型继承(没有子类)

  • 没有算术转换

  • 接口是隐式的(无需“implements”声明)

  • 方法就是函数

  • 接口只是方法集合(没有数据)

  • 方法仅按名称匹配(不是按类型)

  • 没有构造函数

  • n++和 n–是语句,而不是表达式

  • 没有++n 和–n

  • 赋值不是表达式

  • 在赋值和函数调用中定义的求值顺序(无“序列点”概念)

  • 没有指针算术

  • 内存总是初始化为零值

  • 没有 const 或其他类型注解语法

  • 没有模板/泛型

  • 没有异常(exception)

  • 内置字符串、切片(slice)、字典(map)类型

  • 内置数组边界检查

  • 内置并发支持

  • … …

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

参考链接:02 Go语言的设计哲学之一:简单-慕课专栏

Go语言的设计哲学之二:组合

当我们有必要采用另外一种方式处理数据时,我们应该有一些耦合程序的方式,就像花园里将浇水的软管通过预置的螺丝扣拧入另一段那样,这也是 Unix IO 采用的方式。- 道格·麦克罗伊,Unix 管道的发明者(1964)

C++、Java等主流面向对象语言是通过庞大的、自上而下的类型体系、继承、显示接口实现等机制将程序的各个部分组合起来。

但是在Go语言中找不到经典的面向对象语法元素、类型体系和继承机制。那么,Go语言是如何将程序的各个部分有机地耦合在一起的呢?答案就是Go语言遵从组合的设计哲学。

在诠释组合之前,先来了解一下Go语言在语法元素设计层面上是如何为组合哲学奠定基础的。

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

Go的组合设计哲学可以分为垂直组合水平组合


垂直组合

Go语言的垂直组合通过" 类型嵌入(type embedding)“实现。通过类型嵌入,我们可以将已经实现的功能嵌入到新类型中,以快速满足新类型的功能需求。即通过类型嵌入,快速让一个新类型“复用”其他类型已经实现的能力,实现功能的垂直扩展。”

下面是一个类型嵌入的例子:

// Go标准库:sync/pool.go
type poolLocal struct {
    private interface{}   // Can be used only by the respective P.
    shared  []interface{} // Can be used by any P.
    Mutex                 // Protects shared.
    pad     [128]byte     // Prevents false sharing.
}

我们在 poolLocal 这个 struct 中嵌入类型 Mutex,被嵌入的 Mutex 类型的方法集合会被提升到外面的类型中。比如,这里的 poolLocal 将拥有 Mutex 类型的 Lock 和 Unlock 方法。实际调用时,方法调用实际会被传给 poolLocal 中的 Mutex 实例。


水平组合

Go语言的水平组合interface是关键。

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

水平组合的“模式”很多,比如:一种常见方法就是:通过接受 interface 类型参数的普通函数进行组合,例如下面代码。

func ReadAll(r io.Reader) ([]byte, error)
func Copy(dst Writer, src Reader) (written int64, err error)

ReadAll 通过 io.Reader 这个接口将 io.Reader 的实现与 ReadAll 所在的包低耦合的水平组合在一起了。类似的水平组合“模式”还有 wrapper、middleware 等。



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

参考链接:03 Go 语言的设计哲学之二:组合-慕课专栏

Go语言的设计哲学之三:原生并发

并发(Concurrency)是有关结构的,而并行(Parallelism)是有关执行的 - Rob Pike(2012)

2007 年处理器领域已开始进入一个全新的多核时代,多核 CPU 带来了更强的并行处理能力、更高的计算密度和更低的时钟频率,并大大减少了散热和功耗。Go 的设计者敏锐地把握了 CPU 向多核方向发展的这一趋势,果断将面向多核、原生内置并发支持作为了新语言的设计原则之一。


Go语言的原生并发原则的落地是映射到以下几个层面:

1. Go语言自身实现层面支持面向多核硬件的并发执行和调度

实现核心:goroutine + go runtime


传统的编程语言如C、C++等的并发实际上是基于操作系统调度,即程序负责创建线程,操作系统按照一定的调度算法将多个线程调度到物理CPU上去运行。

这种传统支持并发的方式有诸多不足:

(1)复杂:线程创建容易,退出难;并发单元间通信困难;thread stack size值设定困难。 ​
​(2)难于扩展:不能大量创建线程,因为每个线程占用资源不小,且操作系统调度切换线程的代价也不小。

为了解决以上问题,Go采用了用户层轻量级线程 - “goroutine”。它有以下特点:

(1) goroutine调度的切换不用在操作系统内核层完成,代价很低。
(2) goroutine占用资源非常小,每个goroutine stack的size默认设置是2k。
(3) go程序中可以创建成千上万个并发的goroutine。


将这些goroutines按照一定算法放到CPU上执行的程序成为goroutine调度器或goroutine schedule

不过,一个Go程序对于操作系统来说只是一个用户层程序,对于操作系统而言,它的眼中只有thread,它甚至不知道有什么叫goroutine东西的存在。goroutine的调度全要靠Go自己完成,实现Go程序内gorouinte之间“公平”的竞争“CPU资源”,这个任务就落到了go runtime头上。


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

看一下传统编程语言和Go语言分别在语法元素和机制层面上是如何支持并发的。

传统编程语言 Go语言
执行单元 线程 goroutine
创建和销毁的方式 调用库函数或调用对象方法 go+函数调用;函数退出即 goroutine 退出
并发线程间的通信 多基于操作系统提供的 IPC 机制,比如:共享内存、Socket、Pipe 等,当然也会使用有并发保护的全局变量 通过语言内置的 channel 传递消息或实现同步,并通过 select 实现多路 channel 的并发控制

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

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

Rob Pike 认为:

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

划重点:并发是一种程序结构设计的方法,它使得并行成为可能。


Go语言的设计哲学之四:面向工程

软件工程指引着 Go 语言的设计。- Rob Pike(2012)

在大规模软件开发时,经常遇到以下问题:

  • 程序构建慢
  • 失控的依赖管理
  • 实现自动化工具难度高
  • 版本问题
  • 代码可理解性差(代码可读性差,文档差等)

很多编程语言设计者认为这些问题并不是一门编程语言应该去解决的,但 Go 语言的设计者并不这么看,他们以更高更广阔的视角去审视软件开发领域尤其是大规模软件开发过程中遇到的各种问题,并在 Go 语言最初设计阶段就将解决工程问题作为 Go 的设计原则之一去考虑 Go 语法、标准库与工具的设计。

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

1 语言

  • Go采用大括号代码块结构,并没有采用Python那样的代码缩进来表示程序结构,因为当代码库变得很大的时候,缩进式的结构非常容易出错。

  • 重新设计编译单元和目标文件格式,实现 Go 源码快速构建,让大工程的构建时间缩短到类似 Python 的交互式编译的编译速度。

  • 如果源文件导入它不使用的包,则程序将无法编译。这可以充分保证任何 Go 程序的依赖树是精确的。这也可以保证在构建程序时不会编译额外的代码,从而最大限度地缩短编译时间。

  • 去除包的循环依赖,循环依赖会在大规模的代码中引发问题,因为它们要求编译器同时处理更大的源文件集,这会减慢增量构建。

  • 故意不支持默认函数参数。因为在规模工程中,很多开发者利用默认函数参数机制,向函数添加过多的参数以弥补函数 API 的设计缺陷,这会导致函数拥有太多的参数,降低清晰度和可读性。

  • 首字母大小写定义标识符可见性,这是 Go 的一个创新。它让开发人员通过名称即可知晓其可见性,而无需回到标识符定义的位置查找并确定其可见性,这提升了开发人员阅读代码的效率。

  • 内置垃圾收集,这对于大型工程项目来说,大大降低了程序员在内存管理方面的负担,程序员使用 GC 感受到的好处超过了付出的成本,并且这些成本主要由语言实现者来承担。

  • 内置并发支持,为网络软件带来了简单性,简单又带来了健壮,这是大型工程软件开发所需要的

  • 增加 type alias,支持大规模代码库的重构。

2 标准库

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

仅使用标准库来构建系统,这对于开发人员还是蛮有吸引力的。在很多关于选用何种 Go Web 开发框架的调查中,选择标准库的依然占大多数,这也是 Go 社区显著区别于其他编程语言社区的一点。Go 团队还在 golang.org/x 路径下面提供了暂未放入标准库的扩展库/补充库供广大 Gopher 使用,包括:text、net、crypto 等,这些库的质量也是非常高的,标准库中部分包也将 golang.org/x 下的 text、net 和 crypto 包作为依赖包 vendor 到 Go 标准库中。

3 工具

  • 构建和运行:go build/go run

  • 依赖包查看与获取:go list/go get/go mod xx

  • 编辑辅助格式化:go fmt/gofmt

  • 文档查看:go doc/godoc

  • 单元测试/基准测试/测试覆盖率:go test

  • 代码静态分析:go vet

  • 性能剖析与跟踪结果查看:go tool pprof/go tool trace

  • 升级到新 Go 版本 API 的辅助工具:go tool fix

  • 报告 Go 语言 bug:go bug