MENU

[摘录]《Go语言精进之路:从新手到高手的编程思想、方法和技巧1 - 白明》

December 11, 2022 • Read: 60 • 阅读摘录

Go语言精进之路:从新手到高手的编程思想、方法和技巧1
白明
330个笔记

◆ 第一部分 熟知Go语言的一切

golang仅应用于命名Go语言官方网站,当时之所以使用golang.org作为Go语言官方域名,是因为go.com已经被迪士尼公司占用了。

Go语言项目在2009年11月10日正式开源,这一天也被Go官方确定为Go语言诞生日

Go程序员也被昵称为Gopher(后文会直接使用Gopher指代Go语言开发者),Go语言官方技术大会被称为GopherCon。

国内最负盛名的Go技术大会同样以Gopher命名,被称为GopherChina。


图2-1 Go语言的先祖(图片来自《Go程序设计语言》一书

Go的基本语法参考了C语言,Go是“C家族语言”的一个分支;而Go的声明语法、包概念则受到了Pascal、Modula、Oberon的启发;一些并发的思想则来自受到Tony Hoare教授CSP理论[1]影响的编程语言,比如Newsqueak和Limbo。

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

Go语言提供的最为直观的组合的语法元素是类型嵌入(type embedding)。

interface是Go语言中真正的“魔法”,是Go语言的一个创新设计,它只是方法集合,且与实现者之间的关系是隐式的,它让程序各个部分之间的耦合降至最低,同时是连接程序各个部分的“纽带”

综上,组合原则的应用塑造了Go程序的骨架结构。类型嵌入为类型提供垂直扩展能力,interface是水平组合的关键,它好比程序肌体上的“关节”,给予连接“关节”的两个部分各自“自由活动”的能力,而整体上又实现了某种功能

Go的设计者敏锐地把握了CPU向多核方向发展的这一趋势,在决定不再使用C++而去创建一门新语言的时候,果断将面向多核、原生内置并发支持作为新语言的设计原则之一。

操作系统调度器会将系统中的多个线程按照一定算法调度到物理CPU上运行

传统编程语言(如C、C++等)的并发实现实际上就是基于操作系统调度的,即程序负责创建线程(一般通过pthread等函数库调用实现),操作系统负责调度。这种传统支持并发的方式主要有两大不足:复杂和难于扩展。

虽然线程的代价比进程小了很多,但我们依然不能大量创建线程,因为不仅每个线程占用的资源不小,操作系统调度切换线程的代价也不小。

对于很多网络服务程序,由于不能大量创建线程,就要在少量线程里做网络的多路复用,即使用epoll/kqueue/IoCompletionPort这套机制

即便有了libevent、libev这样的第三方库的帮忙,写起这样的程序也是很不容易的,存在大量回调(callback),会给程序员带来不小的心智负担。

goroutine占用的资源非常少,Go运行时默认为每个goroutine分配的栈空间仅2KB。

goroutine调度的切换也不用陷入(trap)操作系统内核层完成,代价很低

将这些goroutine按照一定算法放到CPU上执行的程序就称为goroutine调度器(goroutine scheduler)。

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

Go设计者将所有工程问题浓缩为一个词:scale(笔者总觉得将scale这个词翻译为任何中文词都无法传神地表达其含义,暂译为“规模”吧)。

生产规模:用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语言则是这一哲学在编程语言领域为数不多的践行者。

“少”绝不是目的,“多”才是其内涵

“高内聚、低耦合”是软件开发领域亘古不变的管理复杂性的准则

在人类自然语言学界有一个很著名的假说——“萨丕尔-沃夫假说”,这个假说的内容是这样的:“语言影响或决定人类的思维方式。”

首届图灵奖得主、著名计算机科学家艾伦·佩利(Alan J. Perlis),他从另外一个角度提出:“不能影响到你的编程思维方式的编程语言不值得学习和使用。”

针对这个问题,我们可以采用埃拉托斯特尼素数筛算法。

先用最小的素数2去筛,把2的倍数筛除;下一个未筛除的数就是素数(这里是3)。再用这个素数3去筛,筛除3的倍数……这样不断重复下去,直到筛完为止

Go版本程序实现了一个并发素数筛,它采用的是goroutine的并发组合

程序从素数2开始,依次为每个素数建立一个goroutine,用于作为筛除该素数的倍数

ch指向当前最新输出素数所位于的筛子goroutine的源channel。这段代码来自Rob Pike的一次关于并发的分享

Go版本程序的执行过程可以用图4-2立体地展现出来。

图4-2 Go版本素数筛运作的示意图

C的命令式思维、Haskell的函数式思维和Go的并发思维

编程语言影响编程思维,或者说每种编程语言都有属于自己的原生编程思维

但凡属于某个编程语言的高质量范畴的代码,其必定是在这种编程语言原生思维下编写的代码

以一门编程语言为中心的,以解决工程问题为目标的编程语言用法、辅助库、工具的固定使用方法称为该门编程语言的原生编程思维

[插图]

◆ 第二部分 项目结构、代码风格与标识符命名

截至Go项目commit 1e3ffb0c(2019.5.14),Go项目结构如下:
$ tree -LF 1 ~/go/src/github.com/golang/go
./go
├── api/
├── AUTHORS
├── CONTRIBUTING.md
├── CONTRIBUTORS
├── doc/
├── favicon.ico
├── lib/
├── LICENSE
├── misc/
├── PATENTS
├── README.md
├── robots.txt
├── src/
└── test/

1)代码构建的脚本源文件放在src下面的顶层目录下。

