Go基础14-了解Go语言控制语句惯用法及使用注意事项

Go语言的控制结构全面继承了C语言的语法,并进行了一些创新:

● 总体上继承了C语言的控制语句关键字和使用方式;

● 坚持“一件事情仅有一种做法”的设计理念,仅保留for这一种循环控制语句,去掉while、do-while语法

● 为break和continue增加后接label的可选能力;

● switch的case语句执行完毕后,默认不会像C语言那样继续执行下一个case中的语句,除非显式使用fallthrough关键字,这“填补”了C语言中每个case语句都要以break收尾的“坑”;

● switch的case语句支持表达式列表;

● 增加type switch,让类型信息也可以作为分支选择的条件;

● 增加针对channel通信的switch-case语句——select-case。

要掌握一门编程语言,不光要掌握其各种语句的基本用法,还要掌握符合其语言思维的惯用法。在这一条中我们就来了解一下Go语言控制语句的惯用法及使用注意事项。


使用if控制语句时应遵循“快乐路径”原则

对比下面两段逻辑相同但形式不同的伪代码段:
// 伪代码段1

func doSomething() error {
    
    
if errorCondition1 {
    
    
// 错误逻辑
...
return err1
}
// 成功逻辑
...
if errorCondition2 {
    
    
// 错误逻辑
...
return err2
}
// 成功逻辑
...
return nil
}

// 伪代码段2

func doSomething() error {
    
    
if successCondition1 {
    
    
// 成功逻辑
...
if successCondition2 {
    
    
// 成功逻辑
...
return nil
} else {
    
    
// 错误逻辑
...
return err2
}
} else {
    
    
// 错误逻辑
...
return err1
}
}

即便你是刚入门的Go新手,你大概也能看出上面代码的“优劣”。
伪代码段1:

● 没有使用else,失败就立即返回;

● 成功逻辑始终居左并延续到函数结尾,没有被嵌入if语句中;

● 整个代码段布局扁平,没有深度的缩进;

● 代码逻辑一目了然,可读性好。反观实现了同样逻辑的伪代码段2:

● 整个代码段呈现为锯齿状,有深度缩进;

● 成功逻辑被嵌入if-else代码块中;

● 代码逻辑“曲折宛转”,可读性较差。
这里,伪代码段1的if控制语句的使用方法符合Go语言惯用的“快乐路径”原则。所谓“快乐路径”即成功逻辑的代码执行路径,这个原则要求:

● 当出现错误时,快速返回;
● 成功逻辑不要嵌入if-else语句中;
● “快乐路径”的执行逻辑在代码布局上始终靠左,这样读者可以一眼看到该函数的正
常逻辑流程;
● “快乐路径”的返回值一般在函数最后一行,就像上面伪代码段1中的那样。如果你的函数实现代码不符合“快乐路径”原则,可以按下面的步骤进行重构:

● 尝试将“正常逻辑”提取出来,放到“快乐路径”中;

● 如果无法做到上一点,很可能是函数内的逻辑过于复杂,可以将深度缩进到if-else语句中的代码析出到一个函数中,再对原函数实施“快乐路径”原则的重构

for range的避“坑”指南

for range的引入提升了Go语言的表达能力,但for range也不是“免费的午餐”,在享用这道“美味”前,我们需要搞清楚使用for
range的一些注意事项。

  1. 小心迭代变量的重用
    for range的惯用法是使用短变量声明方式(:=)在for的initStmt中声明迭代变量(iteration variable)。但需要注意的是,这些迭代变量在for range的每次循环中都会被重用,而不是重新声明。

这是因为根据上一条Go语言代码块和作用域规则中的说明,我们可以将for range进行等价转换:

var m = [...]int{
    
    1, 2, 3, 4, 5}
for i, v := range m {
    
    
...
}

上述代码可等价转换为:

var m = [...]int{
    
    1, 2, 3, 4, 5}
{
    
    
i, v := 0
for i, v = range m {
    
    
...
}
}

