0%

《Go 语言精进之路》读书笔记(01):熟知 Go 语言的一切

Go 语言入门简单,但是精通不简单。对于编程新手而言,难的并不是掌握这些语法和标准库,而是建立一种思维方式。在真实世界中编写代码解决的不再是一个个简单的问题,而是随着需求不断膨胀的复杂问题。

真正热爱编程的程序员,会把编程当做一门手艺不断打磨。虽然他有着诸如架构师之类的头衔,但骨子里他依然是个不断精进的程序员。

背景

Go 是 Google 三位大师级人物 Robert Griesemer,Rob Pike 及 Ken Thompson 共同设计的一种静态类型、编译型编程语言。经过十余年演进和发展,Go 如今已经成为主流云原生编程语言,很多云原生时代的杀手级平台、中间件、协议和应用都是采用 Go 语言开发,例如 Docker、k8s 等。

如何才能像 Go 开发团队那样写出符合 Go 思维和语言惯例的高质量代码呢?

  • 思维层面:写出高质量 Go 代码的前提是思维方式的进阶,即用 Go 语言思维写 Go 代码
  • 实践技巧层面:Go 标准库和优秀 Go 开源库是挖掘符合 Go 惯用法的高质量 Go 代码的宝库,对其进行阅读、整理和归纳,可以得到一些帮助我们快速进阶的有效实践

只有真正领悟一门编程语言的设计哲学和编程思维,并将其应用到日常编程当中,才算真正精通了这门编程语言。

了解 Go 语言的诞生与演进

Go 语言的诞生

Go 语言诞生于 Google,当时其内部主要使用 C++ 构建各种系统,但是 C++ 复杂性高,编译速度慢,在编写服务端程序时不便支持并发,这使得 Robert Griesemer,Rob Pike 及 Ken Thompson 三位大佬产生了设计一门新的编程语言的想法。按照他们的初步设想,这门新的语言应该是能够给程序员带来快乐、匹配未来硬件发展趋势并适合用来开发 google 内部大规模程序。其主要思路是,在 C 语言基础上,修正一些明显的缺陷,删除一些被诟病较多的特性,增加一些缺失的功能

这门新的编程语言被命名为 go,很多 Go 初学者经常将这门语言称为 golang,其实这是不对的。golang 仅用于命名 Go 语言官方网站。

Go 语言的早期团队和演进历程

2008 年初,Unix 之父 Ken Thompson 实现了第一版 Go 编译器,用于验证之前的设计。这个编译器先将 Go 代码转换为 C 代码,再有 C 编译器编译成二进制文件。

2008 年中,Ian Lance 为 Go 语言实现了一个 GCC 前端,这也是 Go 源语言的第二个编译器。Go 语言的第二个实现对于确定语言规范和标准库是至关重要的,之后,Ian Lance 也以第四位成员的身份正式加入 Go 语言开发团队,并在后面的 Go 语言发展进程中成为 Go 语言及工具设计和实现的核心开发人员。

Russ Cox 是 Go 核心开发团队的第 5 位成员,他的一些天赋随即在 Go 语言设计和实现中展现出来,例如他奠定了 Go 语言 I/O 结构模型的 io.Reader 和 io.Writer 接口。在 Ken Thompson 和 Rob Pike 先后淡出 Go 语言核心决策层后,Russ Cox 正式接过两位大佬的衣钵,成为 Go 核心技术团队的负责人。

Go 语言正式发布并开源

Go 语言于 2009 年 11.10 正式开源,这一天也被 Go 官方确定为 Go 语言的诞生日。在发布的当年,Go 就成为 TIOBE 编程语言排行榜的年度最佳编程语言。

Go 开源后,诞生了众多杀手级示范项目,例如容器引擎 Docker、云原生事实标准平台 Kubernetes、服务网格 Istio 等等,这些项目也让 Go 被誉为 云计算基础设施编程语言

Go 也有一些自己的文化:

  • Go 有自己的吉祥物:一只地鼠
  • Go 程序员也被称为 Gopher
  • Go 官方技术大会被称为 GopherCon

选择适当的 Go 语言版本

Go 语言的先祖

和绝大多数编程语言类似,Go 语言也是站在巨人的肩膀上,Go 继承了许多编程语言的特性。

  • Go 的基本语法参考了 C 语言,Go 是 C 家族语言 的一个分支
  • Go 的声明语法、包概念则受到 Pascal、Modula、Oberon 的启发
  • 一些并发编程思想则受到 CSP 理论影响