2)src下的二级目录cmd下面存放着Go工具链相关的可执行文件(比如go、gofmt等)的主目录以及它们的main包源文件。

3)src下的二级目录pkg下面存放着上面cmd下各工具链程序依赖的包、Go运行时以及Go标准库的源文件。

Russ Cox在一个开源项目的issue中给出了他关于Go项目结构的最小标准布局[1]的想法。他认为Go项目的最小标准布局应该是这样的:
// 在Go项目仓库根路径下

- go.mod
- LICENSE
- xx.go
- yy.go
...

  • go.mod
- LICENSE
- package1
 - package1.go
- package2
 - package2.go
...


图5-1 Go语言典型项目结构(以构建二进制可执行文件为目的的Go项目)


图5-2 Go语言库项目结构

Go核心团队将这类问题归结为一个词——规模化(scale),这也是近几年比较火热的Go2演进方案将主要解决的问题

Go官方在gofmt中提供了-s选项。通过gofmt -s可以将遗留代码中的非简化代码自动转换为简化写法,并且没有副作用,因此一般“-s”选项都会是gofmt执行的默认选项。

我们可以通过-r命令行选项对代码进行表达式级别的替换,以达到重构的目的。

gofmt -r的原理就是在对源码进行重新格式化之前,搜索源码是否有可以匹配pattern的表达式,如果有,就将所有匹配到的结果替换为replacement表达式。

gofmt提供了-l选项,可以按格式要求输出满足条件的文件列表

goimports在gofmt功能的基础上增加了对包导入列表的维护功能,可根据源码的最新变动自动从导入包列表中增删包。

Go和Vim通过vim-go插件连接在一起

计算机科学中只有两件难事:缓存失效和命名。
——Phil Karlton,Netscape架构师

但简单并不意味着一味地为标识符选择短小的名字,而是要选择那种可以在标识符所在上下文中保持其用途清晰明确的名字

要想做好Go标识符的命名(包括对包的命名),至少要遵循两个原则:简单且一致;利用上下文辅助命名。

对于Go中的包(package),一般建议以小写形式的单个单词命名。

在Go中变量分为包级别的变量和局部变量(函数或方法内的变量)

Go语言官方要求标识符命名采用驼峰命名法(CamelCase)

为变量、类型、函数和方法命名时依然要以简单、短小为首要原则。

• 循环和条件变量多采用单个字母命名(具体见上面的统计数据);
• 函数/方法的参数和返回值变量以单个单词或单个字母为主;
• 由于方法在调用时会绑定类型信息,因此方法的命名以单个单词为主;
• 函数多以多单词的复合词进行命名;
• 类型多以多单词的复合词进行命名。

变量名字中不要带有类型信息

保持变量声明与使用之间的距离越近越好,或者在第一次使用变量之前声明该变量

保持简短命名变量含义上的一致性

常量多使用多单词组合的方式命名

Go语言中的接口是Go在编程语言层面的一个创新,它为Go代码提供了强大的解耦合能力

在Go语言中,对于接口类型优先以单个单词命名。

对于拥有唯一方法(method)或通过多个拥有唯一方法的接口组合而成的接口,Go语言的惯例是用“方法名+er”命名

Go语言推荐尽量定义小接口,并通过接口组合的方式构建程序

Go在给标识符命名时还有着考虑上下文环境的惯例,即在不影响可读性的前提下,兼顾一致性原则,尽可能地用短小的名字命名标识符

◆ 第三部分 声明、类型、语句与控制结构

对于以面向工程著称且以解决规模化问题为目标的Go语言,Gopher在变量声明形式的选择上应尽量保持项目范围内一致。

包级变量(package variable):在package级别可见的变量。如果是导出变量,则该包级变量也可以被视为全局变量

局部变量(local variable):函数或方法体内声明的变量,仅在函数或方法体内可见。

包级变量只能使用带有var关键字的变量声明形式,但在形式细节上仍有一定的灵活度。

我们一般将同一类的变量声明放在一个var块中,将不同类的声明放在不同的var块中;或者将延迟初始化的变量声明放在一个var块,而将声明并显式初始化的变量放在另一个var块中。笔者称之为“声明聚类”。

变量声明最佳实践中还有一条:就近原则,即尽可能在靠近第一次使用变量的位置声明该变量。就近原则实际上是变量的作用域最小化的一种实现手段

如果一个包级变量在包内部被多处使用,那么这个变量还是放在源文件头部声明比较适合。

对于延迟初始化的局部变量声明,采用带有var关键字的声明形式

另一种常见的采用带var关键字声明形式的变量是error类型的变量err(将error类型变量实例命名为err也是Go的一个惯用法),尤其是当defer后接的闭包函数需要使用err判断函数/方法退出状态时

对于声明且显式初始化的局部变量,建议使用短变量声明形式

尽量在分支控制时应用短变量声明形式


图8-1 变量声明形式使用决策流程图

在C语言中,字面值(literal)担负着常量的角色(针对整型值,还可以使用枚举常量)。

为了不让这些魔数(magic number)充斥于源码各处,早期C语言的常用实践是使用宏(macro)定义记号来指代这些字面值

宏定义的常量有着诸多不足,比如:
• 仅是预编译阶段进行替换的字面值,继承了宏替换的复杂性和易错性;
• 是类型不安全的;
• 无法在调试时通过宏名字输出常量的值。

Go语言中的const整合了C语言中宏定义常量、const只读变量和枚举常量三种形式,并消除了每种形式的不足,使得Go常量成为类型安全且对编译器优化友好的语法元素。