这样,我们就可以清晰地看到迭代变量的重用。来看一个与迭代变量重用相关的“坑”的例子:

func demo1() {
    
    
var m = [...]int{
    
    1, 2, 3, 4, 5}
for i, v := range m {
    
    
go func() {
    
    
time.Sleep(time.Second * 3)
fmt.Println(i, v)
}()
}
time.Sleep(time.Second * 10)
}

很显然,这个demo意图在每个循环启动的goroutine中输出i、v,但实际输出结果是什么呢?我们实际运行一下:

4 5
4 5
4 5
4 5
4 5

我们看到,goroutine中输出的i、v值都是for range循环结束后的i、v最终值,而不是各个goroutine启动时的i、v值。这是因为goroutine执行的闭包函数引用了它的外层包裹函数中的变量i、v,这样变量i、v在主goroutine和新启动的goroutine之间实现了共享。而i、v值在整个循环过程中是重用的,仅有一份。在for range循环结束后,i = 4,v= 5,因此各个goroutine在等待3秒后进行输出的时候,输出的是i、v的最终值。

如果要修正这个问题,可以为闭包函数增加参数并在创建goroutine时将参数与i、v的当时值进行绑定:

func demo2() {
    
    
var m = [...]int{
    
    1, 2, 3, 4, 5}
for i, v := range m {
    
    
go func(i, v int) {
    
    
time.Sleep(time.Second * 3)
fmt.Println(i, v)
}(i, v)
}
time.Sleep(time.Second * 10)
}

运行该例子:

0 1
1 2
2 3
3 4
4 5

输出结果与我们的预期一致。(注意:每次输出结果的行序可能不同,这是由goroutine的调度顺序决定的。)

  1. 注意参与迭代的是range表达式的副本
    for range语句中,range后面接受的表达式的类型可以是数组、指向数组的指针、切片、字符串、map和channel(至少需具有读权限)。

我们以数组为例来看一个简单的例子:

func arrayRangeExpression() {
    
    
	var a = [5]int{
    
    1, 2, 3, 4, 5}
	var r [5]int
	 fmt.Println("arrayRangeExpression result:")
	fmt.Println("a = ", a)
	for i, v := range a {
    
    
		if i == 0 {
    
    
			a[1] = 12
			a[2] = 13
		}
	r[i] = v
	}
	fmt.Println("r = ", r)
	fmt.Println("a = ", a)
}

我们期待的输出结果是:

a = [1 2 3 4 5]
r = [1 12 13 4 5]
a = [1 12 13 4 5]

但实际运行该程序的输出结果却是:

a = [1 2 3 4 5]
r = [1 2 3 4 5]
a = [1 12 13 4 5]

我们原以为在第一次循环过程,也就是i = 0时,我们对a的修改(a[1] = 12,a[2] =13)会在第二次、第三次循环中被v取出,但结果却是v取出的依旧是a被修改前的值:2和3。出现这个结果的原因是:参与循环的是range表达式的副本。也就是说在上面这个例子
中,真正参与循环的是a的副本,而不是真正的a。

例子中for-range循环的等价伪代码如下:

for i, v := range a' { //a'是a的一个值副本
if i == 0 {
    
    
a[1] = 12
a[2] = 13
}
r[i] = v
}

Go中的数组在内部表示为连续的字节序列,虽然长度是Go数组类型的一部分,但长度并不包含在数组类型在Go运行时的内部表示中,数组长度是由编译器在编译期计算出来。

这个例子中,对range表达式的复制即对一个数组的复制,a’则是Go临时分配的连续字节序列,与a完全不是一块内存区域。因此无论a被如何修改,其参与循环的副本a’依旧保持原值,因此v从a’中取出的仍旧是a的原值,而非修改后的值。

我们再来试试用数组指针作为range表达式:

