「读书笔记」了解 Go 语言控制语句惯用法及使用注意事项

185 阅读5分钟

声明、类型、语句与控制结构

19 了解 Go 语言控制语句惯用法及使用注意事项

  1. 使用 if 控制语句时应遵循“快乐路径”原则,“快乐路径”即成功逻辑的代码执行路径。原则要求:
  • 当出现错误时,快速返回;
  • 成功逻辑不要嵌入 if-else 语句中;
  • “快乐路径”的执行逻辑在代码布局上始终靠左,这样读者可以一眼看到该函数的正常逻辑流程;
  • “快乐路径”的返回值一般在函数最后一行。
  1. for range 的避“坑”指南:
  • 小心迭代变量的重用:for range 的惯用法是使用短变量声明方式(:=)在 for 的 initStmt 中声明迭代变量。但要注意,这些迭代变量在 for range 的每次循环中都会被重用,而不是重新声明。

    func demo1() {
       var m = [...]int{1, 2, 3, 4, 5}
       for i, v := range m {
          go func() {
             time.Sleep(time.Second * 1)
             fmt.Println(i, v)
          }()
       }
       time.Sleep(time.Second * 2)
    }
    
    func demo2() {
       var m = [...]int{1, 2, 3, 4, 5}
       for i, v := range m {
          go func(i, v int) {
             time.Sleep(time.Second * 1)
             fmt.Println(i, v)
          }(i, v)
       }
       time.Sleep(time.Second * 2)
    }
    
    func main() {
       demo1()
       // 4 5
       // 4 5
       // 4 5
       // 4 5
       // 4 5
    
       demo2() // 输出结果由 goroutine 调度决定
       // 3 4
       // 4 5
       // 0 1
       // 2 3
       // 1 2
    }
    
  • 注意参与迭代的是 range 表达式的副本:range 后面接受的表达式的类型可以是数组、指向数组的指针、切片、字符串、map 和 channel(至少需要有读权限)。

    • 数组:迭代数组是原数组的一个复制,是 Go 临时分配的连续字节序列,与原数组完全不是一块内存区域,所以修改原数组并不会影响到正在迭代的数组。

    • 指针数组:使用指针数组时,其副本依旧是一个指向原数组的指针,因此循环中均是指针数组指向的原数组亲自参与,因此可以从指向的原数组的取值。

    • 切片:切片由 (*T, len, cap) 三元组组成,*T 指向切片对应的底层数组的指针,切片副本的结构体中的 *T 依旧指向原切片对应的底层数组,因此对切片副本的修改也都会反映到底层数组上。但在迭代过程中,当原切片 len 变化(如 append)时,迭代切片内部表示中的 len 字段并不会改变,因此不产生影响。

    • string:在 Go 运行时内部表示为 struct {*byte, len},并且 string 本身是不可改变的,其行为和切片类似,每次循环的单位是一个 rune,而不是一个 byte,返回的第一个值为迭代字符码点的第一字节的位置。如果字符串中存在非法 UTF8 字节序列,那么 v 将返回 0xfffd 这个特殊值,并在下一轮循环中,v 将仅前进一字节。

    • map:在 Go 运行时内部表示为一个 hmap 的描述符结构指针,因此该指针的副本也指向同一个 hmap 描述符,这样 for range 对 map 副本的操作即对源 map 的操作。for range 无法保证每次迭代的元素次序是一致的。同时,如果在循环的过程中对 map 进行修改(新增项、删除项等),那么这样修改的结果是否会影响后续迭代过程也是不确定的。

    • channel:在 Go 运行时内部表示为一个 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)
         }
      }
      
      func recvFromNilChannel() {
         var c chan int
         // 程序将一直阻塞在这里
         for v := range c {
            fmt.Println(v)
         }
      }
      
      func main() {
         recvFromUnbufferedChannel()
         // 1
         // 2
         // 3
      
         recvFromNilChannel()
         // fatal error: all goroutines are asleep - deadlock!
      }
      
  1. break 跳到哪里去了:
  • break 语句(不接 label 的情况下)结束执行并跳出的是同一函数内 break 语句所在的最内层的 for、switch 或 select 的执行。

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

    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{}{}
    
    // wait child goroutine exit
    time.Sleep(3 * time.Second)
    
    // tick
    // tick
    // exiting...
    // exit!
    
  1. 尽量用 case 表达式列表替代 fallthrough:实际编码过程中 fallthrough 的应用不多,而且 Go 的 switch-case 语句还提供了 case 表达式列表来支持多个分支表达式处理逻辑相同的情况,更加简洁和易读,因此在程序中使用 fallthrough 关键字前,先想想能否使用更为简洁、清晰的 case 表达式列表替代。

往期回顾

关注我

掘金:XQGang

Github: XQ-Gang

参考

《Go 语言精进之路:从新手到高手的编程思想、方法和技巧》——白明