Go 语言的设计哲学

很多编程语言的初学者在学习初期,可能都会遇到这样的问题:最初兴致勃勃地开始学习一门编程语言,学着学着就发现很多 “别扭” 的地方,比如想要的语言特性缺失、语法风格偏避与主流语言差异大、语言的不同版本之间无法兼容、语言的语法特性过多导致学习曲线陡峭、语言的工具链支持较差,等等。

以上的这些问题,本质上都与语言设计者的设计哲学有关。所谓编程语言的设计哲学,就是指决定这门语言演化进程的高级原则和依据。

设计哲学之余编程语言,就好比一个人的价值观之余这个人的行为。如果不认同这个人的价值观,那你就很难与之持续交往下去。同理,如果你不认同一门编程语言的设计哲学,那么大概率你会在后续的语言学习中,就会遇到上面提到的问题,而且可能会让你失去继续学习的精神动力。

因此,在正式学习 Go 语法和编码之前,我们还需要先来了解一下 Go 语言的设计哲学。

设计哲学

Go 语言的设计哲学可以总结为五点:简单、显式、组合、并发和面向工程。

简单

Go 语言的设计者们在语言设计之初,就拒绝走语言特性融合的道路,选择 “做减法” 并致力于打造一门简单的编程语言。

这也就意味着 Go 不会像 C++、Java 那样将其他编程语言的新特性兼蓄并收,所以你在 Go 语言中看不到传统的面向对象的类、构造函数与继承,看不到结构化的异常处理,也看不到本属于函数编程范式的语法元素。

不过,Go 语也没它看起来那么简单,自身实现起来也并不容易,但这些复杂性都被 Go 语言的设计者们 “隐藏” 了,所以 Go 语法层面上呈现这样的状态:

  • 仅有 25 个关键字,主流编程语言很少;
  • 内置垃圾收集,降低开发人员内存管理的心智负担;
  • 首字母大小写决定可见性,无需通过额外关键字修饰;
  • 变量初始为类型零值,避免以随机值作为初值的问题;
  • 内置数组边界检查,极大减少越界访问带来的安全隐患;
  • 内置并发支持,简化并发程序设计;
  • 内置接口类型,为组合的设计哲学奠定基础;
  • 原生提供完善的工具链,开箱即用;
  • … …

我们可以看到 Go 设计者选择的 “简单”,其实是站在巨人肩膀上,去除或优化了以往语言中,已经被开发者证明为体验不好或难以驾驭的语法元素和语言机制,并提出自己的一些创新性的设计。比如,首字母大小写决定可见性、变量初始为类型零值、内置以 go 关键字实现的并发支持等。

简单意味着可以使用更少的代码实现相同的功能,简单意味着代码具有更好的可读性,可读性好的代码通常意味着更好的可维护性以及可靠性。

简单的设计哲学是 Go 生产力的源泉。

显式

首先,我们先来看一段 C 程序,看下 “隐式” 代码的行为特性。

在 C 语言中,下面这段代码可以正常编译并输出正确结果:

#include <stdio.h>

int main() {
    short int a = 5;

    int b = 8;
    long c = 0;
    
    c = a + b;
    printf("%ld\n", c);
}
c

在上面这段代码中,变量 a、b 和 c 的类型均不相同,C 语言编译器在编译 c = a + b 这一行时,会自动将短整型变量 a 和整型变量 b,先转换成 long 类型然后相加,并将所有结果存储在 long 类型变量 c 中。

package main

import "fmt"

func main() {
    var a int16 = 5
    var b int = 8
    var c int64

    c = a + b
    fmt.Printf("%d\n", c)
}
go

我们将上面的 C 程序转换为等价的 Go 代码,当编译程序时,会得到这样的编译器错误:“invalid operation:a + b(mismatched types int16 and in)”。我们能看到 Go 与 C 语言的隐式自动类型转换不同,Go 不允许不同类型的整型变量进行混合计算,它同样也不会对其进行隐式的自动转换。

因此,如果要使这段代码通过编译,我们就需要对变量 a 和 b 进行显式转型,就像下面这段代码:

c = int64(a) + int64(b)
fmt.Printf("%d\n", c)
go

这其实就是 Go 语言显式设计哲学的体现。

在 Go 语言中,不同类型变量是不能在一起进行混合计算的,这是因为 Go 希望开发人员明确知道自己在做什么,你需要以显式的方式通过转型统一参与计算各个变量的类型。

初次之外,Go 设计者所崇尚的显式哲学还直接决定了 Go 语言错误处理的形态:Go 语言采用显式的基于值的错误处理方案,函数/方法中的错误都会通过 return 语句显式地返回,并且通常调用者不能忽略对返回的错误进行处理。

组合