绝大多数情况下,Go常量在声明时并不显式指定类型,也就是说使用的是无类型常量(untyped constant)

Go要求,两个类型即便拥有相同的底层类型(underlying type),也仍然是不同的数据类型,不可以被相互比较或混在一个表达式中进行运算:

Go的设计者认为,隐式转换带来的便利性不足以抵消其带来的诸多问题[1]。

Go的const语法提供了“隐式重复前一个非空表达式”的机制

iota是Go语言的一个预定义标识符,它表示的是const声明块(包括单行声明)中每个常量所处位置在块中的偏移值(从零开始)

GOROOT/src/sync/mutex.go (go 1.12.7)
const (
 mutexLocked = 1 << iota
 mutexWoken
 mutexStarving
 mutexWaiterShift = iota
 starvationThresholdNs = 1e6
)

iota预定义标识符能够以更为灵活的形式为枚举常量赋初值

Go的枚举常量不限于整型值,也可以定义浮点型的枚举常量

iota使得维护枚举常量列表更容易

使用有类型枚举常量保证类型安全

保持零值可用。
——Go谚语

虽然一些编译器的较新版本提供了一些命令行参数选项用于对栈上变量进行零值初始化,比如GCC就提供如下命令行选项:

但这并不能改变C语言原生不支持对未显式初始化局部变量进行零值初始化的事实

由于Go中的切片类型具备零值可用的特性,我们可以直接对其进行append操作,而不会出现引用nil的错误

Go标准库的设计者很贴心地将sync.Mutex结构体的零值设计为可用状态,让Mutex的调用者可以省略对Mutex的初始化而直接使用Mutex。

零值可用也有一定的限制,比如:在append场景下,零值可用的切片类型不能通过下标形式操作数据

像map这样的原生类型也没有提供对零值可用的支

另外零值可用的类型要注意尽量避免值复制

有些时候,零值并非最好的选择,我们有必要为变量赋予适当的初值以保证其后续以正确的状态参与业务流程计算,尤其是Go语言中的一些复合类型的变量。

Go语言中的复合类型包括结构体、数组、切片和map

Go提供的复合字面值(composite literal)语法可以作为复合类型变量的初值构造器

复合字面值由两部分组成:一部分是类型,比如上述示例代码中赋值操作符右侧的myStruct、[5]int、[]int和map[int]string;另一部分是由大括号{}包裹的字面值。

如果源码中使用了从另一个包中导入的struct类型,但却未使用field:value形式的初值构造器,则该规则认为这样的复合字面值是脆弱的

不允许将从其他包导入的结构体中的未导出字段作为复合字面值中的field,这会导致编译错误。

与结构体类型不同,数组/切片使用下标(index)作为field:value形式中的field,从而实现数组/切片初始元素值的高级构造形式

但是对于map类型(这一语法糖在Go 1.5版本中才得以引入)而言,当key或value的类型为复合类型时,我们可以省去key或value中的复合字面量中的类型

对于零值不适用的场景,我们要为变量赋予一定的初值

每当你花费大量时间使用某种特定工具时,深入了解它并了解如何高效地使用它是很值得的。

slice,中文多译为切片,是Go语言在数组之上提供的一个重要的抽象数据类型。在Go语言中,对于绝大多数需要使用数组的场合,切片实现了完美替代。并且和数组相比,切片提供了更灵活、更高效的数据序列访问接口。

Go语言数组是一个固定长度的、容纳同构类型元素的连续序列,因此Go数组类型具有两个属性:元素类型和数组长度。这两个属性都相同的数组类型是等价的

Go数组是值语义的,这意味着一个数组变量表示的是整个数组,这点与C语言完全不同。

在Go语言中,更地道的方式是使用切片。
切片之于数组就像是文件描述符之于文件

在Go语言中,数组更多是“退居幕后”,承担的是底层存储空间的角色;而切片则走向“前台”,为底层的存储(数组)打开了一个访问的“窗口”(见图13-1)。

我们可以称切片是数组的“描述符”

//$GOROOT/src/runtime/slice.go
type slice struct {
 array unsafe.Pointer
 len int
 cap int
}

我们可以通过语法u[low: high]创建对已存在数组进行操作的切片,这被称为数组的切片化(slicing)

还可以通过语法s[low: high]基于已有切片创建新的切片,这被称为切片的reslicing

新创建的切片与原切片同样是共享底层数组的,并且通过新切片对数组的修改也会反映到原切片中。

Go切片还支持一个重要的高级特性:动态扩容

这样的append操作有时会给Gopher带来一些困惑,比如通过语法u[low: high]形式进行数组切片化而创建的切片,一旦切片cap触碰到数组的上界,再对切片进行append操作,切片就会和原数组解除绑定

append操作是一件利器,它让切片类型部分满足了“零值可用”的理念

但从append的原理中我们也能看到重新分配底层数组并复制元素的操作代价还是挺大的,尤其是当元素较多的情况下。那么如何减少或避免为过多内存分配和复制付出的代价呢?一种有效的方法是根据切片的使用场景对切片的容量规模进行预估,并在创建新切片时将预估出的切片容量数据以cap参数的形式传递给内置函数make

由结果可知,使用带cap参数创建的切片进行append操作的平均性能(9250ns)是不带cap参数的切片(36 484ns)的4倍左右,并且每操作平均仅需一次内存分配

key的类型应该严格定义了作为“==”和“!=”两个操作符的操作数时的行为

创建map类型变量有两种方式:一种是使用复合字面值,另一种是使用make这个预声明的内置函数。