func pointerToArrayRangeExpression() {
    
    
 var a = [5]int{
    
    1, 2, 3, 4, 5}
var r [5]int
fmt.Println("pointerToArrayRangeExpression result:")
fmt.Println("a = ", a)
for i, v := range &a {
    
    
if i == 0 {
    
    
a[1] = 12
a[2] = 13
}
r[i] = v
}
fmt.Println("r = ", r)
fmt.Println("a = ", a)
}

这回的输出结果如下:

pointerToArrayRangeExpression result:
a = [1 2 3 4 5]
r = [1 12 13 4 5]
a = [1 12 13 4 5]

我们看到这次r数组的值与最终a被修改后的值一致了。这个例子使用了*[5]int作为range表达式,其副本依旧是一个指向原数组a的指针,因此后续所有循环中均是&a指向的原数组亲自参与的,因此v能从&a指向的原数组中取出a修改后的值。

在Go中,大多数应用数组的场景都可以用切片替代,这里也用切片来试试:


func sliceRangeExpression() {
    
    
	var a = [5]int{
    
    1, 2, 3, 4, 5}
	var r [5]int
	fmt.Println("sliceRangeExpression result:")
	fmt.Println("a = ", a)
	for i, v := range a[:] {
    
    
		if i == 0 {
    
    
			a[1] = 12
			a[2] = 13
		}
		r[i] = v
	}
	fmt.Println("r = ", r)
	fmt.Println("a = ", a)
}

这个例子的运行结果如下:

pointerToArrayRangeExpression result:
a = [1 2 3 4 5]
r = [1 12 13 4 5]
a = [1 12 13 4 5]

显然用切片也能满足预期要求,我们来分析一下切片是如何做到的。在前文中我们了解到,切片在Go内部表示为一个结构体,由(*T, len, cap)三元组组成。

其中T指向切片对应的底层数组的指针,len是切片当前长度,cap为切片的容量。在进行range表达式复制时,它实际上复制的是一个切片,也就是表示切片的那个结构体。表示切片副本的结构体中的T依旧指向原切片对应的底层数组,因此对切片副本的修改也都会反映到底层数组a上。而v从切片副本结构体中*T指向的底层数组中获取数组元素,也就得到了被修改后的元素值。

切片与数组还有一个不同点,就是其len在运行时可以被改变,而数组的长度可认为是一个常量,不可改变。那么len变化的切片对for range有何影响呢?我们继续看一个例子:

func sliceLenChangeRangeExpression() {
    
    
var a = []int{
    
    1, 2, 3, 4, 5}
var r = make([]int, 0)
fmt.Println("sliceLenChangeRangeExpression result:")
fmt.Println("a = ", a)
for i, v := range a {
    
    
if i == 0 {
    
    
a = append(a, 6, 7)
}
r = append(r, v)
}
fmt.Println("r = ", r)
fmt.Println("a = ", a)
}

运行上面例子的输出结果如下:

a = [1 2 3 4 5]
r = [1 2 3 4 5]
a = [1 2 3 4 5 6 7]

在这个例子中,原切片a在for range的循环过程中被附加了两个元素6和7,其len由5增加到7,但这对于r却没有产生影响。

其原因就在于a的副本a’的内部表示中的len字段并
没有改变,依旧是5,因此for range只会循环5次,也就只获取到a所对应的底层数组的前5个元素。

range表达式的复制行为还会带来一些性能上的消耗,尤其是当range表达式的类型为数组时,range需要复制整个数组;而当range表达式类型为数组指针或切片时,这个消耗将小得多,因为仅仅需要复制一个指针或一个切片的内部表示(一个结构体)即可。

下面是我们通过性能基准测试得到的三种情况的消耗对比情况。对于元素个数为100的int数组或切片,测试结果如下:

BenchmarkArrayRangeLoop-8 50000000 33.6 ns/op
BenchmarkPointerToArrayRangeLoop-8 50000000 34.7 ns/op
BenchmarkSliceRangeLoop-8 20000000 59.6 ns/op
PASS
ok command-line-arguments 4.749s