这个设计哲学和我们的各个程序之间的耦合有关,Go 语言不像 C++、Java 等主流面向对象语言,我们在 Go 中见不到经典的面向对象语法元素、类型体系和继承机制,Go 崇尚的是组合的设计哲学。

在 Go 语言设计层面,Go 设计者为开发者们提供正交的语法元素,以供后续组合使用,包括:

  • Go 语言无类型层次体系,各类型之间是相互独立的,没有子类型的概念;
  • 每个类型都可以有自己的方法集合,类型定义与方法实现是正交独立的;
  • 实现某个接口时,无需像 Java 那样采用特定关键字修饰;
  • 包之间是相互独立的,没有子包的概念。

正交指相互独立,不可替代,并且组合起来可实现其它功能。

无论是包、接口还是具体类型定义,GO 语言其实为我们呈现这样的一幅图景:一座座没有关联的 “孤岛”,但每个岛内又很精彩。Go 采用组合的方式在这些孤岛之间建立关联,形成一个整体。

Go 语言为支撑组合的设计提供了类型嵌入(Type Embedding)。通过类型嵌入,我们可以将已实现的功能嵌入到新类型中,以快速满足新类型的功能需求,这种方式有点类似面向对象语言中的 “继承” 机制,但在原理上完全不同,这是一种 Go 设计者们精心设计的 “语法糖”。

被嵌入的类型和新类型两者之间没有任何关系,甚至相互完全不知道对方的存在,没有面向对象语言中的那种父类、子类的关系,以及向上、向下转型(Type Casting)。通过新类型实例调用方法时,方法的匹配主要取决于方法名字,而不是类型。这种组合方式,可以称之为垂直组合,即通过类型嵌入,快速让一个新类型 “复用” 其他类型已经实现的能力,实现功能的垂直扩展。

下面是 Go 标准库中的一段使用类型嵌入组合方式的代码段:

// $GOROOT/src/sync/pool.go
type poolLocal struct {
    private interface{}   
    shared  []interface{}
    Mutex               
    pad     [128]byte  
}
go

在代码段中,我们在 poolLocal 这个结构体类型中嵌入类型 Mutex,这使得 poolLocal 这个类型具有互斥同步的能力,我们可以通过 poolLocal 类型的变量,直接调用 Mutex 类型的方法 Lock 或 Unlock。

另外,我们在标准库中还会看到类似如下定义接口类型的代码段:

// $GOROOT/src/io/io.go
type ReadWriter interface {
    Reader
    Writer
}
go

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

垂直组合本质上是一种 “能力继承”,采用嵌入方式定义的新类型继承嵌入类型的能力。Go 还有一种常见的组合方式,叫水平组合。和垂直组合的能力继承不同,水平组合是一种能力委托(Delegate),我们通常使用接口类型来实现水平组合。

Go 语言中的接口是一个创建设计,它知识方法集合,并且它与实现者之间的关系无需通过显式关键字修饰,它让程序内部各部分之间的耦合降至最低,同时它也是连接程序各个部分之间的 “纽带”。

水平组合的模式有很多,比如一种常见方法就是,通过接受接口类型参数的普通函数进行组合,如以下代码所示:

// $GOROOT/src/io/ioutil/ioutil.go
func ReadAll(r io.Reader)([]byte, error)

// $GOROOT/src/io/io.go
func Copy(dst Writer, src Reader)(written int64, err error)
go

函数 ReadAll 通过 io.Reader 这个接口,将 io.Reader 的实现与 ReadAll 所在的包低耦合地水平组合在一起,从而达到从任意实现 io.Reader 的数据源读取所有数据的目的。类似的水平组合 “模式” 还有点缀器、中间件等。

此外,我们还可以将 Go 语言内置的并发能力进行灵活组合以实现,比如,通过 goroutine + channel 的组合,可以实现类似 Unix Pipe 的能力。

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

并发

“并发” 这个设计哲学的出现也有有它的背景,CPU 都是靠提高主频来改进性能的,但是这个做法已经遇到瓶颈。主频提高导致 CPU 的功耗和发热量剧增,反过来制约 CPU 性能的进一步提高。2007 年开始,处理器厂商的竞争焦点从主频转向了多核。

在这种大背景下,Go 的设计者在决定创建一门新语言的时候,果断将面向多核、原生支持并发作为新语言的设计原则之一。并且,Go 放弃了传统的基于操作系统线程的并发模型,采用用户层轻量级线程,Go 将之称之为 goroutine。

goroutine 占用的资源非常小,Go 运行时默认为每个 goroutine 分配的占空间仅 2 KB。goroutine 调度的切换也不用陷入(trap)操作系统内核层完成,代价很低。因此,一个 Go 程序中可以创建成千上万个并发的 goroutine。而且,所有的 Go 代码都在 goroutine 中执行,哪怕是 go 运行时的代码也不例外。