和切片一样,map也是引用类型,将map类型变量作为函数参数传入不会有很大的性能损耗,并且在函数内部对map变量的修改在函数外部也是可见的

Go语言的一个最佳实践是总是使用“comma ok”惯用法读取map中的值。

即便要删除的数据在map中不存在,delete也不会导致panic

我们看到对同一map做多次遍历,遍历的元素次序并不相同。这是因为Go运行时在初始化map迭代器时对起始位置做了随机处理

千万不要依赖遍历map所得到的元素次序。

如果你需要一个稳定的遍历次序,那么一个比较通用的做法是使用另一种数据结构来按需要的次序保存key,比如切片

Go运行时使用一张哈希表来实现抽象的map类型。运行时实现了map操作的所有功能,包括查找、插入、删除、遍历等

考虑到map可以自动扩容,map中数据元素的value位置可能在这一过程中发生变化,因此Go不允许获取map中value的地址,这个约束是在编译期间就生效的。

如果可能的话,我们最好对map使用规模做出粗略的估算,并使用cap参数对map实例进行初始化

使用cap参数的map实例的平均写性能是不使用cap参数的2倍。

在能预估出最终字符串长度的情况下,使用预初始化的strings.Builder连接构建字符串效率最高

strings.Join连接构建字符串的平均性能最稳定,如果输入的多个字符串是以[]string承载的,那么strings.Join也是不错的选择

使用操作符连接的方式最直观、最自然,在编译器知晓欲连接的字符串个数的情况下,使用此种方式可以得到编译器的优化处理

fmt.Sprintf虽然效率不高,但也不是一无是处,如果是由多种不同类型变量来构建特定格式的字符串,那么这种方式还是最适合的

无论是string转slice还是slice转string,转换都是要付出代价的,这些代价的根源在于string是不可变的,运行时要为转换后的类型分配新内存

Go要求包之间不能存在循环依赖,这样一个包的依赖关系便形成了一张有向无环图。由于无环,包可以被单独编译,也可以并行编译。

和主流静态编译型语言一样,Go程序的构建简单来讲也是由编译(compile)和链接(link)两个阶段组成的

编译器在编译过程中必然要使用的是编译单元(一个包)所依赖的包的源码。

包名与包导入路径中的最后一个目录名不同时,最好用下面的语法将包名显式放入包导入语句。

支持在同一行声明和初始化多个变量(不同类型也可以)

支持在同一行对多个变量进行赋值

表达式的求值顺序在任何一门编程语言中都是比较“难缠的”

在一个Go包内部,包级别变量声明语句的表达式求值顺序是由初始化依赖(initialization dependencies)规则决定的。

在Go包中,包级别变量的初始化按照变量声明的先后顺序进行

如果某个变量(如变量a)的初始化表达式中直接或间接依赖其他变量(如变量b),那么变量a的初始化顺序排在变量b后面。

未初始化的且不含有对应初始化表达式或初始化表达式不依赖任何未初始化变量的变量,我们称之为“ready for initialization”变量。

包级别变量的初始化是逐步进行的,每一步就是按照变量声明顺序找到下一个“ready for initialization”变量并对其进行初始化的过程。反复重复这一步骤,直到没有“ready for initialization”变量为止。

位于同一包内但不同文件中的变量的声明顺序依赖编译器处理文件的顺序:先处理的文件中的变量的声明顺序先于后处理的文件中的所有变量。

先来看switch-case语句中的表达式求值,这类求值属于“惰性求值”范畴。惰性求值指的就是需要进行求值时才会对表达值进行求值,这样做的目的是让计算机少做事,从而降低程序的消耗,对性能提升有一定帮助

select执行开始时,首先所有case表达式都会被按出现的先后顺序求值一遍

有一个例外,位于case等号左边的从channel接收数据的表达式(RecvStmt)不会被求值

如果选择要执行的是一个从channel接收数据的case,那么该case等号左边的表达式在接收前才会被求值

表达式本质上就是一个值,表达式求值顺序影响着程序的计算结果。

包级别变量声明语句中的表达式求值顺序由变量的声明顺序和初始化依赖关系决定,并且包级变量表达式求值顺序优先级最高。

表达式操作数中的函数、方法及channel操作按普通求值顺序,即从左到右的次序进行求值。

赋值语句求值分为两个阶段:先按照普通求值规则对等号左边的下标表达式、指针解引用表达式和等号右边的表达式中的操作数进行求值,然后按从左到右的顺序对变量进行赋值。

重点关注switch-case和select-case语句中的表达式“惰性求值”规则

只有深入了解了Go代码块与作用域规则,才能理解这段代码输出“1 2 3”的真正原因。

代码块是代码执行流流转的基本单元,代码执行流总是从一个代码块跳到另一个代码块

Go语言中有两类代码块,一类是我们在代码中直观可见的由一堆大括号包裹的显式代码块,比如函数的函数体、for循环的循环体、if语句的某个分支等

宇宙(Universe)代码块

包代码块

文件代码块

每个if、for和switch语句均被视为位于其自己的隐式代码块中;
• switch或select语句中的每个子句都被视为一个隐式代码块。

和switch-case无法在case子句中声明变量不同的是,select-case可以在case字句中通过短变量声明定义新变量

为break和continue增加后接label的可选能力;

增加type switch,让类型信息也可以作为分支选择的条件

增加针对channel通信的switch-case语句——select-case

所谓“快乐路径”即成功逻辑的代码执行路径

