Go 语言为开发者提供了简单的基础语法,这部分将详细介绍 Go 在基础语法层面上有哪些高质量 Go 代码的惯用法和有效实践。
使用一致的变量声明形式
Go 是一门静态类型语言,使用变量之前需要先进行变量的声明。以下都是 Go 常见的变量声明形式:
1 | var i int32 |
Gopher 在变量声明形式的选择上,应该尽量保持项目范围内一致。
包级别变量的声明形式
包级别变量是指在 package 级别可见的变量。如果它以大写字母开头,则它还是导出变量。包级别的变量只能使用带有 var 关键字的变量声明形式。
- 对于在声明变量的同时进行显式初始化的这类包级别的变量,在实践中多采用如下形式
1 | var variableName = InitExpression |
go 编译器能够自动根据等号右侧的 InitExpression 表达式求值的类型确定左侧所声明变量的类型。如果 InitExpression 采用的是不带类型信息的常量表达式,则包级别常量会被设置为常量表达式的默认类型,例如整型常量默认类型是 int,浮点常量默认类型是 float64。
1 | var i = 17 |
如果不接受默认类型,则需要显式指定类型,有两种形式:
1 | var a int32 = 17 |
从声明一致性的角度,Go 官方更推荐后者,因为这样就统一了接受默认类型和显式指定类型两种声明形式。尤其是将这些变量放在一个 var 块中声明时:
1 | var ( |
而不是这种形式:
1 | var ( |
- 对于声明时并不显式初始化的包级别变量,使用最基本的形式
1 | var a int32 |
虽然没有显式初始化,但是 Go 语言会让这些变量拥有初始的 零值
。
Go 提供的 var 块用于将多个变量声明语句放在一起。虽然语法上不限制 var 块中变量的类型,但是一般将同一类的变量放在一个 var 块中,将不同类的声明放在不同的 var 块中;或者将延迟初始化的变量放在一个 var 块中,而将声明并显式初始化的变量放在另一个 var 块中。这可以称为 `声明聚类
使用静态编程语言的开发人员知道,变量声明最佳实践的还有一条就是
就近原则
,即尽可能在靠近第一次使用该变量的位置声明该变量。就近原则实际上是变量作用域最小化的一个实现手段。对于包级别的变量,也可以遵循就近原则
。但是如果该变量在包内部被多处使用,那么这个变量还是放在源文件头部声明比较合适
局部变量的声明形式
局部变量是指函数或方法体内声明的变量,仅在函数或方法体内可见。相比于包级别变量,局部变量多了一种短变量声明形式。这也是局部变量采用的最多的一种声明形式。
- 对于延迟初始化的局部变量声明形式,采用带有
var
关键字的声明形式。一种常见的采用 var 关键字声明形式的变量是 error 类型的变量 err(这也是常见的 Go 命名惯例)。尤其当 defer 后接的闭包函数需要使用 err 判断函数/方法退出状态
1 | func Foo() { |
- 对于声明且显式初始化的局部变量,建议采用短变量声明形式,例如
1 | a := 17 |
- 对于不接受默认类型的变量,依然可以使用短变量声明形式,只是在
:=
的右侧要显式转换
1 | a := int32(17) |
- 尽量在分支控制时使用短变量声明形式。在编写 go 代码时,很少单独声明在分支控制语句中使用的变量,而是通过短变量声明形式将其与 if、for 等融合在一起。这种方式也体现了就近原则,让变量作用域最小化
- 如果在声明局部变量时,也遇到了适合聚类的应用场景,也可以通过 var 块来声明多个局部变量
使用无类型常量简化代码
Go 常量溯源
Go 原生提供常量定义的关键字 const。Go 的 const 整合了 C 语言中的宏定义常量、const 只读变量和枚举常量三种形式,并消除了每种形式的不足,使得 Go 常量成为类型安全且对编译器友好的语法元素。
如下都是 Go 中常量定义形式:
1 | const ( |
更多时候,Go 常量在声明时并不显式指定类型,即使用的是无类型常量(untyped constant)。
1 | const ( |
有类型常量带来的烦恼
Go 是对类型安全要求十分严格的编程语言,Go 要求,两个类型拥有相同的底层类型,也仍然是不同的数据类型,不可以相互比较或者混在一个表达式中运算:
1 | type myint int |
上述代码会有编译错误,Go 在处理不同类型的变量间不支持隐式的类型转换(Go 设计者认为隐式转换的便利性不足以抵消其带来的问题),要解决上述编译错误,必须使用显式类型转换:
1 | type myint int |
而将有类型的常量与变量混合在一起进行运算求值时,也要遵循这一要求,即如果有类型常量与变量的类型不同,那么混合运算的求值操作会报错。
1 | // compiler: cannot use n + 5 (constant 8 of type myInt) as int value in constant declaration |
唯有显式类型转换才能工作:
1 | const m int = int(n) + 5 |
可以看到,有类型常量为代码简化带来了麻烦,但这也是 Go 对类型安全严格要求的结果。
无类型常量消除烦恼,简化代码
1 | type myInt int |
可以看到这三个字面值无需显式类型转换,就可以直接赋值给三个自定义类型的变量。Go 的无类型常量类似于拥有像字面值这样的特性,该特性使得无类型常量在参与变量赋值和计算过程时无需显式类型转换。
1 | type myInt int |
仍然需要注意,无类型常量也有自己的默认类型:无类型的布尔类型常量、整数常量、字符常量、浮点数常量、复数常量、字符串常量对应的默认类型分别为 bool、int、int32(rune)、float64、complex128、string。当使用短变量赋值或者给接口变量赋值时,常量的默认类型就很重要了:
1 | package main |
1 | # ./const |
无类型常量是 Go 语言推荐的实践,它拥有和字面值一样的灵活特性,可以直接用于更多的表达式而不需要进行显式类型转换,从而简化了代码编写。
使用 iota 实现枚举常量
枚举的存在代表了一类需求:有限数量标识符构成的集合,且大多数情况下不关心标识符实际对应的值,同时注重类型安全。C 语言的枚举定义如下:
1 | enum Weekday { |
Go 语言没有提供定义枚举常量的语法,通常使用常量语法定义枚举常量。
1 | const ( |
但是 Go 的 const 语法提供了 隐式重复前一个非空表达式
的机制。
1 | import "fmt" |
1 | # ./const |
可以看到,c, d
隐式重复了前一行非空表达式,这样值就是 5, 6
,而 g, h
也被隐式赋予了前一行非空表达式,这样值就是 7, 8
。
在此基础上,Go 还提供了 iota,有了 iota,就可以定义满足各种场景的枚举常量。iota 是 Go 的一个预定义标识符,它表示的是 const 声明块(包括单行声明)中的每个常量所处位置在块中的偏移值(从 0 开始)。同时,iota 也是一个无类型常量,因此可以自动参与不同类型的求值过程,无需对其进行显式类型转换。
1 | import "fmt" |
根据隐式重复前一个非空表达式的规则,上述代码中 const 常量的定义等价于如下代码:
1 | const ( |
而 iota 的值总是该行在 const 块中中的偏移量,因此最终程序得到如下结果:
1 | # ./const |
另外,需要注意,位于同一行的 iota 即便出现多次,其值也是一样的:
1 | const ( |
而如果要略过 iota = 0,而要从 iota = 1 开始正式定义枚举常量,可以使用如下类似代码:
1 | const ( |
如果要定义非连续枚举值,也可以使用类似方式跳过某个数值:
1 | _ = iota |
iota 让 Go 在枚举常量的定义上表达力大增:
- iota 预定义标识符能够以更为灵活的形式为枚举常量赋初值
- Go 的枚举常量不限于整型值,也可以是浮点类型的枚举常量
1 | const ( |
- iota 使得维护枚举常量列表更加容易,避免手工调整枚举常量值
- 使用由类型枚举常量保证类型安全。枚举常量多数是无类型常量,如果要严格考虑类型安全,也可以定义有类型枚举常量。如下是一段示例:
1 | type Weekday int |
这样要使用 Sunday
、Monday
等枚举常量给变量赋值时,其对应的变量类型必须是 Weekday 类型。
尽量定义零值可用的类型
在 Go 语言中,零值不仅在变量初始化阶段避免了变量值不确定可能带来的潜在问题,而且定义零值可用的类型也是 Go 语言积极倡导的最佳实践之一。
Go 类型的零值
在 C 语言中,栈上分配的分配的局部变量,如果没有显式初始化,那么其值是不确定的(虽然有些编译器提供了一些命令行参数选项,用于对栈上的变量进行零值初始化)。而 Go 语言对这个问题进行了彻底修复和优化:当通过声明或者调用 new 为变量分配存储空间,或者通过复合文字字面量或者调用 make 创建新值,且不提供显式初始化时,Go 会为变量或者值提供默认值。
Go 语言中每个原生类型都有其默认值,这个默认值就是这个类型的零值。如下是 Go 规定的内置原生类型的默认值:
- 所有默认类型:0
- 浮点类型:0.0
- 布尔类型:false
- 字符串类型:””
- 指针、interface、切片(slice)、channel、map、function:nil
另外,Go 的零值初始是递归的,即数组、结构体等类型的零值初始化就是对其组成元素逐一进行零值初始化。
零值可用
Go 从诞生以来,就一直秉承着尽量保持 零值可用
的理念。如下是一个 slice 的例子:
1 | var zeroSlice []int |
这里,Go 中的切片类型具备零值可用的特性,可以直接对其进行 append,而不会出现引用 nil 错误。第二个例子是通过 nil 指针调用函数:
1 | func main() { |
在这个例子中,最终会调用 TCPAddr
类型的 String 方法,而它的实现充分考虑了 零值可用
的理念,如果为 nil 指针,则直接输出 <nil>
值。
1 | func (a *TCPAddr) String() string { |
Go 标准库中还有很多例子,例如 sync.Mutex
、bytes.Buffer
都是零值可用的。Go 语言零值可用的理念给内置类型、标准库的使用者带来了很多便利,但是并不是所有 Go 类型都是零值可用的,并且零值可用也有一定的限制:例如对于 slice 零值,不能通过下标形式添加数据:
1 | var s []int |
而像 map 这样的原生类型也没有提供对零值可用的支持。
1 | var m map[string]int |
另外,零值可用的类型要注意尽量避免值复制,而是通过指针方式传递类型:
1 | var mu sync.Mutex |
保持与 Go 一致的理念,给自定义的类型一个合理的零值,并尽量保持自定义类型的零值可用,这样我们的 Go 代码会更加符合 Go 语言的惯用法。
使用复合字面值作为初值构造器
有些时候,零值并非是最好的选择,我们有必要为变量赋予适当的初始值以保证其后续以正确的状态参与业务流程计算,尤其是 Go 语言中的一些复合类型变量。Go 中的复合类型包括结构体、数组、slice 和 map。
Go 提供了复合字面值(composite literal)语法可以作为复合类型变量的初值构造器,这样就不用对其内部元素进行逐个赋值。
1 | s := myStruct { |
复合字面值由两部分构成:一部分是类型,另一部分则是由大括号包裹的字面值。
结构体复合字面值
Go 推荐使用 field:value
的复合字面值形式对 struct 类型的变量进行值构造。这种构造方式可以降低结构体类型使用者与结构体类型之间的耦合,这也是 Go 的惯用法。
这种 field:value
形式的复合字面值初值构造器很强大:
field:value
形式字面值中的字段可以以任意次序出现,未显式出现在字面值的结构体中的字段将采用其对应类型的零值- 通过在复合字面值构造器的类型前添加 &,可以得到对应类型的指针变量
复合字面值作为结构体构造器大量使用,使得即便采用类型零值时,我们也会采用字面值构造器形式,而较少使用 new 这一个 Go 预定义的函数来创造结构体变量:
1 | s := myStruct{} // 常用 |
但是需要注意,不允许将从其他包导入的结构体中的未导出字段作为复合字面值中的 field,这会导致编译错误。
数组/切片复合字面值
数组/切片使用下标(index)作为 field:value 形式中的 field,从而实现数组/切片初始元素值的高级构造形式。
1 | numbers := [256]int{'a':8, 'b':9} |
这种 index:value
这种初值构造形式,主要应用在少数场合,例如为非连续(稀疏)元素构造初始值、让编译器根据最大元素下标推导数组大小等;或者编写单元测试时,为了显著体现元素对应的下标值。
map 复合字面值
相比于结构体、数组/切片,为 map 类型使用复合字面值作为初值构造器就显得自然很多,因为 map 类型具有原生的 key:value 构造形式。
对于数组切片类型,当元素为复合类型时,可以省去元素复合字面量中的类型,例如:
1 | type Point struct { |
对于 map 类型,当 key 或者 value 类型为复合类型时,我们可以省去 key 或者 value 中的复合字面量中的类型(go 1.5 之后):
1 | // go 1.5 之前 |
**对于 key 或者 value 为指针类型的情况,也可以省略 &T
**:
1 | m3 := map[string]*Point { |
了解切片实现原理并高效使用
切片(slice)是 Go 语言在数组之上提供的一个重要的抽象数据类型,并且和数组相比,切片提供了更灵活、更高效的数据序列访问接口。
切片究竟是什么
Go 数组是一个固定长度的、容纳同构类型元素的连续序列。因此 Go 数组类型具有两个属性:元素类型和数组长度。这两个属性都相同的数组类型都是等价的。Go 数组是值语义的,这意味着一个数组变量表示的是整个数组,在 Go 中传递数组是纯粹的值拷贝。因此当数组元素类型长度较大或者元素个数较多时,直接以数组类型参数传递到函数中会有较大的开销。为了节省这个开销,更地道的方式是使用切片(而不是使用数组指针类型,这种是 C 的惯用法)。
可以把切片看成 数组的描述符
,这也是为什么切片在参数传递时能够避免较大的性能损耗,因为切片这个描述符是固定大小的。
1 | //$GOROOT/src/runtime/slice.go |
- array:指向底层数组某元素的指针,该元素也是切片的起始元素
- len:切片的长度,即切片中当前元素的个数
- cap:切片的最大容量,cap >= len
在运行时中,每个切片容量都是一个 runtime.slice 结构体类型的实例,可以使用如下语句创建一个切片实例, 这里 len 参数为 5,由于没有指定 cap 参数,则默认 cap = len。
1 | s : = make([]int, 5) |
可以通过语法 u[low:high]
创建 已存在数组
的切片,这被称为数组的切片化。
1 | u := [5]int{1,2,3,4,5} |
此时 low:high
决定了切片的长度,而 cap 则取决于底层数组的长度,从切片第一个元素开始,到数组末尾之间,就是 cap 的值。也可以通过语法 s[low:high]
基于已有切片创建新的切片,这被称为切片的 reslicing。
当切片作为函数传递时,实际传递的切片的内部表示,所以无论切片描述的底层数组有多大,切片作为参数传递带来的性能损耗都是很小且恒定的。而切片可以比指针提供更为强大的功能,比如下标访问、边界溢出检查(不能超过 len)、动态扩容等。
切片的高级特性
Go 切片还支持一个高级特性:动态扩容。之前介绍过,切片类型是部分满足零值可用理念的:
1 | var s []byte |
append 会根据切片的需要,在当前底层数组容量无法满足的情况下,动态分配新的数组,新数组长度会按照一定算法进行扩展。新数组建立后,append 会将旧数组中的数据复制到新数组中,之后新的数组便成为切片的底层数组,就数组后续会被垃圾回收掉。
这样的 append 操作会带来一些额外注意事项:当通过语法 u[low:high]
形式进行数组切片化而创建得到切片时,一旦切片 cap 触碰到数组的上界,再对切片进行 append 操作时,切片就会和原数组解除绑定。
1 | package main |
1 | # ./slice |
可以看到,在对 slice 进行扩容后,触发了新的底层数组分配,因此切片 s 与原数组就解除了关系,再次修改 s[0] 的值时,不会对原数组有任何影响,因为 s 已经不再是数组 u 的描述符了。
尽量使用 cap 参数创建切片
在使用 append 对 slice 添加元素时,有可能会触发扩容,而重新分配数组并复制元素的操作代价还是很大的。为了减少这种开销,一种方法是根据切片的使用场景对切片的容量规模进行预估,并在创建新切片时将预估出来的切片容量数据以 cap 参数的形式传递给内置函数 make。
当可以预估出切片底层数组需要承载的元素数量时,强烈建议在创建切片时带上 cap 参数。
了解 map 的实现原理并且高效使用
map 是 Go 提供的一种抽象数据类型,它表示一组无序的键值对(key-value)。map 对 value 的类型没有限制,但是对 key 的类型有严格的要求:key 的类型应该严格定义了作为 ==
和 !=
两个操作符的操作数时的行为,因此函数、map 和切片不能作为 may 的 key 类型。
map 类型不支持 零值可用
,未显式赋初值的 map 类型的变量的零值为 nil。对于处于零值的 map 变量进行操作会导致运行时 panic。所以需要对 map 类型的变量进行显式初始化才能使用,这有两种方式:
- 使用复合字面量创建 map 类型变量
1 | var statusText = map[int]string { |
- 使用 make 创建 map 类型变量
1 | cookies := make(map[string][]*Cookie) |
和切片一样,map 也是引用类型,将 map 类型变量作为函数参数传入不会有很大的性能损耗,并且在函数内部对 map 变量的修改在函数外部也是可见的。
map 的基本操作
- 对于非 nil 的 map 类型变量,可以通过
m[key] = value
的形式向 map 中插入数据。如果 key 已经存在于 map 中,则该插入操作会用新值覆盖旧值 - 和 slice 一样,map 也可以通过内置函数 len 获取当前已经存储的数据个数
- 可以使用
comma, ok
惯用法在 map 中查找数据,例如
1 | _, ok := m["key"] |
即使 key 不存在,使用 value := m[key]
的形式也会返回对应 value 类型的零值,所以这个时候无法确认返回的这个 零值
到底是 key
对应的真实值,还是因为 key 不存在而返回的零值。因此这个时候还是要借助 comma, ok
惯用法。因此 Go 的一个最佳实践,总是使用 comma, ok
惯用法读取 map 中的值。
- 借助内置函数 delete 从 map 中删除数据。**即便要删除的数据在 map 中不存在,delete 也不会 panic **
1 | m := map[string]int { |
- 可以通过
for range
语句对 map 中的数据进行遍历。但是需要注意,对同一个 map 做多次遍历,遍历的元素次序并不相同,因此不能依赖遍历 map 所得到的元素次序
1 | package main |
如果需要一个稳定的遍历次序,那么一个比较通用的做法就是使用另外一种数据结构来按需要的次序保存 key。
map 的内部实现
Go 运行时使用一张哈希表来实现抽象的 map 类型,运行时实现了 map 操作的所有功能,包括查找、插入、删除、遍历等。语法层面 map 类型变量–对应的是 runtime.hamp
类型的实例。hmap 是 map 类型的 header,可以理解为 map 类型的描述符,它存储了后续 map 类型操作的所有信息。map 对底层使用的内存进行自动管理。
hamp 实例自身是有状态的,且对状态读写时是没有并发保护的,因此 map 实例并不是并发安全的。如果需要对 map 实例进行并发读写,程序运行时会发生 panic。如果仅仅是并发读,则 map 是没有问题的。Go 1.9 引入了并发安全的 sync.Map 类型。
考虑到 map 可以自动扩容,map 中数据元素的 value 位置可能在这一过程中发生变化,因此 Go 不允许获取 map 中 value 的地址,这个约束是在编译器就生效的。
尽量使用 cap 参数创建 map
由于 map 也会进行动态扩容,而这一过程会降低 map 的访问性能。因此如果可能的话,最好对 map 使用规模做出粗略估算,并使用 cap 参数对 map 实例进行初始化。
了解 string 实现原理并高效使用
Go 内置了 string 类型,统一了对字符串的抽象。
Go 语言的字符串类型
无论是字符串常量、字符串变量,还是字符串字面量,它们的类型都被统一设置成 string 类型。Go 的 string 类型有如下特点:
- string 类型的数据是不可变的:无论是常量还是变量,该标识符所指代的数据在整个程序生命周期内都无法更改
如下尝试将将 string 转换为一个切片并通过该切片对其内容进行修改,但无法实现。因为对 string 进行切片化后,Go 编译器会为切片变量重新分配底层存储,而不是共用 string 的底层存储。因此对切片的修改并不会对原 string 产生影响。
1 | func main() { |
1 | # ./change_string |
即使我们通过 unsafe 指针获取 string 在运行时内部表示结构中的数据存储块地址,然后通过指针修改那块内存中存储的数据,也无法实现。因为对 string 的底层数据存储区仅能进行只读操作,一旦试图修改那块区域,就会得到 SIGBUS 运行时错误。
- Go string 类型支持
零值可用
,其零值为""
,长度为 0 - 获取长度的时间复杂度是 O(1) 级别
- 支持通过
+/+=
操作符进行字符串连接 - 支持各种比较关系操作符:
==/!=, <, >, <=, >=
- 对非 ASCII 字符提供原生支持。Go 源文件默认采用 Unicode 字符集,每个 Unicode 字符以 UTF-8 编码格式存储。如下示例分别打印每个中文字符、其 Unicode 码点、UTF-8 编码序列。
1 | package main |
1 | # ./rune |
这段代码通过 []byte(s)
获得字符串 s 的底层存储的复制品,从而得到这段字符串的 utf-8 编码字节序列。
另外需要注意,使用 for i,v := range s
方式访问 s,是按字符(unicode 码点)遍历字符串,i 是该字符在字节序列中的下标。而以 s[i]
的方式访问字符串,返回的是字节序列中第 i 个字节的值。len(s)
也是返回字符串 s 的字节序列长度。
- 原生支持多行字符串:Go 可以通过反引号构造
所见即所得
的多行字符串的方法。
1 | package main |
1 | # ./multi_line |
字符串的内部表示
Go string 在运行时表示为下面的结构:
1 | type stringStruct struct { |
可以看到 string 类型也是一个描述符,它并不真正存储数据,而仅由一个指向底层存储的指针和字符串的长度字段组成。所以直接将 string 类型通过函数参数传递时也不会有太多的性能损耗,因为仅仅传入的是一个 描述符
。
字符串的高效构造
Go 支持通过 +
、+=
操作符对字符串进行拼接,除此之外,还有一些字符串的构造方法:
- 使用 fmt.Sprintf
- 使用 strings.Join
- 使用 strings.Builder
- 使用 bytes.Buffer
在能预估出最终字符串长度的情况下,使用预初始化的 strings.Builder 连接构建字符串效率最高;strings.Join 连接构建字符串的平均性能最稳定,也是不错的选择。fmt.Sprintf 效率不高,但如果是由多种不同类型的变量来构建特定格式的字符串,那么这种方式还是最合适的。
字符串相关的高效转换
string 和 []rune
之间是可以相互转换的,string 与 []byte
之间也可以相互转换。无论是 string 到 slice 还是 slice 到 string 的转换都是需要付出代价的,这是因为 string 是不可变的的,运行时要为转换后的类型分配新的内存。
slice 类型是不可比较的,而 string 类型是比较的。因此在日常编码中,经常将 slice 临时转换为 string 的情况,Go 编译器为这样的场景提供了优化,这样的优化是针对以下几个特定场景的:
string(b)
用在 map 类型的 key 中string(b)
用在字符串链接语句中string(b)
用在字符串比较中
Go 编译器对用在 for-range 循环中的 string 到 []byte 的转换也有优化处理,它不会为 []byte 进行额外的内存分配,而是直接使用 string 的底层数据。例如:
1 | func test() { |
Go 语言还提供了 strings 和 strcov 包,可以辅助对 string 类型数据进行更多高级操作。
理解 Go 语言的包导入
Go 使用包(package)作为基本单元来组织源码,可以说一个 Go 程序就是由一些包链接在一起构建而成的。这使得 Go 的编译速度更快:
- Go 要求每个源文件在开头处显式列出所有依赖的
包导入
,这样 Go 编译器不必读取和处理整个文件就能确定其依赖的包列表 - Go 要求包之间不能存在循环依赖,这样包的依赖关系便形成了一张有向无环图。包可以单独编译,也可以并行编译
- 已编译的 Go 包对应的目标文件不仅记录了该包本身的导出符号信息,还记录了其所依赖包的导出符号信息
通过 package 关键字声明 Go 源文件所属的包。使用 import 关键字导入依赖的标准库包或第三方包。
Go 程序构建过程
和主流静态编译型语言一样,Go 程序的构建简单来讲也是由编译(compile)和链接(link)两个阶段组成。
- 一个非 main 包在编译后会生成一个 .a 文件,该文件可以理解为 Go 包的目标文件
- 在使用第三方包的时候,在第三方包源码存在且对应的
.a
已经安装的情况下,编译器链接的仍是根据第三方包最新源码编译出的.a
文件,而不是之前已经安装到$GOPATH/pkg/darwin_amd64
下的目标文件 依赖标准库包
在编译时也是需要所依赖的标准库包的源代码的。但是默认情况下,编译器直接链接的是$GOROOT/pkg/darwin_amd64
下的目标文件,而不是源码
究竟是路径名还是包名
编译器在编译过程中必然要使用的是编译单元(一个包)所依赖的包的源码。而编译器要找到依赖包的源码文件,就需要知道依赖包的源码路径。这个路径由两部分组成:
- 基础搜索路径
- 包导入路径
基础搜索路径是一个全局设置,以下是规则:
- 所有包(标准包/第三方包)的源码基础搜索路径都包括
$GOROOT/src
- 在上述基础搜索路径的基础上,不同版本的 Go 包含的其他搜索路径有所不同
- Go 1.11 之前,包的源码基础搜索路径还包括
$GOPATH/src
- Go 1.11 ~ Go.1.12 版本,包的源码基础搜索路径有三种模式:
- 经典的 gopath 模式下(GO111MODULE=off):
$GOPATH/src
- module-aware 模式下(GO111MODULE=on):
$GOPATH/pkg/mod
- auto 模式下(GO111MODULE=auto):在
$GOPATH/src
路径下,与 gopath 模式相同;在$GOPATH/src
路径外且包含 go.mod 时,与 module-aware 相同
- 经典的 gopath 模式下(GO111MODULE=off):
- Go 1.13 版本,包的源码基础搜索路径有两种模式:
- 经典的 gopath 模式下(GO111MODULE=off):
$GOPATH/src
- module-aware 模式下(GO111MODULE=on):
$GOPATH/pkg/mod
- 经典的 gopath 模式下(GO111MODULE=off):
- 未来的 Go 版本只有 module-aware 模式,即只在 module 缓存的目录下搜索包的源码
- Go 1.11 之前,包的源码基础搜索路径还包括
搜索路径的第二部分就是每个包源码文件头部的 包导入路径
,基础搜索路径与包导入路径结合在一起,Go 编译器可以确定一个包的所有依赖包的源码路径的集合,这个集合构成了 Go 编译器的 源码搜索路径空间
。如果使用了相对路径(以 .
开头),那么它的基础搜索路径是 $CWD
,即执行编译命令的当前工作目录。
因此需要注意,源文件头部的包导入语句 import 后面的部分就是一个路径,路径的最后一个分段也不是包名。只不过 Go 的一个惯用法,就是包导入路径的最后一段目录名最好与包名一致。当包名与包导入路径中的最后一个目录名不相同时,最好用下面的语法将包名显式放入包导入语句中,这样代码可读性更好。
1 | import ( |
包名冲突问题
同一个源码文件在其包导入路径构成的源码搜索路径空间下很可能存在同名包,为了解决包名冲突问题,还是要用到 为包导入路径下的包显式指定包名的方法
。
理解 Go 语言表达式的求值顺序
Go 支持在同一行声明和初始化多个变量(不同类型也可以):
1 | var a, b, c = 10, "hello", 3.15 |
支持在同一行对多个变量进行赋值:
1 | a, b, c = 5, "hello", 3,14 |
这种语法糖给我们带来便利的同时,也要求我们理解 Go 表达式的求值顺序。
包级别变量声明语句中的表达式求值顺序
在一个 Go 包内部,包级别变量声明语句的表达式求值顺序是由初始化依赖(initialization dependencies)规则决定的:
- 在 Go 包中,包级别的变量的初始化按照变量声明的先后顺序进行
- 如果某个变量(例如 a)的初始化表达式中直接或间接依赖了其他变量(例如 b),那么变量 a 的初始化顺序排在变量 b 后面
- 未初始化的、且不含有对应初始化表达式;或者初始化表达式不依赖任何未初始化变量的变量,称之为
ready for initialization
变量 - 包级别变量的初始化是逐步进行的,每一步就是按照变量声明顺序找到下一个
reaady for initialization
变量,然后对其初始化。反复重复这一过程,直到没有ready for initialization
变量为止 - 位于同一包内但不同文件中的变量的声明顺序依赖编译器处理文件的顺序:先处理的文件中的变量的声明顺序先于后处理文件中的所有变量
在下面这个例子中,按照变量声明顺序 a, b, c, d
,逐个查找 ready for initialization
变量,因此初始化顺序是 d、b、c、a:
1 | import "fmt" |
1 | # ./main |
如果在包级别变量中使用了空变量 _
,空变量也会得到 Go 编译器一视同仁的对待。
还有一种特殊情况,就是当多个变量在声明语句左侧,且右侧为单一表达式的表达式求值情况。在这种情况下,无论左侧哪个变量被初始化,同一行的其他变量也会被一并初始化。
1 | var ( |
这个例子中,按照 a, b & c, d
的顺序,逐个查找 ready for initialization
变量,因此初始化顺序是 d、b & c, a
:
1 | # ./main |
普通求值顺序
除了包级别变量由初始化依赖决定的求值顺序,Go 还定义了普通求值顺序(usual order),用于规定表达式操作数中的函数、方法及 channel 的求值顺序。Go 规定了表达式操作数中的所有函数、方法以及 channel 操作按照从左到右的次序进行求值。
1 | import "fmt" |
1 | # ./main |
当普通求值顺序与包级别变量的初始化依赖顺序一并使用时,后者优先级更高。但每个单独表达式中的操作数求值依旧按照普通求值顺序的规则。如下是一个例子:
1 | import "fmt" |
1 | # ./main |
这段代码中的包级别变量初始化语句等同于如下代码(注意,与前面的多个变量在声明语句左侧且右侧为单一表达式时的表达式求值情况不同,这里右侧并非单一表达式):
1 | var ( |
赋值语句的求值
Go 语言规定,赋值语句求值分为两个阶段:
- 第一阶段:对于等号左边的下标表达式、指针解引用表达式和等号右边表达式中的操作数,按照普通求值规则从左到右进行求值
- 第二阶段:按照从左到右的顺序,对变量进行赋值
1 | package main |
switch/select 语句中的表达式求值
switch-case 语句中的表达式求值属于 惰性求值
,即需要求值时才会对表达式进行求值,这样做的目的是让计算机少做事情,从而降低程序的消耗,对性能提升有一定帮助。
1 | import "fmt" |
1 | # ./main |
这个例子中:
- 首先对 switch 后面的表达式
Expr(2)
- 按照从上到下、从左到右的顺序对 case 语句中的表达式进行求值。如果一旦匹配,那么求值就停止
- fallthrough 语句将控制权直接转移到下一个 case 执行语句了,略过了 case 表达式
Expr(4)
的求值
接下来再看 select-case
语句的求值,Go 语言中的 select 为我们提供了一种在多个 channel 间实现 多路复用
的机制:
1 | import ( |
1 | ./main |
- **从 select 执行开始时,所有 case 表达式都会按照出现的顺序求值一遍,有一个例外:位于 case 等号左边的、从 channel 接收数据的表达式不会被求值,这里对应
getASlice(0)
**。 - 如果选择要执行的是一个从 channel 接收数据的 case,那么该 case 等号左边的表达式在接收之前才会被求值。例如
getAReadOnlyChannel()
中创建的 goroutine 在 3s 后向 channel 中写入一个 int 值后,select 选择了第一个 case 执行,此时对等号左侧的表达式getASlice()[0]
进行求值,这也算一种惰性求值
理解 Go 语言代码块与作用域
理解 Go 代码块(code block)和作用域(scope)规则,有助于我们编写正确且可读性高的代码。
Go 代码块与作用域简介
Go 中的代码块是包裹在一对大括号内部的声明和语句,且代码块支持嵌套。Go 中有两类代码块:
- 一类是我们在代码中直观可见的、由一对大括号包裹的显式代码块,例如函数体、for 循环的循环体、if 语句的某个分支等
- 另一类则是没有大括号包裹的隐式代码块。Go 定义了如下几种隐式代码块
- Universe 代码块:所有 Go 源码都在该隐式代码块中,相当于所有 Go 代码的最外层都存在一对大括号
- 包代码块:每个包都有一个包代码块,其中放置该包的所有源代码
- 文件代码块:每个文件都有一个文件代码块,其中包含该文件的所有 Go 源码
- 每个 if、for 和 switch 语句都被视为位于其自己的隐式代码块中
- switch 或 select 语句中的每个子句都被视为一个隐式代码块
Go 标识符的作用域是基于代码块定义的,作用域规则描述了标识符在哪些代码块中是有效的。下面是标识符作用域规则:
- 预定义标识符,make、new、cap len 等的作用域是 Universe 代码块
- 顶层(任何函数之外)声明的常量、类型、变量或者函数(但不是方法)对应的标识符的作用域范围是包代码块
- Go 源文件中导入的包名称作用域范围是文件代码块
- 方法接收器(receiver)、函数参数或者返回值变量对应的标识符的作用域范围是函数体(显式代码块),虽然它们没有被函数体的大括号所包裹
- 在函数内部声明的常量或变量对应的标识符的作用域范围始于常量或变量声明语句的末尾,止于其最里面的那个包括块的末尾
- 在函数内部声明的类型标识符的作用域范围始于类型定义中的标识符,止于其最里面的那个包含块的末尾
if 条件控制语句的代码块
对于如下单 if 型:
1 | if SimpleStmt; Expression { |
if 语句自身在一个隐藏的代码块中,因此对于上面的单 if 类型的控制语句中有两个代码块:
- 一个隐式代码块
- 一个显式代码块
因此上述代码等价如下代码:
1 | { // 隐式代码块开始 |
这也是为什么 SimpleStmt 中使用的短变量定义的变量可以在 if 语句的显式代码块中使用。
对于 if {} else {}
型,假设有如下代码:
1 | if SimpleStmt; Expression { |
上述代码等价于:
1 | { |
所以在 SimpleStmt 中定义的变量,其作用域范围可以延伸到 else 后面的显式代码块中。
而对于如下的 if {} else if {} else {}
的代码:
1 | if SimpleStmt1; Expression1 { |
它的代码等价于:
1 | { // 隐式代码块 1 开始 |
只有理解上述作用域规则,才能理解如下代码的输出:
1 | package main |
1 | # ./main |
其他控制语句的代码块规则简介
对于 for 控制语句:
1 | for InitStmt; Condition; PostStmt { |
其代码等价于:
1 | { // 隐式代码块开始 |
其实 for 循环形式,也是类似的。
1 | for IndentifierList := range Expression { |
等价于:
1 | { // 隐式代码块开始 |
对于 switch-case 语句,对于如下形式:
1 | switch SimpleStmt; Expression { |
等价于
1 | { |
可以看到每个 case 语句都对应一个隐式代码块,因此每个 case 语句中都可以独立定义同名变量。
对于 select-case
代码块,和 switch-case
无法在 case 子句中声明变量不同的是,select-case
可以在 case 子句中通过短变量声明定义新变量,但是该变量依然被纳入 case 的隐式代码块中。
1 | select { |
代码等价于
1 | select { // 显式代码块开始 |
了解 Go 语言控制语句惯用法及使用注意事项
Go 语言的控制结构全面继承了 C 语言的语法,并进行了一些创新:
- 坚持
一件事情仅有一种做法
的设计理念,仅保留 for 这一种循环控制语句(去掉 while、do-while 语法) - 为 break 和 continue 增加后接 label 的可选能力
- switch 的 case 语句执行完毕后不会执行下一个 case 中的语句,除非显式使用 fallthrough 关键字
- switch 的 case 语句支持表达式列表
- 增加 type switch,让类型信息也可以作为分支选择的条件
- 增加针对 channel 通信的 switch-case 语句 – select-case
使用 if 控制语句时应该遵循 快乐路径
的原则
所谓快乐路径即成功逻辑的代码执行路径,这个原则要求:
- 当出现错误时,快速返回
- 成功逻辑不要嵌入 if-else 语句中
- 快乐路径的执行逻辑在代码布局上始终靠左,这样读者可以一眼看到该函数的正常逻辑流程
- 快乐路径的返回值一般在函数最后一行
for range 的 避坑
指南
for range
的惯用法是使用短变量声明方式(:=)在 for 的 initStmt 中声明迭代变量(iteration variable),但需要注意的是这些迭代变量在 for range
的每次循环中都会被重用,而不是重新声明。这是因为之前说过,每个 for 语句都被认为处于其自己的隐式代码块中
所以对于如下代码:
1 | var m := [...]int{1, 2, 3, 4, 5} |
上述代码可以等价于
1 | var m := [...]int{1, 2, 3, 4, 5} |
所以对于如下代码,各个 goroutine 打印的是同一份 i, v:
1 | import ( |
1 | # ./main |
要修正这个问题,可以为闭包函数增加参数并在创建 goroutine 时将参数 i、v 的当前值进行绑定:
1 | import ( |
range 表达式还有一个特别需要注意的地方,参与循环的是 range 表达式的副本:
1 | import "fmt" |
这里,a 是一个数组,参与 range 迭代的是 a 的副本,而不是真正的 a。因此 i == 0
对数组的修改,并不会反映到 range 表达式中的 a 副本中。
我们知道,Go 中的数组是一个值类型,因此数组的副本是与原数组完全不同的一块底层存储区域。而切片是一个引用类型,切片的副本仍和原始切片指向同一块底层存储区域。所以如果改成切片,就会有不同的结果(或者使用数组的指针):
1 | package main |
1 | # ./main |
之前介绍过 slice 的运行时实现中是包含 len 字段的,因此 slice 的副本中 len 字段是不会改变的。所以在 for range
循环中改变 slice 的长度,也不会影响 slice 副本
的长度:
1 | import "fmt" |
1 | # ./main |
range 表达式的复制行为还会带来一些性能损耗,尤其是当 range 表达式的类型为数组时,range 需要复制整个数组。而对于数组指针或者切片,这个开销就小的多。
对于 range 后面的其他表达式类型,例如 string、map 和 channel 等,for range 也都会对副本进行遍历。对于 string 类型,由于在 Go 运行时内部也是一个结构体:struct{*byte, len}
,并且 string 本身也是不可变的。所以其 for range
开销类似于 slice。对于 for range
对于 string 来说,每次循环的单位是一个 rune,而不是一个 byte。返回的第一个值为 迭代字符码点
的第一字节位置
1 | import "fmt" |
1 | # ./main |
如果作为 range 表达式的字符串 s 中存在非法 UTF-8 字节序列,那么 v 将返回 0xfffd 这个特殊值,并且在下一轮循环中,v 将仅前进一字节。
当 map 类型作为 range 表达式时,会得到一个 map 的内部表示的副本,map 是 Go 运行时内部表示为一个 hmap 的描述符结构指针,因此该指针的副本也指向同一个 hmap 描述符。所以 for range
对 map 副本的操作即对源 map 的操作。
之前介绍过,for range
无法保证每次迭代的元素次序是一致的,同时,如果在循环过程中对 map 进行修改,那这样的修改是否会影响后续迭代过程也是不确定的。例如如果在循环体中新创建一个 map 元素项,那么该项元素可能出现在后续循环中,也可能不出现。
对于 channel 来说,channel 在 Go 运行时内部表示为一个 channel 描述符的指针,因此 channel 的指针副本也指向原始 channel。当 channel 作为 range 表达式类型时,for range 最终以阻塞读的方式阻塞在 channel 表达式中,即使是带缓冲的 channel 也是如此:当 channel 中无数据时,for range 也会阻塞在 channel 上,直到 channel 关闭。
1 | import ( |
如果使用一个 nil channel 作为 range 表达式,程序编译不会有问题,但是 for range
将永远阻塞在这个 nil channel 上,直到 Go 运行时发现程序陷入 deadlock 状态,并抛出 panic。
1 | package main |
1 | # ./main |
break 跳到哪里去了
Go 语言明确规定 break 语句(不接 label 的情况下)结束执行并跳出的是同一函数内 break 语句所在的最内层 for、switch 或 select 的执行。所以下面的例子中,select 其实跳出的是 select 语句,并不会跳出外层的 for 循环。
1 | import ( |
1 | # ./main |
为了修正这个问题,可以使用 break [label]
。带 label 的 break 或者 continue 提升了 Go 的表达能力,可以让程序轻松拥有从深层循环中终止外层循环或跳转到外层循环继续执行的能力,使得 Gopher 无需为类似逻辑设计复杂的程序结构或者 goto 语句:
1 | import ( |
尽量用 case 表达式代理 fallthrough
在 Go 语言中,switch-case
语句默认不是 fallthrough
的,需要 fall through
的时候,可以使用关键字 fallthrough
显式实现。
另外,Go 的 switch-case
还提供了 case 表达式列表
来支持多个分支表达式处理逻辑相同的情况,这种方式比 fallthrough 更加简洁和易读。例如:
1 | switch n { |