控制语句惯用法
Go 语言的控制结构全面继承了 C 语言的语法,并进行了一些创新。
- 总体上继承了 C 语言的控制语句的关键词和使用方式。
- 仅保留
for
一种循环语句,去掉了while
和 dowhile
循环。 switch
语句不再需要显式地使用break
来结束每个分支,也可以直接switch
语句结束。switch
的case
语句支持表达式列表。- 增加
type switch
语句,用于判断某个interface
变量中实际存储的变量类型。 - 增加针对
channel
的select
控制语句。
快乐路径原则
这个原则要求:
- 当出现错误时,快速返回。
- 成功逻辑不要嵌套入
if-else
语句中。 - 执行逻辑在代码布局上始终靠左。
- 返回值一般在函数最后一行。
下面是遵循快乐路径原则的代码示例:
func ReadFile() error {
file, err := os.Open("file")
if err != nil {
return err
}
Process(file)
defer file.Close()
return nil
}
上面代码:
- 没有使用 else,失败就立即返回
- 成功逻辑始终居左,没有嵌套到 if 语句中
- 代码段布局段扁平,没有深度缩进
- 返回值在函数最后一行
for-range 的坑
小心迭代变量的重用
例子:
func main() {
a := []int{1, 2, 3}
for i, v := range a {
go func() {
time.Sleep(3 * time.Second)
fmt.Println(i, v)
}()
}
}
// 输出
// 2 3
// 2 3
// 2 3
我们能看到 groutine
中输出的都是 i,v 值都是 for range
循环结束的最终值,而不是各个 goroutine
启动时的值。
这是因为 groutine
执行闭包函数引用了它的外层包裹函数的变量,而不是值拷贝。i,v 值在整个循环过程中是重用的,仅一份。
要修正这个问题,可以为闭包函数增加参数并在创建 goroutine
时将参数传入。
func main() {
a := []int{1, 2, 3}
for i, v := range a {
go func(i, v int) {
time.Sleep(3 * time.Second)
fmt.Println(i, v)
}(i, v)
}
}
// 输出
// 0 1
// 1 2
// 2 3
range 表达式副本
for-range
表达式是副本,不是原值。如果 range
表达式是一个数组、指向数组的指针、slice、map、channel,那么 range
产生的迭代变量是其元素的副本,即值拷贝。
func main() {
var a = [3]int{1, 2, 3}
var r [3]int
println(a)
for i, v := range a {
if i == 0 {
a[1], a[2] = 0, 0
}
r[i] = v
}
println(r)
println(a)
}
// 期望
// [1 2 3]
// [1 0 0]
// [1 0 0]
// 输出
// [1 2 3]
// [1 2 3]
// [1 0 0]
导致这个问题的原因是参与循环的是 range 表达式的副本,而不是真正的 a
。
我们再来试试使用数组指针作为 range 表达式:
func main() {
var a = [3]int{1, 2, 3}
var r [3]int
println(a)
for i, v := range &a {
if i == 0 {
a[1], a[2] = 0, 0
}
r[i] = v
}
println(r)
println(a)
}
// 输出
// [1 2 3]
// [1 0 0]
// [1 0 0]
我们大多数的开发场景是使用 slice,所以我们再来试试使用 slice 作为 range 表达式:
func main() {
var a = []int{1, 2, 3}
var r = make([]int, 3)
println(a)
for i, v := range a {
if i == 0 {
a = a[:]
}
r[i] = v
}
println(r)
println(a)
}
// 输出
// [1 2 3]
// [1 0 0]
// [1 0 0]
显然,切片也是符合预期的,因为切片是引用类型。切片的底层是一个数组,所以切片的修改会影响到底层数组。range 表达式是切片的副本,但是底层数组是共享的。
其他注意事项
对于 range 后面的其他表达式类型,比如 string,map和 channel,for-range 依旧会制作副本。
1、string
对于 string 来说,每次循环的单位是一个 rune,而不是一个 byte。返回的 i 值为迭代字节符码点的第一个字节的位置:
func main() {
s := "hello, 世界"
for i, v := range s {
fmt.Printf("%d: %c\n", i, v)
}
}
// 输出
// 0: h
// 1: e
// 2: l
// 3: l
// 4: o
// 5: ,
// 6:
// 7: 世
// 10: 界
2、map
map 在 Go 运行时内部表示为一个 hmap
的描述符结构指针,因此该指针副本也指向同一个 hmap
描述符,所以 range 表达式的 map 副本操作也是指向源 map 的操作。
关于 map 的遍历,我们需要注意的是 map 是无序的,所以每次遍历的顺序都是不一样的。
如果在循环过程中对 map 做了修改,那么这样修改的结果是否会影响后续的迭代过程也是不确定的。
func main() {
m := map[string]int{"a": 1, "b": 2, "c": 3}
for k, v := range m {
fmt.Println(k, v)
if k == "b" {
m["a"] = 100
m["d"] = 4
}
}
}
其结果可能有很多种。这里不展示了。
3、channel
channel 在 Go 运行时内部表示为一个 hchan
的描述符结构指针,因此该指针副本也指向同一个 hchan
描述符,所以 range 表达式的 channel 副本操作也是指向源 channel 的操作。
当 channel 作为 range 表达式时,如果 channel 无数据,会以阻塞读的方式阻塞在 channel 上,直到 channel 关闭或者有数据可读。
func main() {
ch := make(chan int)
go func() {
for i := 0; i < 3; i++ {
ch <- i
}
close(ch)
}()
for v := range ch {
fmt.Println(v)
}
}
// 输出
// 0
// 1
// 2
如果使用一个 nil
channel 作为 range
表达式,会导致死锁。
func main() {
var ch chan int
for v := range ch {
fmt.Println(v)
}
}
// 输出
// fatal error: all goroutines are asleep - deadlock!
break 的坑
我们看一个例子:
func main() {
exit := make(chan struct{})
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{}{}
time.Sleep(3 * time.Second)
}
这个例子愿意是想要在接收到 exit
信号后退出循环。但是最终输出结果是:
tick
tick
exiting...
tick
tick
tick
在接收到退出信号后,程序并没有退出,而是继续执行了循环。这是因为 break
只能跳出 select
语句,而不能跳出 for
循环。
要解决这个问题,可以使用一个标签来标记循环:
func main() {
exit := make(chan struct{})
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{}{}
time.Sleep(3 * time.Second)
}
在改进的例子,我们定义了一个标签 loop,该标签附在
for
循环外面,然后在 select
语句中使用 break loop
跳出循环。我们看一下执行效果:
tick
tick
exiting...
exit!
带标签的 continue
和 break
提升了 Go 语言的表达能力,可以让程序轻松拥有从深层循环中终止外层循环或跳转到外层循环继续执行的能力。
case 表达式列表
switch
语句不再需要显式地使用 break
来结束每个分支,也可以直接 switch
语句结束。但是如果我们想要继续执行下一个分支,可以使用 fallthrough
关键字。
但是 switch
语句提供了 case
表达式列表来支持多个分支表达式处理逻辑相同的情况:
switch n {
case 1: fallthrough
case 2: fallthrough
case 3:
fmt.Println("1, 2, 3")
case 4: fallthrough
case 5: fallthrough
case 6:
fmt.Println("4, 5, 6")
default:
fmt.Println("default")
}
// vs
switch n {
case 1, 2, 3:
fmt.Println("1, 2, 3")
case 4, 5, 6:
fmt.Println("4, 5, 6")
default:
fmt.Println("default")
}
我们可以看到,通过 case
接表达式列表的方式要比 fallthrough
更加简洁和易读。