• 当出现错误时,快速返回;
• 成功逻辑不要嵌入if-else语句中;
• “快乐路径”的执行逻辑在代码布局上始终靠左,这样读者可以一眼看到该函数的正常逻辑流程;
• “快乐路径”的返回值一般在函数最后一行,就像上面伪代码段1中的那样。

小心迭代变量的重用

参与循环的是range表达式的副本

切片在Go内部表示为一个结构体,由(*T, len, cap)三元组组成

表示切片副本的结构体中的*T依旧指向原切片对应的底层数组,因此对切片副本的修改也都会反映到底层数组a上

过for range对于string来说,每次循环的单位是一个rune,而不是一个byte,返回的第一个值为迭代字符码点的第一字节的位置

for range无法保证每次迭代的元素次序是一致的。同时,如果在循环的过程中对map进行修改,那么这样修改的结果是否会影响后续迭代过程也是不确定的

channel在Go运行时内部表示为一个channel描述符的指针(关于channel的内部表示将在后文中详细说明),因此channel的指针副本也指向原channel

当channel作为range表达式类型时,for range最终以阻塞读的方式阻塞在channel表达式上,即便是带缓冲的channel亦是如此:当channel中无数据时,for range也会阻塞在channel上,直到channel关闭。

Go语言规范中明确规定break语句(不接label的情况下)结束执行并跳出的是同一函数内break语句所在的最内层的for、switch或select的执行

在改进后的例子中,我们定义了一个label——loop,该label附在for循环的外面,指代for循环的执行。代码执行到“break loop”时,程序将停止label loop所指代的for循环的执行

带label的continue和break提升了Go语言的表达能力,可以让程序轻松拥有从深层循环中终止外层循环或跳转到外层循环继续执行的能力,使得Gopher无须为类似的逻辑设计复杂的程序结构或使用goto语句

在C语言中,case语句默认都是“fall through”的,于是就出现了每个case必然有break附在结尾的场景

在程序中使用fallthrough关键字前,先想想能否使用更为简洁、清晰的case表达式列表替代

◆ 第四部分 函数与方法

从程序逻辑结构角度来看,包(package)是Go程序逻辑封装的基本单元,每个包都可以理解为一个“自治”的、封装良好的、对外部暴露有限接口的基本单元

在Go包这一基本单元中分布着常量、包级变量、函数、类型和类型方法、接口等

Go语言中有两个特殊的函数:一个是main包中的main函数,它是所有Go可执行程序的入口函数;另一个就是包的init函数。
init函数是一个无参数、无返回值的函数

如果一个包定义了init函数,Go运行时会负责在该包初始化时调用它的init函数。在Go程序中我们不能显式调用init,否则会在编译期间报错

Go运行时不会并发调用init函数,它会等待一个init函数执行完毕并返回后再执行下一个init函数,且每个init函数在整个Go程序生命周期内仅会被执行一次

不要依赖init函数的执行次序。

Go运行时遵循“深度优先”原则

init函数就好比Go包真正投入使用之前的唯一“质检员”,负责对包内部以及暴露到外部的包级数据(主要是包级变量)的初始状态进行检查。在Go运行时和标准库中,我们能发现很多init检查包级变量的初始状态的例子

Go语言以“成为新一代系统级语言”而生,但在演进过程中,逐渐演变成了面向并发、契合现代硬件发展趋势的通用编程语言

Go语言中的方法(method)本质上是函数的一个变种

本质上,我们可以说Go程序就是一组函数的集合

如果一门编程语言对某种语言元素的创建和使用没有限制,我们可以像对待值(value)一样对待这种语法元素,那么我们就称这种语法元素是这门编程语言的“一等公民”

拥有“一等公民”待遇的语法元素可以存储在变量中,可以作为参数传递给函数,可以在函数内部创建并可以作为返回值从函数返回。在动态类型语言中,语言运行时还支持对“一等公民”类型的检查

正如Ward Cunningham对“一等公民”的诠释,Go中的函数可以像普通整型值那样被创建和使用

计算机科学中,柯里化是把接受多个参数的函数变换成接受一个单一参数(原函数的第一个参数)的函数,并返回接受余下的参数和返回结果的新函数的技术

闭包是在函数内部定义的匿名函数,并且允许该匿名函数访问定义它的外部函数的作用域

本质上,闭包是将函数内部和函数外部连接起来的桥梁。

函子需要满足两个条件:
• 函子本身是一个容器类型,以Go语言为例,这个容器可以是切片、map甚至channel;
• 该容器类型需要实现一个方法,该方法接受一个函数类型参数,并在容器的每个元素上应用那个函数,得到一个新函子,原函子容器内部的元素值不受影响。

尽管作为“一等公民”的函数给Go带来了强大的表达能力,但是如果选择了不适合的风格或者为了函数式而进行函数式编程,那么就会出现代码难于理解且代码执行效率不高的情况(CPS需要语言支持尾递归优化,但Go目前并不支持)

defer的第二个重要用途就是拦截panic,并按需要对panic进行处理,可以尝试从panic中恢复(这也是Go语言中唯一的从panic中恢复的手段)

对于自定义的函数或方法,defer可以给予无条件的支持,但是对于有返回值的自定义函数或方法,返回值会在deferred函数被调度执行的时候被自动丢弃

defer关键字后面的表达式是在将deferred函数注册到deferred函数栈的时候进行求值的。

defer让进行资源释放(如文件描述符、锁)的过程变得优雅很多,也不易出错。但在性能敏感的程序中,defer带来的性能负担也是Gopher必须知晓和权衡的

使用defer的函数的执行时间是没有使用defer的函数的7倍左右