可以看到,range表达式的类型为切片或数组指针的性能相近,消耗都接近数组类型的1/2。


其他range表达式类型的使用注意事项

对于range后面的其他表达式类型,比如string、map和channel,for range依旧会制作副本。

对于range后面的其他表达式类型,比如string、map和channel,for range依旧会制作副本。
(1)string

当string作为range表达式的类型时,由于string在Go运行时内部表示为struct{*byte, len},并且string本身是不可改变的(immutable),因此其行为和消耗与切片作为range表达式时类似。不过for range对于string来说,每次循环的单位是一个rune,而不是一个byte,返回的第一个值为迭代字符码点的第一字节的位置:


var s = "中国人"
for i, v := range s {
    
    
fmt.Printf("%d %s 0x%x\n", i, string(v), v)
}

这个例子的输出结果如下:

00x4e2d
30x56fd
60x4eba

如果作为range表达式的字符串s中存在非法UTF8字节序列,那么v将返回0xfffd这个特殊值,并且在下一轮循环中,v将仅前进一字节:

var sl = []byte{
    
    0xe4, 0xb8, 0xad, 0xe5, 0x9b, 0xbd, 0xe4, 0xba, 0xba}
for _, v := range sl {
    
    
fmt.Printf("0x%x ", v)
}
fmt.Println("\n")

// 故意构造非法UTF8字节序列

sl[3] = 0xd0
sl[4] = 0xd6
sl[5] = 0xb9
for i, v := range string(sl) {
    
    
fmt.Printf("%d %x\n", i, v)
}

上面示例的输出结果如下:

0xe4 0xb8 0xad 0xe5 0x9b 0xbd 0xe4 0xba 0xba
0 4e2d
3 fffd
4 5b9
6 4eba

我们看到在第二次循环时,由于以sl[3]开始的字节序列并非一个合法的UTF8字符,因此v的值为0xfffd,并且下一轮(第三轮)循环从i = 4开始。第三轮循环找到了一个合法的UTF8字节序列0xd6,0xb9,即码点为0b59的UTF8字符,这是一个希伯来语的字符。接下来的第四轮循环,程序又回归正常节奏,正确找出了码点为4eba的UTF8字符。

2)map

当map类型作为range表达式时,我们会得到一个map的内部表示的副本。在前文中我们学习过map的内部表示,map在Go运行时内部表示为一个hmap的描述符结构指针,因此该指针的副本也指向同一个hmap描述符,这样for range对map副本的操作即对源map的操作。

关于map的元素迭代,在前文中也提及过,for range无法保证每次迭代的元素次序是一致的。同时,如果在循环的过程中对map进行修改,那么这样修改的结果是否会影响后续
迭代过程也是不确定的,比如下面的例子:

var m = map[string]int{
    
    
"tony": 21,
"tom": 22,
"jim": 23,
}
counter := 0
for k, v := range m {
    
    
if counter == 0 {
    
    
delete(m, "tony")
}
counter++
fmt.Println(k, v)
}
fmt.Println("counter is ", counter)

反复运行这个例子多次,得到两个不同的结果:

tony 21
tom 22
jim 23
counter is 3
// 或
tom 22
jim 23
counter is 2

如果在循环体中新创建一个map元素项,那么该项元素可能出现在后续循环中,也可能不出现:

m["tony"] = 21
counter = 0
for k, v := range m {
    
    
if counter == 0 {
    
    
m["lucy"] = 24
}
counter++
fmt.Println(k, v)
}
fmt.Println("counter is ", counter)

上述例子的执行结果如下:

tony 21
tom 22
jim 23
lucy 24
counter is 4
// 或
tony 21
tom 22
jim 23
counter is 3

