Effective Go 精简版
Table of Contents
花了一段时间看完了官方的 Effective Go 文档,今天又把 中文翻译 的过了一遍。
对于 Effective 系列的书,我还是停留在五六年前看过的 Effective C++、More Effective C++ 和 Effective STL,其内容深层次的剖 析了 C++ 语言中的众多核心特性(指针、内存分配、面向对象、泛型编程等),看完之后让人茅塞顿开。
然而带着对 Go 语言的疑惑(指针、内存模型、并发等)来看 Effective Go,并没有得到我想要的东西,对于深层次的东西都是一带而过 的,有些失望,可能是 Go 语言本身并没有 C++ 那么复杂,也可能是我预期太高了一些。
总体来看 Effective Go 比较基础,可作为 Go 入门之后的第二本书(而 Effective C++ 没有一定的 C++ 基础不建议阅读,也看不懂), 更像是一个如何更好些 Go 语言的语言规范。
作为一个 C/C++ 出身的程序员,这篇文章主要整理了在写 Go 程序过程中可能会迷惑的、出问题地方(基于 Effective Go)。
1. 引言
Go 是一门全新的语言,尽管它借鉴了很多已有语言的许多理念,但它有自己的语言特性。虽然你也可以用写 C++ 或者 Java 的方式来写 Go 语言,但程序可能不能令人满意。
所以想要把 Go 程序写好了,需要了解它的特性、风格,即以 Go 的方式来思考 Go,才能写出更好的 Go 程序。
2. 格式化
Go 提供了 gofmt 工具来按照标准来格式化代码,尝试解决同一种语言编码风格混乱的问题,所有人都遵循相同的风格,也不用在编码风 格上再浪费时间。
3. 注释
Go 语言支持 C 风格的注释 /**/
和 //
,块注释一般用于给包做注释。
包注释 一般放于包子句 package xxx
的前面,包含多个包文件的包,包注释放于任意一个文件中即可。
在包中,顶级声明前面的注释称作该声明的*文档注释*,程序中,每个导出的名称(首字母大写)都应该有文档注释。第一句应当以被声 明的东西开头(以便查找文档),并且是一个完整的句子作为摘要。
同样,Go 提供了 godoc 用来提取工程中的文档。
4. 命名
4.1. 包命名
包的名字应该简洁明了,易于理解:
- 使用小写的单个单词来命名,不使用下划线或驼峰记法
- 不需要保证在所有源码中保持唯一(导入时需要使用包的全路径),即便是同一个源文件中出现了两个相同的包名,也可以使用别名的方式解决冲突
- 使用包的内容,一般通过包名来引用(可避免命名冲突)
4.2. Getter、Setter 命名
Go 不在语言层面提供 getter 和 setter 的支持,你可以自己做封装。通常 getter 直接用变量名命名(首字母大写),
而不是 GetXxx
,setter 使用 SetXxx
。
4.3. 接口命名
如果只有一个方法的接口,应该在该方法的名称加上 -er
后缀来命名,比如 Reader、Writer。
4.4. 驼峰命名
Go 使用驼峰的方式来命名,MinxedCaps 或者 mixedCaps。
5. 分号
Go 语言和 C 一样,已分号作为语句的结尾,但与 C 不同的是,分号并不会在源码中出现,Go 的词法分析器会根据规则自动插入分号。
也因为这样代码块 {}
中的前一个 {
放在上一行的结尾(如果放在下一行,Go 会在上一行自动加上分号),还有多值分行初始化时,
最后一行同样要添加一个逗号。
6. 控制结构
- Go 没有 while 循环,只有通用的 for 循环(可以满足 while 的需求)
- if、switch、for 支持一个可选的初始化语句,常见的用法:
if _, err := func(); err != nil
- Go 没有逗号操作符,所以可以使用平行复制的方式
i, j = j, i
++
和--
是语句 而非 表达式
6.1. Switch
Go 中的 switch 比 C 更通用(而且解决 C 的很多潜在问题):
- switch 表达式无需常量或者整数
- case 语句会逐一进行求值直到匹配为止(C 并不是这样,C 是找到一个匹配的入口,如果没遇到 break 则会一直往下执行,不管下面的 case 条件是否满足)
- if-else-if-else 可以使用 switch 替换
- Go 的 break 可以指定一个可选的 Label(类似 C 中的 goto 语句,但是 C 中的 goto 可能会导致资源泄露,所以一般不用)
- switch 配合类型断言语法,可做类型选择
7. 函数
- Go 支持多值返回,通常第二个值表示错误码
- 返回值可添加命名形参,在函数开始执行时初始化为零值,有命名形参时,返回时 return 空即可
defer
语法可以让函数延迟到代码块结束时调用,一般用来释放资源:- 被延迟调用的函数参数是立即计算值,而不是调用时
- Deferred 函数式后入先出的执行顺序(LIFO)
8. 数据
Go 有两种内置的内存分配原语: new
和 make
。
new
跟其它编程语言不同的地方在于它申请的内存不会被 初始化 ,它只是全部设置为 零值 返回的是 T* 译者:文档有点歧义,实际上 Go 的 new 是会初始化内存的,只不过初始化成了对应类型的零值,这里表达的应该是类似 C++ 中的 new 会自动调用构造函数。make(T, args)
和new(T)
有不同的设计目标,make 只用来创建 slices、maps 和 channels,并且返回的是 初始化 的(非零值) 的类型 T(而不是*T),原因是这三种类型在后台实现时必须进行初始化。 比如:切片实际包含三个字段:指向数据的指针、长度和容量,在初始化这些数据之前 slice 是nil
。对于 slices、maps 和 channels,make 会初始化其内部结构数据。
注意 make
只能用于 slices、maps 和 channels 并且返回的是对象,而不是指针。
8.1. 数组
Go 的数组与 C 不同点:
- 数组是值,赋值时会把所有的数据拷贝一份
- 如果把数组传递给函数,函数拿到的是数组的 copy ,而不是指针
- 数组的大小是类型的一部分。
[10]int
和[20]int
是不同的
如果你想要和 C 一样传递数组给函数,你需要使用数组的指针,但通常使用 slice 来代替 array。
8.2. 切片
请查看我之前写的 理解 Go 的 Array 和 slice。
8.3. Map
- 将 map 传递给函数,在函数内部修改 map 会修改调用方的 map
访问一个 key 不存在的值,会返回 value 类型的零值(所以 set 可以使用 value 类型为 bool 来实现,不存在时值为 false); 当需要程序上判断一个值是否存在时,可以通过访问时返回的第二个值来判断,如下:
func offset(tz string) int { if seconds, ok := timeZone[tz]; ok { return seconds } log.Println("unknown time zone:", tz) return 0 }
- 删除 map 中的某个 key 使用内置的
delete
函数,即便 key 已经已经被删掉了,再执行一次也是安全的
8.4. 初始化
- 常量:在 Go 中常量仅仅指的是不变的值。他们由编译期间创建,可以为数字、字符、字符串或者布尔类型,因为编译期间的限制, 表达式必须是常量表达式,由编译器来计算值(函数调用是运行时)
- init 函数:每个源文件都可以定义自己的
init
函数,而且可以有多个。init
函数即不接受参数也不返回任何值,而且不能被 主动调用,在包导入时会自动调用执行,在main
函数之前执行。init
最常见的用法是用来完成初始化表达式未能完成的初始化工作,还可用作状态检查与修复、注册、只被执行一次的运算等
9. 方法
9.1. 指针 vs 值
对于指针和值接收器调用原则为:值接收器关联的函数可以被指针和值调用,而指针方法只能被指针调用。语言为了避免这种错误,就添 加了一个例外,当值是有地址的时候,出现值调用指针方法的时候,语言会自动插入地址运算。
换句话说,都可以相互调用,只不过区别在于是否修改调用方的值。
10. 接口
- Go 中的接口和实现不像其它语言一样,没有显式的关联关系,它只是定义了一个规范,谁有它的 行为 ,谁就是它
- 如果一个类型仅仅实现了一个接口,并且除此之外没有其它需要导出的方法,那这个类型也不需要导出(在这种情况下构造函数返回一 个接口值,而非类型),这其实是一种很好的抽象(关注行为,而非数据)。
11. 空白标识符
- 空白标识符可被赋予或声明为任何类型的任何值,而其值会被无害地丢弃,类似 Unix 中的
/dev/null
文件,只写不读 - 导入包时,别名设置为空白标识符,可解决需要使用导入包的 init,但本文件又不需要包内容的情况(Go 不允许导入不使用的包)
12. 内嵌
Go 语言不提供类型子类这样的东西,但是它提供了在接口或者结构体中内嵌的接口或结构体的方法。
内嵌的接口和结构体可以接口/结构体名称直接访问,如果想要直接访问时字段名为类型名。比如:
type Job struct { Command string *log.Logger }
访问 log.Logger 的成员可以直接通过 Job 的对象来访问,当然也可以用显示调用的方式来访问: job.Logger.Logf
, 访问忽略包名(log)即可。
内嵌引来的问题是名称冲突,解决规则很简单:
- 首先,上层的字段会覆盖到深层的字段
- 其次,如果在相同级别上出现了两个名字相同的字段(这样通常是错误的),但如果内部不使用的情况下,不会出问题
笔者:尽可能用组合代替继承。
13. 并发
13.1. Goroutines
- goroutines 是并发运行在同一地址空间的函数,比线程更轻量级(消耗只有栈空间的分配)
- 在多线程操作系统上实现多路复用,如果一个线程阻塞(比如等待 I/O),就会在其它线程上运行(隐藏了线程创建和管理的复杂性, 本质上底层是线程调度的)
13.2. 管道(Channels)
管道和 maps 类似,使用 make
分配,返回一个底层数据结构的引用。可以提供一个可选的整型参数,用来设置管道的大小。默认值是
0,作为无缓冲或者同步管道。
14. 错误
14.1. Panic
通常情况下,出错的时候应该向调用者返回一个 error
,比如 Read 方法会返回字节数量和 error。但是有时候会遇到错误无法恢复,
程序无法正常运行的情况。
Go 提供了内建函数 panic
用来创建一个运行时的错误将停止程序的运行,它有一个任意类型的参数(经常是个 string 在程序快挂的
时候输出),一般用来表示处理逻辑上不可能发生的事情,比如无限循环竟然退出了:
// A toy implementation of cube root using Newton's method. func CubeRoot(x float64) float64 { z := x/3 // Arbitrary initial value for i := 0; i < 1e6; i++ { prevz := z z -= (z*z*z-x) / (3*z*z) if veryClose(z, prevz) { return z } } // A million iterations has not converged; something is wrong. panic(fmt.Sprintf("CubeRoot(%g) did not converge", x)) }
一般情况下库函数应该避免使用 panic
。如果程序出现了问题,应该尽可能的自愈然后继续运行。
14.2. Recover
当 panic
调用时,包含隐式的运行时错误,比如越界访问,断言失败等,它会立即停止运行然后展开 goroutine 的堆栈,接下来运行
defer 函数。但是可以通过 recover
重新获得 goroutine 的控制并恢复正常运行。代码只能放在 defer 函数中(只有 defer 函数在
这个时候才能正常运行)。
recover 只会关闭当前的 goroutine(干净的退出),而不会影响其它正在执行的 goroutines。