在Go 1.14版本中,defer性能提升巨大,已经和不用defer的性能相差很小了

和函数相比,Go语言中的方法在声明形式上仅仅多了一个参数,Go称之为receiver参数。receiver参数是方法与类型之间的纽带

这种直接以类型名T调用方法的表达方式被称为方法表达式(Method Expression)。类型T只能调用T的方法集合(Method Set)中的方法

方法集合决定接口实现

我们首先要识别出自定义类型的方法集合和接口类型的方法集合

对于非接口类型的自定义类型T,其方法集合由所有receiver为T类型的方法组成

而类型T的方法集合则包含所有receiver为T和T类型的方法

Go的设计哲学之一是偏好组合,Go支持用组合的思想来实现一些面向对象领域经典的机制,比如继承。而具体的方式就是利用类型嵌入(type embedding)。

不过在Go 1.14之前的版本中这种方式有一个约束,那就是被嵌入的接口类型的方法集合不能有交集

在结构体类型中嵌入接口类型后,该结构体类型的方法集合中将包含被嵌入接口类型的方法集合

优先选择结构体自身实现的方法。

如果结构体自身并未实现,那么将查找结构体中的嵌入接口类型的方法集合中是否有该方法,如果有,则提升(promoted)为结构体的方法。

如果结构体嵌入了多个接口类型且这些接口类型的方法集合存在交集,那么Go编译器将报错,除非结构体自己实现了交集中的所有方法。

结构体类型在嵌入某接口类型的同时,也实现了这个接口

在结构体类型中嵌入结构体类型为Gopher提供了一种实现“继承”的手段

• T类型的方法集合 = T1的方法集合 + *T2的方法集合;

T类型的方法集合 = T1的方法集合 + *T2的方法集合。

已有的类型(比如上面的I、T)被称为underlying类型,而新类型被称为defined类型。

基于接口类型创建的defined类型与原接口类型的方法集合是一致的

而基于自定义非接口类型创建的defined类型则并没有“继承”原类型的方法集合,新的defined类型的方法集合是空的。

方法集合决定接口实现。基于自定义非接口类型的defined类型的方法集合为空,这决定了即便原类型实现了某些接口,基于其创建的defined类型也没有“继承”这一隐式关联。新defined类型要想实现那些接口,仍需重新实现接口的所有方法。

Go预定义标识符rune、byte就是通过类型别名语法定义的:
// $GOROOT/src/builtin/builtin.go
type byte = uint8
type rune = int32

类型别名与原类型拥有完全相同的方法集合,无论原类型是接口类型还是非接口类型。

虽然string类型变量可以直接赋值给interface{}类型变量,但是[]string类型变量并不能直接赋值给[]interface{}类型变量

Go语言不允许在同一个作用域下定义名字相同但函数原型不同的函数

如果要重载的函数的参数都是相同类型的,仅参数的个数是变化的,那么变长参数函数可以轻松对应;如果参数类型不同且个数可变,那么我们还要结合interface{}类型的特性。

如果参数在传入时有隐式要求的固定顺序(这点由调用者保证),我们还可以利用变长参数函数模拟实现函数的可选参数和默认参数

◆ 第五部分 接口

Go语言推崇面向组合编程,而接口是Go语言中实践组合编程的重要手段。

接口是Go这门静态类型语言中唯一“动静兼备”的语言特性。

接口类型变量具有静态类型

支持在编译阶段的类型检查:当一个接口类型变量被赋值时,编译器会检查右值的类型是否实现了该接口方法集合中的所有方法。

接口类型变量兼具动态类型,即在运行时存储在接口类型变量中的值的真实类型。

接口类型变量在程序运行时可以被赋值为不同的动态类型变量,从而支持运行时多态。

装箱(boxing)是编程语言领域的一个基础概念,一般是指把值类型转换成引用类型

在Go语言中,将任意类型赋值给一个接口类型变量都是装箱操作

了前面对接口类型变量内部表示的了解,我们知道接口类型的装箱实则就是创建一个eface或iface的过程

接口越大,抽象程度越低。
——Rob Pike,Go语言之父

接口就是将对象的行为进行抽象而形成的契约。契约有繁有简,Go选择了去繁就简

契约的自动遵守:Go语言中接口与其实现者之间的关系是隐式的

实现者仅需实现接口方法集中的全部方法,便算是自动遵守了契约,实现了该接口

小契约:契约繁了便束缚了手脚,降低了灵活性,抑制了表现力。Go选择使用小契约,表现在代码上便是尽量定义小接口

接口越小,抽象程度越高,被接纳度越高

计算机程序本身就是对真实世界的抽象与再建构。抽象是对同类事物去除其个别的、次要的方面,抽取其相同的、主要的方面的方法

抽象程度越高,对应的集合空间越大;抽象程度越低(越具象,越接近事物的真实面貌),对应的集合空间越小

接口越小(接口方法少),抽象程度越高,对应的事物集合越大,即被事物接纳的程度越高。而这种情况的极限恰是无方法的空接口interface{},空接口的这个抽象对应的事物集合空间包含了Go语言世界的所有事物。

Go的设计原则推崇通过组合的方式构建程序

小接口更契合Go的组合思想,也更容易发挥出组合的威力

专注于接口是编写强大而灵活的Go代码的关键

越偏向业务层,抽象难度越高

