Go语言在变量声明、初始化以及赋值语句上相比其先祖C语言做了一些改进,诸如:
● 支持在同一行声明和初始化多个变量(不同类型也可以)
var a, b, c = 5, "hello", 3.45
a, b, c := 5, "hello", 3.45 // 短变量声明形式
● 支持在同一行对多个变量进行赋值
a, b, c = 5, "hello", 3.45
这种语法糖在给我们带来便利的同时,也可能带来一些令人困惑的问题。
Go语言之父Rob Pike在Go语言早期(r60版本,2011年)曾经讲过一门名为“The GoProgramming Language” [1] 的课程,虽然距今年代有些久远,但该课程仍然是笔者心中的经典,强烈推荐Gopher学习一下。
在该门课程第二天 [2] 的内容中,Rob Pike出了这样一道练习题:下面语句执行完毕后,n0和n1的值分别是多少?
n0, n1 = n0+n1, n0
或者
n0, n1 = op(n0, n1), n0
对于这个问题,很多Go语言初学者无法给出答案;一些Go语言老手虽然能给出正确答案,但也说不出个所以然。显然这个问题涉及Go语言的表达式求值顺序(evaluationorder)。
上面问题中赋值语句中的表达式求值仅仅是表达式求值的众多应用场景中的一个。表达式的求值顺序在任何一门编程语言中都是比较“难缠的”。很多情形下,语言规范给出的 答 案 可 能 是“undefined”(未 定 义)、“not specified”(未 明 确 说 明)或“implementation-dependent”(实现相关)。
理解表达式求值顺序的机制,对于编写出正确、逻辑清晰的Go代码很有必要,因此在这一条中,我们一起结合直观的实例来深入理解Go语言的表达式求值顺序。
包级别变量声明语句中的表达式求值顺序
在一个Go包内部,包级别变量声明语句的表达式求值顺序是由初始化依赖(initialization dependencies)规则决定的。那初始化依赖规则是什么呢?根据Go语言规范中的说明,这里将该规则总结为如下几点。
● 在Go包中,包级别变量的初始化按照变量声明的先后顺序进行。
● 如果某个变量(如变量a)的初始化表达式中直接或间接依赖其他变量(如变量b),那么变量a的初始化顺序排在变量b后面。
● 未初始化的且不含有对应初始化表达式或初始化表达式不依赖任何未初始化变量的变量,我们称之为“ready for initialization”变量。
● 包级别变量的初始化是逐步进行的,每一步就是按照变量声明顺序找到下一个“ready for initialization”变量并对其进行初始化的过程。反复重复这一步骤,直到没有“ready for initialization”变量为止。
● 位于同一包内但不同文件中的变量的声明顺序依赖编译器处理文件的顺序:先处理的文件中的变量的声明顺序先于后处理的文件中的所有变量。规则往往抽象难懂,例子则更直观易理解。
我们看一个Go语言规范中的例子,并使用
上述规则进行分析(Go编译器版本1.13):
package main
import "fmt"
var (
a = c + b
b = f()
c = f()
d = 3
)
func f() int {
d++
return d
}
func main() {
fmt.Println(a, b, c, d)
}
运行结果:
9 4 5 5
对于上面的代码,不同的包变量初始化顺序会导致变量值不同,因此明确四个变量的初始化顺序至关重要。我们结合上面的初始化依赖规则来分析一下该程序执行后的a、b、c、d四个变量的结果值。
1)根据规则,包级变量初始化按照变量声明先后顺序进行,因此每一轮寻找“readyfor initialization”变量的过程都会按照a -> b -> c -> d的顺序依次进行。
2)我们先来进行第一轮选择“ready for initialization”变量的过程。我们从变量a开始。变量a的初始化表达式为c + b,这使得a的初始化依赖b和c,而b、c通过函数f间接依赖未初始化变量d,因此a并不是“ready for initialization”变量。
3)按照声明顺序,接下来是b。b的初始化表达式依赖函数f,而函数f依赖未初始化变量d,因此b也不是“ready for initialization”变量。
4)按照声明顺序,接下来是c。c的初始化表达式依赖函数f,而函数f依赖未初始化变量d,因此c也不是“ready for initialization”变量。
5)按照声明顺序,接下来是d。d没有需要求值的初始化表达式,而是直接被赋予了初值,因此d是我们第一轮找到的“ready for initialization”变量,我们对其进行初始化:d = 3。当前已初始化变量集合为[d=3]。
6)接下来进行第二轮“ready for initialization”变量的寻找。我们依然从a开始,和第一轮一样,b、c依旧是未初始化变量,a不符合条件;我们继续看b。b依赖函数f,函数f依赖d,但d已经是已初始化变量集合中的元素了,因此b具备了成为“ready forinitialization”的条件,于是第二轮我们选出了b,并对b进行初始化:b = d + 1 = 4。
此时已初始化变量集合为[d=4, b=4]。
7)接下来进行第三轮“ready for initialization”变量的寻找。我们依然从a开始,和前两轮一样,c依旧是未初始化变量,a不符合条件;我们继续看c。c依赖函数f,函数f依赖d,但d已经是已初始化变量集合中的元素了,因此c具备了成为“ready forinitialization”的条件,于是第三轮我们选出了c,并对c进行初始化:c = d + 1 = 5。
此时已初始化变量集合为[d=5, b=4, c=5]。
8)接下来进行最后一轮“ready for initialization”变量的寻找。此时只剩下变量a了,并且a依赖的b、c都是已初始变量集合中的元素了,因此a符合“ready forinitialization”的条件,于是最后一轮我们选出a,并对a进行初始化:a = 4 + 5 = 9。
此时已初始化变量集合为[d = 5, b = 4, c = 5, a = 9]。
9)初始化结束,根据上述分析,程序应该输出9 4 5 5。
如果在包级变量声明中使用了空变量_,空变量也会得到Go编译器一视同仁的对待。我们看下面的例子:
package main
import "fmt"
var (
a = c + b
b = f()
_ = f()
c = f()
d = 3
)
func f() int {
d++
return d
}
func main() {
fmt.Println(a,b,c,d)
}
有了第一个例子中详细的分析,这里我们的分析从简。
1) 初 始 化 过 程 按 照 a - > b - > _ - > c - > d 的 顺 序 进 行“ready forinitialization”变量的查找。
2)第一轮:变量a、b、_、c都不符合条件,d被选出并初始化,已初始化变量集合为[d=3]。
3)第二轮:变量b符合条件被选出并初始化,已初始化变量集合为[d=4, b=4]
4)第三轮:空变量符合条件被选出并初始化,但空变量忽略了初始值,这一过程的副作用是使得变量d增加1,已初始化变量集合为[d=5, b=4]。
5)第四轮:变量c符合条件被选出并初始化,已初始化变量集合为[d=6, b=4, c=6]。
6)第五轮:变量a符合条件被选出并初始化,已初始化变量集合为[d=6, b=4, c=6,a=10]。
7)包变量初始化结束,分析输出结果应为10 4 6 6。
还有一种比较特殊的情况值得我们在这里一并分析,那就是当多个变量在声明语句左侧且右侧为单一表达式时的表达式求值情况。在这种情况下,无论左侧哪个变量被初始化,同一行的其他变量也会被一并初始化。
我们来看下面这个例子:
package main
import "fmt"
var (
a = c
b, c = f()
d = 3
)
func f() (int, int) {
d++
return d, d + 1
}
func main() {
fmt.Println(a, b, c, d)
}
1)根据包级变量初始化规则,初始化过程将按照a -> b&c -> d顺序进行“ready forinitialization”变量的查找。
2)第一轮:变量a、b、c都不符合条件,d被选出并初始化,已初始化变量集合为[d=3]。
3)第二轮:变量b和c一起符合条件,以b被选出为例,b被初始化的同时,c也得到了
初始化,因此已初始化变量集合为[d=4, b=4, c=5]。
4)第三轮:变量a符合条件被选出并初始化,已初始化变量集合为[d=4, b=4, c=5,a=5]。
5)包变量初始化结束,分析输出结果应为5 4 5 4。
运行上述代码:
5 4 5 4
输出结果也与我们分析的一致。
普通求值顺序
除了包级变量由初始化依赖决定的求值顺序,Go还定义了普通求值顺序(usualorder),用于规定表达式操作数中的函数、方法及channel操作的求值顺序。Go规定表达式操作数中的所有函数、方法以及channel操作按照从左到右的次序进行求值。
同样来看一个改编自Go语言规范中的例子:
package main
import "fmt"
func f() int {
fmt.Println("calling f")
return 1
}
func g(a, b, c int) int {
fmt.Println("calling g")
return 2
}
func h() int {
fmt.Println("calling h")
return 3
}
func i() int {
fmt.Println("calling i")
return 1
}
func j() int {
fmt.Println("calling j")
return 1
}
func k() bool {
fmt.Println("calling k")
return true
}
func main() {
var y = []int{11, 12, 13}
var x = []int{21, 22, 23}
var c chan int = make(chan int)
go func() {
c <- 1
}()
y[f()], _ = g(h(), i()+x[j()], <-c), k()
}
y[f()], _ = g(h(), i()+x[j()], <-c), k()这行语句是赋值语句,但赋值语句的表
达式操作数中包含函数调用、channel操作。按照普通求值规则,这些函数调用、channel操作按从左到右的顺序进行求值。
● 按照从左到右的顺序,先对等号左侧表达式操作数中的函数进行调用求值,因此第一个是y[f()]中的f()。
● 接下来是等号右侧的表达式。第一个函数是g(),但g()依赖其参数的求值,其参数列表依然可以看成是一个多值赋值操作,其涉及的函数调用顺序从左到右依次为h()、i()、j()、<-c,这样该表达式操作数函数的求值顺序即为h() -> i() -> j() -> c取值操作 ->g()。
● 最后还剩下末尾的k(),因此该语句中函数以及channel操作的完整求值顺序是:f() ->h() -> i() -> j() -> c取值操作 -> g() -> k()。
例子的实际运行结果如下:
calling f
calling h
calling i
calling j
calling g
calling k
输出结果与我们分析的一致。
赋值语句的求值
package main
import "fmt"
func example() {
n0, n1 := 1, 2
n0, n1 = n0+n1, n0
fmt.Println(n0, n1)
}
func main() {
example()
}
这是一个赋值语句。Go语言规定,赋值语句求值分为两个阶段:、
1)第一阶段,对于等号左边的下标表达式、指针解引用表达式和等号右边表达式中的操作数,按照普通求值规则从左到右进行求值
2)第二阶段,按从左到右的顺序对变量进行赋值。
根据上述规则,我们对这个问题等号两端的表达式的操作数采用从左到右的求值顺序。
假定n0和n1的初值如下:n0, n1 = 1, 2
第一阶段:等号两端表达式求值。上述问题中,等号左边没有需要求值的下标表达式、指针解引用表达式等,只有右端有n0+n1和n0两个表达式,但表达式的操作数(n0,n1)
都是已初始化了的,因此直接将值代入,得到求值结果。
求值后,语句可以看成n0, n1 =3, 1。
第二阶段:从左到右赋值,即n0 =3,n1 = 1。
switch/select语句中的表达式求值
上面的三类求值顺序原则已经可以覆盖大部分Go代码中的场景了,如果说在表达式求值方面还有值得重点关注的,那肯定非switch/select语句中的表达式求值莫属了。
我们先来看switch-case语句中的表达式求值,这类求值属于“惰性求值”范畴。惰性求值指的就是需要进行求值时才会对表达值进行求值,这样做的目的是让计算机少做事,从而降低程序的消耗,对性能提升有一定帮助。
package main
import "fmt"
func Expr(n int) int {
fmt.Println(n)
return n
}
func main() {
switch Expr(2) {
case Expr(1), Expr(2), Expr(3):
fmt.Println("enter into case1")
fallthrough
case Expr(4):
fmt.Println("enter into case2")
}
}
运行结果:
2
1
2
enter into case1
enter into case2
从例子的输出结果我们看到:
1)对于switch-case语句而言,首先进行求值的是switch后面的表达式Expr(2),这个表达式在求值时输出2。
2)接下来将按照从上到下、从左到右的顺序对case语句中的表达式进行求值。如果某个表达式的结果与switch表达式结果一致,那么求值停止,后面未求值的case表达式将被
忽略。结合上述例子,这里对第一个case中的Expr(1)和Expr(2)进行了求值,由于Expr(2)
求值结果与switch表达式的一致,所以后续Expr(3)并未进行求值。
3)fallthrough将执行权直接转移到下一个case执行语句中了,略过了case表达式Expr(4)的求值。
我们再来看看select-case语句的求值。Go语言中的select为我们提供了一种在多个channel间实现“多路复用”的机制,是编写Go并发程序最常用的并发原语之一。
我们通过一个例子直观看一下select-case语句中表达式的求值规则:
package main
import (
"fmt"
"time"
)
func getAReadOnlyChannel() <-chan int {
fmt.Println("invoke getAReadOnlyChannel")
c := make(chan int)
go func() {
time.Sleep(3 * time.Second)
c <- 1
}()
return c
}
func getASlice() *[5]int {
fmt.Println("invoke getASlice")
var a [5]int
return &a
}
func getAWriteOnlyChannel() chan<- int {
fmt.Println("invoke getAWriteOnlyChannel")
return make(chan int)
}
func getANumToChannel() int {
fmt.Println("invoke getANumToChannel")
return 2
}
func main() {
select {
// 从channel接收数据
case (getASlice())[0] = <-getAReadOnlyChannel():
fmt.Println("recv something from a readonly channel")
// 将数据发送到channel
case getAWriteOnlyChannel() <- getANumToChannel():
fmt.Println("send something to a writeonly channel")
}
}
运行结果:
invoke getAReadOnlyChannel
invoke getAWriteOnlyChannel
invoke getANumToChannel
invoke getASlice
recv something from a readonly channel
从上述例子可以看出以下两点。
1)select执行开始时,首先所有case表达式都会被按出现的先后顺序求值一遍。
invoke getAReadOnlyChannel
invoke getAWriteOnlyChannel
invoke getANumToChannel
有一个例外,位于case等号左边的从channel接收数据的表达式(RecvStmt)不会被求值,这里对应的是getASlice()。
2)如果选择要执行的是一个从channel接收数据的case,那么该case等号左边的表达式在接收前才会被求值。比如在上面的例子中,在getAReadOnlyChannel创建的goroutine
在3s后向channel中写入一个int值后,select选择了第一个case执行,此时对等号左侧的
表达式(getASlice())[0]进行求值,输出“invoke getASlice”,这也算是一种惰性求
值。
表达式本质上就是一个值,表达式求值顺序影响着程序的计算结果。
Gopher应牢记以下几点规则。
● 包级别变量声明语句中的表达式求值顺序由变量的声明顺序和初始化依赖关系决定,
并且包级变量表达式求值顺序优先级最高。
● 表达式操作数中的函数、方法及channel操作按普通求值顺序,即从左到右的次序进
行求值。
● 赋值语句求值分为两个阶段:先按照普通求值规则对等号左边的下标表达式、指针解
引用表达式和等号右边的表达式中的操作数进行求值,然后按从左到右的顺序对变量
进行赋值。
● 重点关注switch-case和select-case语句中的表达式“惰性求值”规则。