《Go语言精进之路——从新手到高手的编程思想、方法和技巧》白明/著
16 分钟阅读
《Go语言精进之路——从新手到高手的编程思想、方法和技巧》白明/著
第一部分 熟知Go语言的一切
第1条 了解Go语言的诞生与演进
Go语言的诞生
Go语言的早期团队和演进历程
Go语言正式发布并开源
第2条 选择适当的Go语言版本
Go语言的先祖
Go语言的版本发布历史
Go语言的版本选择建议
第3条 理解Go语言的设计哲学
追求简单,少即是多
偏好组合,正交解耦
原生并发,轻量高效
面向工程,自带电池
第4条 使用Go语言原生编程思维来写Go代码
语言与思维——来自大师的观点
现实中的投影
Go语言原生编程思维
第二部分 项目结构、代码风格与标识符命名
第5条 使用得到公认且广泛使用的项目结构
Go项目的项目结构
Go语言典型项目结构
第6条 提交前使用gofmt格式化源码
gofmt:Go语言在解决规模化问题上的最佳实践
使用gofmt
使用goimports
将gofmt/goimports与IDE或编辑器工具集成
第7条 使用Go命名惯例对标识符进行命名
简单且一致
利用上下文环境,让最短的名字携带足够的信息
第三部分 声明、类型、语句与控制结构
第8条 使用一致的变量声明形式
包级变量的声明形式
局部变量的声明形式
第9条 使用无类型变量简化代码
Go常量溯源
有类型常量带来的烦恼
无类型常量消除烦恼,简化代码
第10条 使用iota实现枚举常量
第11条 尽量定义零值可用的类型
Go类型的零值
零值可用
第12条 使用复合字面值作为初值构造器
结构体复合字面量
数组/切片复合字面量
map复合字面量
第13条 了解切片实现原理并高效使用
切片究竟是什么
切片的高级特性:动态扩容
尽量使用cap参数创建切片
第14条 了解map实现原理并高效使用
什么是map
map的基本操作
map的内部实现
尽量使用cap参数创建map
第15条 了解string实现原理并高效使用
Go语言的字符串类型
字符串的内部表示
字符串的高效构造
字符串相关的高效转换
第16条 理解Go语言的包导入
Go程序构建过程
究竟是路径名还是包名
包名冲突问题
第17条 理解Go语言表达式的求值顺序
包级别变量声明语句中的表达式
普通求值顺序
赋值语句的求值
switch/select语句中的表达式求值
第18条 理解Go语言代码块与作用域
Go代码块与作用域简介
if条件控制语句的代码块
其他控制语句的代码块规则简介
第19条 了解Go语言控制语句惯用法及使用注意事项
使用if控制语句时应遵循"快乐"
for range 的避坑指南
break跳到哪里去了
尽量用case表达式列表替代fallthrough
第四部分 函数与方法
第20条 在init函数中检查包级变量的初始状态
认识init函数
程序初始化顺序
使用init函数检查包级变量的初始状态
第21条 让自己习惯于函数是一等公民
什么是一等公民
函数作为一等公民的特殊运用
-> 第22条 使用defer让函数更简洁、更健壮
defer的作用:
- 在函数、方法中使用defer,可以减轻开发人员的心智负担。比如,处理待释放的资源;
- 即使函数、方法中发生panic(除了一些运行时致命问题外),defer都可以被执行到。
- 降低函数、方法中的逻辑复杂度,增加程序可读性,进而提高健壮性。
defer的运作机制
只有在函数、方法中才可以使用defer;
defer这一关键字后,也只能接上函数(被称为 deferred函数)、方法(被称为 deferred方法);
deferred函数或方法,【在defer关键字所在的函数或方法退出前】按后进先出(LIFO:Last In First Out)顺序调度执行。
defer的常见用法
- 释放资源(最常见的用法);
- 拦截panic(配合recover()函数一起使用),如:$GOROOT/src/bytes/buffer.go ;
- 修改函数的具名返回值,如:$GOROOT/src/fmt/scan.go;
- 输出调试信息,如:$GOROOT/src/net/conf.go -> hostLookupOrder;
- 还原变量的旧值,如:$GOROOT/src/syscall/fs_nacl.go -> init。
关于defer的几个关键问题
(a)须知哪些函数、方法可以作为deferred函数、方法;
- 若deferred函数、方法,是有返回值的自定义函数、方法,则在defferred函数、方法被调度执行时该返回值被自动丢弃;
- 虽然对于15个内置函数中,close、copy、delete、panic、print、println、recover可以直接接在defer关键字之后,而append、cap、complex、imag、len、make、new、real不可以。但不妨还是直接在defer关键字之后接上一个匿名函数进行包裹处理,减少心智负担。
(b)须知defer关键字后表达式的求值时机;
(c)须知defer会带来性能损耗。
第23条 理解方法的本质以选择正确的receiver类型
方法的本质
选择正确的receiver类型
基于对Go方法本质的理解巧解难题
第24条 方法集合决定接口实现
方法集合
类型嵌入与方法集合
defined类型的方法集合
类型别名的方法集合
第25条 了解变长参数函数的妙用
什么是变长参数函数
模拟函数重载
模拟实现函数的可选参数与默认参数
实现功能选项模式
第五部分 接口
第26条 了解接口类型变量的内部表示
nil error值 != nil
接口类型变量的内部表示
输出类型变量内部表示的详细信息
接口类型的装箱原理
第27条 尽量定义小接口
Go推荐定义小接口
小接口的优势
定义小接口可以遵循的一些点
第28条 尽量避免使用空接口作为函数参数类型
第29条 使用接口作为程序水平组合的连接点
一切皆组合
垂直组合问题
以接口为连接点的水平组合
第30条 使用接口提高代码的可测试性
实现一个附加免责声明的电子邮件发送函数
使用接口来减低耦合
第六部分 并发编程
-> 第31条 优先考虑并发设计
并发(concurrency )不是并行(parallelism),并发关乎结构,并行关乎执行。—— Rob Pike
并发与并行
Go并发设计实例
第32条 了解goroutine的调度原理
goroutine调度器
goroutine调度模型与演进历程
对goroutine调度器原理的进一步理解
调度器状态的查看方法
goroutine调度实例简要分析
第33条 掌握Go并发模型和常见并发模式
Go并发模型
Go常见的并发模式
第34条 了解channel的妙用
无缓冲channel
带缓冲channel
nil channel的妙用
与select结合使用的一些惯用法
第35条 了解sync包的正确用法
sync包还是channel
使用sync包的注意事项
互斥锁还是读写锁
条件变量
使用sync.Once实现单例模式
使用sync.Pool减轻垃圾回收压力
第36条 使用atomic包实现伸缩性更好的并发读取
atomic包对原子操作
对共享整型变量的无锁读写
对共享自定义类型变量的无锁读写
第七部分 错误处理
-> 第37条 了解错误处理的4种策略
从go语言被人所诟病的、似乎有些过时的错误处理机制作为开章语。
进而解释,之所以没有像主流编程语言那样提供基于异常的结构化try-catch-finally错误处理机制,是因为个go的设计者有两点不同的看法:
- 将异常耦合到程序控制结构中会导致代码混乱;
- (更进一步)若提供了try-catch-finally错误处理机制,程序员将会把大多数常见错误(如无法打开文件等)标记为异常,如此则背离了Go所追求的简单价值观。
再抛出,现在Go的错误处理机制:基于错误值比较的错误处理机制。
其借鉴了C语言的经典错误机制:错误就是值。但又因go天生支持多返回值机制(惯用法是将err作为最后一个返回值),避免了C语言的函数返回值需承载多重信息的问题(例如C语言中的 fprintf函数,错误时返回一个负数,正常时返回输出到FILE流中的字符数)。
最后给出这样的错误处理机制的好处:
- 虽开发人员须显示关注和处理每个错误,但这样处理会使代码更健壮;
- 更容易调试代码;
- 可针对每个错误处理的决策分支进行测试覆盖;
- 代码可读性更佳;
构造错误值
error接口是go原生内置的类型,不需要使用import引入即可使用。
go中提供了:error.New()和fmt.Errorf()这两个函数来构造错误值。
go 1.13(不含)之前,error.New()和fmt.Errorf()返回的错误值的类型是errors.errorString这个底层类型。
但在go 1.13及之后,若fmt.Errorf(“格式化字符串”)的格式化字符串中使用一个%w
,则fmt.Errorf()返回的错误值的类型是fmt.wrapError这个底层类型;若使用多个%w
,则fmt.Errorf()返回的错误值的类型是fmt.wrapErrors这个底层类型。请参阅fmt.Errorf()。
另外需要注意:
虽然fmt.wrapErrors和fmt.wrapError在源码中都有实现Unwrap()方法,但fmt.wrapErrors和fmt.wrapError是不可导出的类型,故不能在自己的代码中使用到Unwrap()方法。
虽然我们不能调用到Unwrap()方法,但可以通过errors.Unwrap(err变量)的方式来间接调用到Unwrap() 方法。可惜的是,您会发现只有当err变量是fmt.wrapError类型,errors.Unwrap(err变量)才能间接调用Unwrap()方法的结果;若是err变量是fmt.wrapErrors类型,errors.Unwrap(err变量)输出为nil。具体可参阅errors.Unwrap 。
遗留问题,那fmt.wrapErrors实现的Unwrap() 方法有什么作用?哪里可以用得到?
您会发现,无论是error.New()还是fmt.Errorf()构造的错误值,都只是以字符串形式呈现给错误处理者。
若需要更多数据信息提供给错误处理者,可以通过自定义错误类型(需实现error接口)来满足。
透明错误处理策略
最简单的错误处理策略:不用关心返回错误值携带的上下文信息,只要发生错误直接使用类似if err != nil { ... return err }
的形式进行错误处理。
go语言中**80%**的错误处理情形,都是这种最简单的错误处理策略。
因错误处理方不关心错误值的上下文,且错误构造方直接使用error.New()或fmt.Errorf()来构造错误值,这种策略下构造出来的错误值,对错误处理方是透明的,故也称这种策略是"透明错误处理策略"。
“哨兵"错误处理策略
若不能直接使用透明错误处理策略,则错误处理方可能会对错误构造方给出的错误值进行不同的判断处理,例如使用switch...case
语句。
若直接根据透明错误值的字符串(err.Error()返回的值)进行判断,会带来严重的隐式耦合。
那怎么办?
go标准库中采用定义导出的哨兵错误值的方式,方便错误处理方对错误构造方给出的透明错误值进行判断处理。
哨兵错误值变量以ErrXXX格式命名。对于API的开发者而言,这些哨兵错误值变量,也是API的一部分,需要进行很好的维护。
go 1.13及之后的版本,可以使用errors.Is(err, 某一哨兵错误值变量),来判断错误值。相比于之前使用的if err == 某一哨兵错误值变量 { ... }
,若err是fmt.wrapError
或 fmt.wrapErrors
类型,errors.Is()方法会在错误链上找到第一个匹配的错误,匹配则返回true
。请参阅errors.Is()。这也是目前推荐的。
go源码:$GOROOT/src/bufio/bufio.go 采用了这种策略。
错误值类型检视策略
以上两种错误处理策略(哨兵错误处理策略、最简错误处理策略)都不能提供更多的错误上下文(都只是直接提供了透明错误值,只能从中得到字符串形式的错误描述)。
若错误构造方想提供更多的错误上下文,则需要自定义错误类型(须实现error接口,一般以XXXError形式命名)。如此,错误处理方则需要通过先判断错误类型,再对不同错误类型进行分别处理。
错误处理方可以使用go提供的类型断言机制、类型选择机制来判断和处理不同错误类型。类似这样的错误处理,作者认为是错误值类型检视策略。
go源码:$GOROOT/src/encoding/json/decode_test.go 中使用了类型断言机制来处理错误。
go源码:$GOROOT/src/encoding/json/decode.go 中使用了类型选择机制来处理错误。
因自定义错误类型,需暴露给错误处理方,故类似哨兵错误值变量,都需要对其进行很好的维护。
从go1.13及之后可以使用errors.As(err, &自定义错误类型变量) 函数,进行错误类型判断,以及进一步处理。相比于之前使用的if e, ok := err.(*MyError); ok { ... }
,若err是fmt.wrapError
或 fmt.wrapErrors
类型,errors.As()方法会在错误链上找到第一个匹配的错误,匹配则返回true
。请参阅errors.As()。这也是目前推荐的。
错误行为特性检视策略
什么是错误行为特性检视策略?
可以认为是在自定义错误类型中,定义了需要实现的方法(一般是表示行为的方法,例如是否超时、是否是临时性错误),错误构造方在提供错误值的时候,会对这些实现的方法体中需要用到的一些数据进行设置,如此在当错误处理方,在判断到是这种自定义错误类型时,可以调用该错误类型实现的方法,进而获取到一定的数据(一般是布尔类型值),以此达到错误处理的目的。
go源码:$GOROOT/src/net/server.go 使用了这种策略。
第38条 尽量优化反复出现的if err != nil
因go选择使用显式错误结果和显式错误检查。由此带来可能需要在go代码中反复出现类似if err != nil
的错误检查。
以CopyFile函数中过多的if err != nil
作为开章。
两种观点
观点1:急需改善go错误处理方式,例如引入try。
观点2:go的成功很大程度上要归功于显式的错误处理方式,与其花费成本在故障发生时处理故障,不如花费成本在反复出现的代码片段if err != nil {...}
的优化上。
尽量优化
Russ Cox也认为当前go错误处理机制对于go开发人员来说确实有一定的心智负担。
对于反复出现的if err != nil
还是需要尽可能去优化!
优化思路
可以从两个方向来优化:
- 改善代码中的视觉呈现;
- 降低
if err != nil
重复的次数(即降低函数或方法的复杂度,可采用进一步封装、分层)。