有了接口后,我们就会看到接口被用在代码的各个地方。一段时间后,我们来分析哪些场合使用了接口的哪些方法,是否可以将这些场合使用的接口的方法提取出来放入一个新的小接口中,就像图27-6中的那样。
在图27-6中,大接口1定义了6个方法。一段时间后,我们发现方法1和方法2经常用在场合1中,方法3和方法4经常用在场合2中,方法5和方法6经常用在场合3中。这说明大接口1的方法呈现出一种按业务逻辑自然分组的状态。

空接口不提供任何信息。
——Rob Pike,Go语言之父

与Java的严格约束和编译期检查不同,动态语言走向另一个“极端”:接口的实现者无须做任何显式的接口实现声明,Ruby解释器也不做任何检查

在函数或方法参数中使用空接口类型,意味着你没有为编译器提供关于传入实参数据的任何信息,因此,你将失去静态类型语言类型安全检查的保护屏障,你需要自己检查类似的错误,并且直到运行时才能发现此类错误。

建议广大Gopher尽可能抽象出带有一定行为契约的接口,并将其作为函数参数类型,尽量不要使用可以逃过编译器类型安全检查的空接口类型(interface{})。

仅在处理未知类型数据时使用空接口类型;

其他情况下,尽可能将你需要的行为抽象成带有方法的接口,并使用这样的非空接口类型作为函数或方法的参数

如果说C++和Java是关于类型层次结构和类型分类的语言,那么Go则是关于组合的语言。
——Rob Pike,Go语言之父

“偏好组合,正交解耦”是Go语言的重要设计哲学之一。

正交性为“组合”哲学的落地提供了前提,而组合就像本条开头引用的Rob Pike的观点那样,是Go程序内各组件间的主要耦合方式,也是搭建Go程序静态结构的主要方式。

组合方式莫过于以下3种。
(1)通过嵌入接口构建接口

(2)通过嵌入接口构建结构体

(3)通过嵌入结构体构建新结构体

而通过接口进行水平组合的一种常见模式是使用接受接口类型参数的函数或方法。


图29-1 以接口为连接点的水平组合的基本形式

包裹函数(wrapper function)的形式是这样的:它接受接口类型参数,并返回与其参数类型相同的返回值

通过包裹函数可以实现对输入数据的过滤、装饰、变换等操作,并将结果再次返回给调用者。

由于包裹函数的返回值类型与参数类型相同,因此我们可以将多个接受同一接口类型参数的包裹函数组合成一条链来调用

适配器函数类型(adapter function type)是一个辅助水平组合实现的“工具”类型

它可以将一个满足特定函数签名的普通函数显式转换成自身类型的实例,转换后的实例同时也是某个单方法接口类型的实现者

在上述例子中通过http.HandlerFunc这个适配器函数类型,可以将普通函数greetings快速转换为实现了http.Handler接口的类型。转换后,我们便可以将其实例用作实参,实现基于接口的组合了

中间件就是包裹函数和适配器函数类型结合的产物

所谓中间件(如logHandler、authHandler)本质上就是一个包裹函数(支持链式调用),但其内部利用了适配器函数类型(http.HandlerFunc)将一个普通函数(如例子中的几个匿名函数)转换为实现了http.Handler的类型的实例,并将其作为返回值返回。

单元测试是自包含和自运行的,运行时一般不会依赖外部资源(如外部数据库、外部邮件服务器等),并具备跨环境的可重复性(比如:既可以在开发人员的本地运行,也可以在持续集成环境中运行)。

接口本是契约,天然具有降低耦合的作用

◆ 第六部分 并发编程

并发不是并行,并发关乎结构,并行关乎执行。
——Rob Pike,Go语言之父

goroutine相比传统操作系统线程而言具有如下优势。
1)资源占用小,每个goroutine的初始栈大小仅为2KB。
// $GOROOT/src/runtime/stack.go
const (
 ...
 // Go代码使用的最小栈空间大小
 _StackMin = 2048
)

2)由Go运行时而不是操作系统调度,goroutine上下文切换代价较小。
3)语言原生支持:goroutine由go关键字接函数或方法创建,函数或方法返回即表示goroutine退出,开发体验更佳。
4)语言内置channel作为goroutine间通信原语,为并发设计提供强大支撑。

一条很显然的改进思路是让这些环节“同时”运行起来,就像流水线一样,这就是并发(见图31-5)。

并发在程序的设计和实现阶段,并行在程序的执行阶段。

发现了G-M模型的不足后,Dmitry Vyukov亲自操刀改进了goroutine调度器,在Go 1.1版本中实现了G-P-M调度模型和work stealing算法[1],这个模型一直沿用至今,

计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决

不要通过共享内存来通信,而应该通过通信来共享内存。
——Rob Pike,Go语言之父

Go始终推荐以CSP模型风格构建并发程序

• goroutine:对应CSP模型中的P,封装了数据的处理逻辑,是Go运行时调度的基本执行单元。
• channel:对应CSP模型中的输入/输出原语,用于goroutine之间的通信和同步。
• select:用于应对多路输入/输出,可以让goroutine同时协调处理多个channel操作。

goroutine的执行函数返回,即意味着goroutine退出

但一些常驻的后台服务程序可能会对goroutine有着优雅退出的要求

可以通过Go语言提供的sync.WaitGroup实现等待多个goroutine退出的模式

Go语言的channel有一个特性是,当使用close函数关闭channel时,所有阻塞到该channel上的goroutine都会得到通知

