对于以面向工程著称且以解决规模化问题为目标的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表达式列表替代