Go 语言的版本发布历史

  • 2009.11.10,Go 语言正式对外发布并开源
  • 2012.3.38,Go 正式发布。同时 Go 官方发布了 Go1 兼容性 承诺:只要符合 Go1 语言规范的源代码,Go 编译器将保证向后兼容。即使用新版本编译器可以正确编译使用老版本语法编写的代码
  • 2013.12.1 Go1.2 发布,从此 Go 开发组启动了以每 6 个月为一个发布周期的发布计划
  • 2015.8.19 Go1.5 发布,它是 Go 语言历史上的一个具有里程碑意义的重要版本。因为从这个版本开始,Go 实现了自举,即无需再依赖 C 编译器。Go 编译器和运行时全部采用 Go 重写
  • 2018.8.25 Go1.11 发布,它引入了新的 Go 包管理机制:Go module
  • 2021.2.18 Go1.16 发布,支持 Apple M1 芯片(通过 darwin/arm64 组合);GO111MODULE 值默认为 on

Go 语言的版本选择建议

Go 团队已经将版本发布节奏稳定在每年发布两个大版本上,一般是 2 月和 8 月。Go 团队承诺对最新的两个 Go 稳定大版本提供支持。Go 开发团队一直建议大家使用最新的发布版本。

Go 的版本选择策略可以有以下几种,大家可以根据实际情况选择最适合自己的策略:

  • 总是将 Go 编译器版本升级到最新版本
  • 使用两个发布周期之前的版本
  • 使用最新版本之前的那个版本

理解 Go 语言的设计哲学

Go 语言的魅力来自 Go 语言的设计哲学,理解这些设计哲学对形成 Go 原生编程思维、编写高质量 Go 代码起到积极作用。

追求简单,少即是多

不同于那些通过相互借鉴而不断增加新特性的主流编程语言(如 C++、Java 等),Go 的设计者在语言设计之初就 拒绝走语言特性融合的道路,选择了 做减法、选择了 简单。他们把复杂性留给了语言自身的设计和实现,留给了 Go 核心开发组自己,而将简单、易用和清晰留给了 Gopher:

  • 简洁常规的语法,极少数关键字
  • 内置垃圾收集,降低开发人员内存管理的心智负担
  • 没有头文件
  • 显式依赖(package)
  • 没有循环依赖(package)
  • 常量只是数字
  • 首字母大小决定可见性
  • 任何类型都可以拥有方法(没有类)
  • 没有子类型继承(没有子类)
  • 没有算术转换
  • 接口是隐式的(无需 implements 声明)
  • 方法就是函数
  • 接口只是方法集合(没有数据)
  • 方法仅按照名称匹配(不是按照类型)
  • 没有构造函数、析构函数
  • n++、n– 是语句,而不是表达式
  • 没有 ++n 和 –n
  • 赋值不是表达式
  • 在赋值和函数调用中定义的求值顺序(无序列概念)
  • 没有指针算术
  • 内存总是初始化为零值
  • 没有类型注解语法(例如 C++ 中的 static、const 等)
  • 没有异常
  • 内置字符串、slice、map 等类型
  • 内置数组边界检查
  • 内置并发支持

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

Go 的简单哲学还体现在 Go 1 兼容性的提出。Go1 定义了两件事情:

  • 语言的规范
  • 一组核心 API 的规范,即 Go 标准库的标准包

兼容性体现在源码级别,版本之间无法保证已编译软件包的二进制兼容性,Go 语言新版本发布之后,源代码需要使用新版本 Go 重新编译和链接。

偏好组合,正交解耦

Go 语言中找不到经典 OO 的语法元素、类型体系和继承机制,或者说 Go 语言本质上就不属于经典 OO 语言范畴。Go 语言遵从的设计哲学是组合。

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

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

Go 通过 组合 这种唯一的方式来将各个元素关联起来。Go 语言提供的最为直观的组合语法元素是类型嵌入。通过类型嵌入可是将已经实现的功能嵌入到新类型中,以快速满足新类型的功能需求。被嵌入的类型与新类型之间没有任何关系,甚至完全不知道对方的存在。在通过新类型实例调用方法时,方法的匹配取决于方法的名字,而不是类型。通过类型嵌入,快速让一个新类型复用其他类型已经实现的能力,实现功能的垂直扩展。

如下都是类型嵌入的例子:

1
2
3
4
5
6
7
8
9
10
11
type poolLocal struct {
private interface {}
shared []interface{}
Mutex
pad [128]byte
}

type ReadWriter interface {
Reader
Writer
}

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

interface 是方法的集合,且与实现者之间的关系是隐式的,它让程序各个部分之间的耦合降到最低,同时是连接程序各个部分的纽带。隐式的 interface 实现满足了 依赖抽象里氏替换接口隔离 等设计原则。