在上述代码里,将每次的left(剩余时间)传入下一个要执行的goroutine的Shutdown方法中。select同样使用这个left作为timeout的值(通过timer.Reset重新设置timer定时器周期)。对照ConcurrentShutdown,SequentialShutdown更简单,这里就不详细介绍了。

  1. 管道模式
    很多Go初学者在初次看到Go提供的并发原语channel时,很容易联想到Unix/Linux平台上的管道机制。下面就是一条利用管道机制过滤出当前路径下以".go"结尾的文件列表的命令:
    $ls -l|grep ".go"
    Unix/Linux的管道机制就是将前面程序的输出数据作为输入数据传递给后面的程序,比如:上面的命令就是将ls -l的结果数据通过管道传递给grep程序。
    管道是Unix/Linux上一种典型的并发程序设计模式,也是Unix崇尚“组合”设计哲学的具体体现。Go中没有定义管道,但是具有深厚Unix文化背景的Go语言缔造者们显然借鉴了Unix的设计哲学,在Go中引入了channel这种并发原语,而channel原语使构建管道并发模式变得容易且自然,如图33-3所示。

case y, ok := <-c2: // 从channel c2接收数据,并根据ok值判断c2是否已经关闭

在上面的例子中,main goroutine创建了一组5个worker goroutine,这些goroutine启动后会阻塞在名为groupSignal的无缓冲channel上。main goroutine通过close(groupSignal)向所有worker goroutine广播“开始工作”的信号

func Increase() int {
 cter.Lock()
 defer cter.Unlock()
 cter.i++
 return cter.i
}

func main() {
 for i := 0; i < 10; i++ {
 go func(i int) {
 v := Increase()
 fmt.Printf("goroutine-%d: current counter value is %d\n", i, v)
 }(i)
 }
 time.Sleep(5 * time.Second)
}
下面是使用无缓冲channel替代锁后的实现:
// chapter6/sources/go-channel-case-6.go
type counter struct {
 c chan int
 i int
}

var cter counter

func InitCounter() {
 cter = counter{
 c: make(chan int),

如果s是chan T类型,那么len(s)针对channel的类型不同,有如下两种语义:
◦ 当s为无缓冲channel时,len(s)总是返回0;
◦ 当s为带缓冲channel时,len(s)返回当前channel s中尚未被读取的元素个数。

select语句的default分支的语义是在其他分支均因通信未就绪而无法被选择的时候执行,这就为default分支赋予了一种“避免阻塞”的特性

面向CSP并发模型的channel原语和面向传统共享内存并发模型的sync包提供的原语已经足以满足Go语言应用并发设计中99.9%的并发同步需求了,而剩余那0.1%的需求,可以使用Go标准库提供的atomic包来实现。

原子操作由底层硬件直接提供支持,是一种硬件实现的指令级“事务”

atomic包更适合一些对性能十分敏感、并发量较大且读多写少的场合。

◆ 第七部分 错误处理

C++之父Bjarne Stroustrup曾说过:“世界上有两类编程语言,一类是总被人抱怨和诟病的,而另一类是无人使用的。”

Go语言设计者们选择了C语言家族的经典错误机制:错误就是值,而错误处理就是基于值比较后的决策

错误是值,只是以error接口变量的形式统一呈现(按惯例,函数或方法通常将error类型返回值放在返回值列表的末尾)

Go 1.13及后续版本中,当我们在格式化字符串中使用%w时,fmt.Errorf返回的错误值的底层类型为fmt.wrapError

与errorString相比,wrapError多实现了Unwrap方法,这使得被wrapError类型包装的错误值在包装错误链中被检视(inspect)到

标准库中的net包就定义了一种携带额外错误上下文的错误类型

代码所在栈帧越低(越接近于main函数栈帧),if err != nil就越不常见;反之,代码在栈中的位置越高(更接近于网络I/O操作或操作系统API调用),if err != nil就越常见

panic和recover让函数调用的性能降低了约90%

Go提供了panic专门用于处理异常,而我们建议不要使用panic进行正常的错误处理

  1. 充当断言角色,提示潜在bug

针对每个连接,http包都会启动一个单独的goroutine运行用户传入的handler函数

39.3 理解panic的输出信息
由前面的描述可以知道,在Go标准库中,大多数panic是充当类似断言的作用的。每次因panic导致程序崩溃后,程序都会输出大量信息,这些信息可以辅助程序员快速定位bug。那么如何理解这些信息呢?这里我们通过一个真实发生的例子中输出的panic信息来说明一下。
下面是某程序发生panic时真实输出的异常信息摘录:
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x8ca449]

goroutine 266900 [running]:
pkg.tonybai.com/smspush/vendor/github.com/bigwhite/gocmpp.(*Client).Connect(0xc42040c7f0, 0xc4203d29c0, 0x11, 0xc420423256, 0x6, 0xc420423260, 0x8, 0x37e11d600, 0x0, 0x0)
 /root/.go/src/pkg.tonybai.com/smspush/vendor/github.com/bigwhite/gocmpp/client.go:79 +0x239
pkg.tonybai.com/smspush/pkg/pushd/pusher.cmpp2Login(0xc4203d29c0, 0x11, 0xc420423256, 0x6, 0xc420423260, 0x8, 0x37e11d600, 0xc4203d29c0, 0x11, 0x73)
 /root/.go/src/pkg.tonybai.com/smspush/pkg/pushd/pusher/cmpp2_handler.go:25 +0x9a
pkg.tonybai.com/smspush/pkg/pushd/pusher.newCMPP2Loop(0xc42071f800, 0x4, 0xaaecd8)
 /root/.go/src/pkg.tonybai.com/smspush/pkg/pushd/pusher/cmpp2_handler.go:65 +0x226

Last Modified: December 17, 2022
Archives QR Code
QR Code for this page
Tipping QR Code