(3)channel
对于channel来说,channel在Go运行时内部表示为一个channel描述符的指针(关于channel的内部表示将在后文中详细说明),因此channel的指针副本也指向原channel。
当channel作为range表达式类型时,for range最终以阻塞读的方式阻塞在channel表达式上,即便是带缓冲的channel亦是如此:当channel中无数据时,for range也会阻塞在
channel上,直到channel关闭。我们来看一个例子:

func recvFromUnbufferedChannel() {
    
    
 var c = make(chan int)
go func() {
    
    
time.Sleep(time.Second * 3)
c <- 1
c <- 2
c <- 3
close(c)
}()
for v := range c {
    
    
fmt.Println(v)
}
}

该例子的运行结果如下:

1
2
3

如果使用一个nil channel作为range表达式,像下面这样:

func recvFromNilChannel() {
    
    
var c chan int
// 程序将一直阻塞在这里
for v := range c {
    
    
fmt.Println(v)
}
}

程序的编译不会有问题,但for range将永远阻塞在这个nil channel上,直到Go运行时发现程序陷入deadlock状态,并抛出panic:

$go run control_structure_idiom_5.go
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan receive (nil chan)]:
main.recvFromNilChannel()

break跳到哪里去了

我们再来看一个例子:

func main() {
    
    
exit := make(chan interface{
    
    })
go func() {
    
    
for {
    
    
select {
    
    
case <-time.After(time.Second):
fmt.Println("tick")
case <-exit:
fmt.Println("exiting...")
break
}
}
fmt.Println("exit!")
}()
time.Sleep(3 * time.Second)
exit <- struct{
    
    }{
    
    }
// wait child goroutine exit
time.Sleep(3 * time.Second)
}

上面这个例子的原意是:3秒后,主goroutine给子goroutine发一个退出信号(通过
channel),子goroutine收到信号后通过break退出循环。主goroutine在发出信号后等待
goroutine退出,等待时间为3秒。
我们来执行一下这个例子:

tick
tick
exiting...
tick
tick
tick

程序的执行并未如我们的预期:子goroutine在收到channel信号后执行的break并未退出外层的for循环(没有输出“exit”),而是再次进入循环中打印“tick”。

这是Go break语法的一个“小坑”。和大家习惯的C家族语言中的break不同,Go语言规范中明确规定break语句(不接label的情况下)结束执行并跳出的是同一函数内break语句所在的最内层的for、switch或select的执行。上面例子中的break实际上跳出了select语句,但并没有跳出外层的for循环,这是程序未按我们预期执行的原因。

要修正这一问题,可以利用Go语言为for提供的一项高级能力:break [label]。我们来看一下修正问题后的代码:

func main() {
    
    
exit := make(chan interface{
    
    })
go func() {
    
    
loop:
for {
    
    
select {
    
    
case <-time.After(time.Second):
fmt.Println("tick")
case <-exit:
fmt.Println("exiting...")
break loop
}
}
fmt.Println("exit!")
}()
time.Sleep(3 * time.Second)
exit <- struct{
    
    }{
    
    }
// 等待子goroutine退出
time.Sleep(3 * time.Second)
}

在改进后的例子中,我们定义了一个label——loop,该label附在for循环的外面,指代for循环的执行。代码执行到“break loop”时,程序将停止label loop所指代的for循环的执行,我们来看一下执行结果:

tick
tick
exiting...
exit!

这个结果与我们的预期是一致的。
带label的continue和break提升了Go语言的表达能力,可以让程序轻松拥有从深层循环中终止外层循环或跳转到外层循环继续执行的能力,使得Gopher无须为类似的逻辑设计复杂的程序结构或使用goto语句。

outerLoop:
for i := 0; i < n; i++ {
    
    
// ...
for j := 0; j < m; j++ {
    
    
// 当不满足某些条件时,直接终止最外层循环的执行
break outerLoop
// 当满足某些条件时,直接跳出内层循环,回到外层循环继续执行
continue outerLoop
}
}

猜你喜欢

转载自blog.csdn.net/hai411741962/article/details/132761670