Go 语言
声明、类型、语句与控制结构
控制语句惯用法

控制语句惯用法

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

  • 总体上继承了 C 语言的控制语句的关键词和使用方式。
  • 仅保留 for 一种循环语句,去掉了 while 和 do while 循环。
  • switch 语句不再需要显式地使用 break 来结束每个分支,也可以直接 switch 语句结束。
  • switchcase 语句支持表达式列表。
  • 增加 type switch 语句,用于判断某个 interface 变量中实际存储的变量类型。
  • 增加针对 channelselect 控制语句。

快乐路径原则

这个原则要求:

  • 当出现错误时,快速返回。
  • 成功逻辑不要嵌套入 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!

带标签的 continuebreak 提升了 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 更加简洁和易读。