在提供了开销较低的 goroutine 的同时,Go 还在语言层面上内置了辅助并发设计的原语:channel 和 select。开发者可以通过语言内置的 channel 传递消息或实现同步,并通过 select 实现多路 channel 的并发控制。相较于传统复杂的线程并发模型,Go 与并发的原生支持将大大降低开发人员在开发并发程序时的心智负担。

此外,并发的设计哲学不仅仅让 Go 在语法层面提供了并发原语支持,对 Go 应用程序设计的影响更为重要。并发是一种程序结构设计的方法,使得并行成为可能。

采用并发方案设计的程序在单核处理器也是可以正常运行的,也许在单核上的处理性能可能不如非并发方案。但随着处理器核数的增多,并发方案可以自然地提高处理性能。

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

面向工程

Go 语言设计的初衷,就是面向解决真实世界中 Google 内部大规模软件开发中存在的各种问题,为这些问题提供答案,例如:程序构建慢、依赖管理失控、代码难于理解、跨语言构建难等。

Go语言最初设计阶段就将解决工程问题作为 Go 的设计原则之一,去考虑 Go 语法、工具链与标准库的设计,这也是 Go 与其他偏学院派、偏研究型的编程语言在设计思路上的一个重要差异。

语法是编程语言的用户接口,它直接影响开发人员对这门语言的使用体验。在面向工程设计哲学的驱使下,Go 在语法设计细节上做了精心打磨。

  • 重新设计编译单元和目标文件格式,实现 Go 源码快速构建,使大工程的构建时间缩短到类似动态语言的交互式解释的编译速度;
  • 如果源文件导入它不使用的包,程序将无法编译。这可以充分保证任何 Go 程序的依赖树是精确的,也可以保证构建程序时不会编译额外的代码,从而最大限度地缩短编译时间;
  • 去除包的循环依赖,循环依赖会在大规模的代码中引发问题,因为它们要求编译器同时处理更大的源文件集,这会减慢增量构建;
  • 包路径是唯一的,包名不必唯一。导入路径必须唯一标识要导入的包,名称只是包的使用者如何引用其内容的约定。“包名称不必是唯一的” 这个约定,可以大大降低开发人员给包起唯一名字的心智负担;
  • 不支持默认函数参数。因为在规模工程中,很多开发者利用默认函数参数机制,向函数添加过多的参数以弥补函数 API 的设计缺陷,这会导致函数拥有太多的参数,降低清晰度和可读性;
  • 增加类型别名(type alias),支持大规模代码库重构。

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

除此之外,Go 语言还提供了完善的工具链支持,涵盖了编译构建、代码格式化、包依赖管理、静态代码检查、测试、文档生成与查看、性能剖析、语言服务器、运行时程序跟踪等方方面面。

值得重点介绍的是 gofmt,它可以统一 Go 语言的代码风格,使开发者可以更加专注于业务。同时,相同的代码风格可以让开发者的代码阅读、理解和评审工作变得更加容易。Go 的这种统一代码风格思路也开始影响后续新编程语言的设计,并且一些现有的主流编程语言也在借鉴 Go 的一些设计。

在提供丰富工具链的同时,Go 在标准库中还提供了官方的词法分析器、语法解析器和类型检查器相关包,开发者可以基于这些包快速构建并扩展 Go 工具链。

总结

这篇文章我们了解了 Go 语言的设计哲学:简单、显式、组合、并发和面向工程。

  • 简单是指 Go 语言特性始终保持在少且足够的水平,不走语言特性融合的道路,但又不缺乏生产力。简单是 Go 生产力的源泉,也是 Go 对开发者的最大吸引力;
  • 显式是指任何代码行为都需要开发者明确知晓,不存在因隐式转型导致可维护性降低和不安全的结果;
  • 组合是构建 Go 程序骨架的主要方式,它可以大幅度降低程序元素间的耦合,提高程序的可扩展性和灵活性;
  • 并发是 Go 把握 CPU 向多核方向发展趋势的产物,可以让那个开发人员更容易写出充分利用系统资源、支持性能随 CPU 核数增加而自然提升的应用程序;
  • 面向过程是 Go 语言在语言设计上的一个重大创新,它将语言要解决的问题域扩展到那些原本并不是由编程语言去解决的领域,从而覆盖更多开发者在开发过程中的 “痛点”,为开发者提供更好的使用体验。

技术拓展

关于 “类型别名” 的渐进式代码修复(Gradual code repair)

https://github.com/golang/proposal/blob/master/design/18130-type-alias.md

它也是 Go 面向工程设计哲学的体现,另外 type alias 对基于现有实现进行扩展并做出新的封装方面也有 “奇效”。