组合原则的应用塑造了 Go 程序的骨架结构。类型嵌入为类型提供垂直扩展能力,interface 是水平组合的关键。组合也让遵循简单原则的 Go 语言在表现力上丝毫不逊于复杂的主流编程语言

原生并发,轻量高效

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

Go 语言支持轻量级协程模型,使得 Go 应用在面向多核硬件时更具扩展性。传统编程语言的并发实际上就是基于操作系统调度的,即程序负责创建线程(pthread 函数库),操作系统负责调度。虽然线程的代价比进程小,但是每个线程占用的资源仍然不少,操作系统调度切换线程的代价也不小。由于不能大量创建线程,就需要在少量线程里做网络的多路复用,即通过 epoll 等机制,这样存在大量回调,给程序员带来心智负担。

Go 放弃了基于传统操作系统线程的并发模型,而使用了用户层轻量级线程或者说是类协程(corouine)模型,go 称为 goroutine,它的特点是:

  • 占用资源少,go 运行时默认为每个 goroutine 分配的栈空间仅为 2kb
  • goroutine 调度的切换也不用陷入操作系统内核层完成
  • goroutine 的调度完全靠 Go 自己完成,将这些 goroutine 按照一定算法放到 CPU 上执行的程序就称为 goroutine 调度器
  • 一个 Go 程序可以创建成千上万个 goroutine。

Go 语言提供了 goroutine、channel 等语法元素和机制,从而为开发者提供原生的并发支持。

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

并发程序的结构设计不要局限于在单核情况下处理能力的高低,而要在以多核情况下充分提升多核利用率、获得性能的自然提升为最终目的。

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

面向工程,自带电池

Go 语言最初设计阶段就将解决工程问题作为 Go 的设计原则之一去考虑 Go 语法、工具链与标准库设计。Go 的设计目标就是帮助开发者更容易、更高效地管理两类规模:

  • 生产规模:用 Go 构建的软件系统的并发规模
  • 开发规模:开发团队、代码库的规模

从语言层面,Go 是一门简单的语言,这意味着可读性好,容易理解,容易上手,容易修复错误。除此之外,每个语言设计细节都还要经过 工程规模化 的考验和打磨,需要在细节上进行充分思考和讨论,以下是一些举例:

  • 使用大括号来表示程序结构(而不是像 Python 那样使用缩进)
  • 重新设计编译单元和目标文件格式,实现 Go 源码快速构建
  • 如果源文件导入了它不使用的包,则程序将无法编译
  • 去除包的循环依赖
  • 在处理依赖关系时,有时允许一部分重复代码来避免引入较多的依赖关系
  • 包路径是唯一的,而包名不必是唯一的。导入路径必须唯一标识要导入的包,而名称只是包的使用者对如何引用其内容的约定
  • 故意不支持默认参数,因为很多开发者会利用默认参数机制向函数添加过多的参数以弥补函数 API 的设计缺陷,降低函数的可读性
  • 首字母大小写定义标识符可见性
  • 内置垃圾收集机制
  • 内置并发支持
  • 相比于 C,提升了语言的健壮性,例如去除隐式类型转换
  • 增加类型别名,支持大规模代码库的重构

Go 的标准库功能丰富,多数功能无需依赖第三方包或者库,所以 Go 是一门自带电池的编程语言。这也减轻了对第三方包或者库的依赖,降低了工程代码依赖管理的复杂性。除了标准库,golang.org/x 提供了暂未放入标准库的扩展库/补充库供广大 Gopher 使用。

Go 语言提供了十分全面、贴心的编程语言官方工具链,涵盖了编译、编辑、依赖获取、调试、测试、文档、性能剖析等方方面面:

  • 构建和运行: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

而且 Go 构建了一个开放的工具链生态系统,它在标准库中提供了官方的词法分析器、语法分析器等,开发者可以基于这些包快速构建并扩展 Go 工具链。

使用 Go 语言原生编程思维来编写 Go 代码

图灵奖得主 Alan J. Perlis 曾经说过:不能影响到你的编程思维方式的编程语言不值得学习和使用。编程语言影响着编程思维,或者说每种编程语言都有属于自己的原生编程思想。

一门编程语言的编程思维是由语言设计者、语言实现团队、语言社区、语言使用者在长期的演进和实践中形成的统一的思维习惯、行为方式、代码惯用法和风格。我们需要不断学习 Go 语言的原生编程思维,时刻用 Go 编程思维考虑 Go 代码的设计和实现,这是通往高质量 Go 代码的必经之路。

后续我们将从语言、标准库、工具链、工程实践等方面来全面介绍 Go 语言的原生编程思维。