如何避免在Go语言编程中常见的那些容易忽略的细节问题?

摘要:可读性 Go 中 if else 应遵循快乐路径,即先考虑(退出返回跳过)错误情况,否则代码层级嵌套过重,可读性会变差。 作用域 Go 中的作用域分为显式作用域和隐式作用域。显示作用域是花括号包裹的代码块,隐式作用域定义如下: 宇宙(U
可读性 Go 中 if else 应遵循快乐路径,即先考虑(退出/返回/跳过)错误情况,否则代码层级嵌套过重,可读性会变差。 作用域 Go 中的作用域分为显式作用域和隐式作用域。显示作用域是花括号包裹的代码块,隐式作用域定义如下: 宇宙(Universe)代码块:所有Go源码都在该隐式代码块中,就相当于所有Go代码的最外层都存在一对大括号。 包代码块:每个包都有一个包代码块,其中放置着该包的所有Go源码。 文件代码块:每个文件都有一个文件代码块,其中包含着该文件中的所有Go源码。 作用域规则如下: 每个if、for和switch语句均被视为位于其自己的隐式代码块中。 switch或select语句中的每个子句都被视为一个隐式代码块。 Go标识符的作用域是基于代码块定义的,作用域规则描述了标识符在哪些代码块中是有效的。 标识符作用域规则如下: 预定义标识符,make、new、cap、len等的作用域范围是宇宙块。 顶层(任何函数之外)声明的常量、类型、变量或函数(但不是方法)对应的标识符的作用域范围是包代码块。比如:包级变量、包级常量的标识符的作用域都是包代码块。 Go源文件中导入的包名称的作用域范围是文件代码块。 方法接收器(receiver)​、函数参数或返回值变量对应的标识符的作用域范围是函数体(显式代码块)​,虽然它们并没有被函数体的大括号所显式包裹。 在函数内部声明的常量或变量对应的标识符的作用域范围始于常量或变量声明语句的末尾,止于其最里面的那个包含块的末尾。 在函数内部声明的类型标识符的作用域范围始于类型定义中的标识符,止于其最里面的那个包含块的末尾 if-else 根据上述作用域规则,给出以下代码示例: if a := 1; false { } else if b := 2; false { } else if c := 3; false { } else { fmt.Println(a, b, c) } 程序输出: 1, 2, 3 其作用域可以等价为: a := 1 if false { } else { { b := 2 if false { } else { { c := 3 if false { } else { fmt.Println(a, b, c) } } } } } for range 根据作用域规则给出以下示例: var m = [...]int{1, 2, 3, 4, 5} for i, v := range m { go func() { time.Sleep(3 * time.Second) fmt.Println(i, v) }() } time.Sleep(10 * time.Second) 在 Go 1.9 版本代码输出 4 5 4 5 4 5 4 5 4 5 从作用域的角度看 i 和 v 的作用域在 for 循环外,协程共用变量 i 和 v。在协程等待时,变量已经更新为 i=4,v=5,最终每个协程输出 4 和 5。 协程输出的并不都是最终值,主要原因是协程调度运行相比循环变量更新要慢。将 m 更新为长度为 5500 的切片就能看到协程打印的值是随机的。 解决方式可以从作用域入手,主要有两种: 方式 1 var m = [...]int{1, 2, 3, 4, 5} for i, v := range m { go func() { iCopy := i vCopy := v time.Sleep(3 * time.Second) fmt.Println(iCopy, vCopy) }() } time.Sleep(10 * time.Second) 将循环变量副本拷贝给闭包内变量,每个闭包(协程)有自己的副本变量。就是把作用域缩小到协程内了。 方式 2 var m = [...]int{1, 2, 3, 4, 5} for i, v := range m { go func(i, v int) { time.Sleep(3 * time.Second) fmt.Println(i, v) }(i, v) } time.Sleep(10 * time.Second) 将变量传给协程,实际也是缩小作用域。 变量赋值 Go 中变量赋值传递的是变量的副本。我们看以下代码示例: var a = [5]int{1, 2, 3, 4, 5} var r [5]int fmt.Println(a, r) for i, v := range a { if i == 0 { a[1] = 12 a[2] = 13 } r[i] = v } fmt.Println(a, r) 代码输出 [1 12 13 4 5] [1 2 3 4 5] 而不是期望的 [1 12 13 4 5] [1 12 13 4 5],发生什么了呢? 重点在于 for i, v := range a 的 a 并不是循环体外的 a 数组。这里的 a(后称 a') 是循环体外 a 数组的副本。 由于复制的是 a 的副本,所以在循环内给 a 赋值并不会影响到 a' 的值。 那么如果把 a 数组换成切片会怎么样呢?示例代码如下: var a = []int{1, 2, 3, 4, 5} var r [5]int for i, v := range a { if i == 0 { a[1] = 12 a[2] = 13 } r[i] = v } fmt.Println(a, r) 代码输出 [1 12 13 4 5] [1 12 13 4 5]。 这是因为虽然复制的是 a 切片的副本,但是底层的数组是一样的。a' 和 a 底层指向一样的数组。 继续我们在循环体内给 a 扩容会怎么样呢?示例代码如下: var a = []int{1, 2, 3, 4, 5} var r = make([]int, 0) for i, v := range a { if i == 0 { a = append(a, 6, 7) fmt.Println(len(a)) } r = append(r, v) } fmt.Println("r = ", r) fmt.Println("a = ", a) 代码输出: r = [1 2 3 4 5] a = [1 2 3 4 5 6 7] 这是因为切片对应的底层结构体为: //$GOROOT/src/runtime/slice.go type slice struct { array unsafe.Pointer len int cap int } 由于复制的是切片的副本,复制时 a' 还是用的原来的切片长度 len 和 cap。 Go 循环跳出 给出示例代码如下: 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(5 * time.Second) 不同于期望示例中的 break 并不会退出,Go语言规范中明确规定break语句(不接label的情况下)结束执行并跳出的是同一函数内break语句所在的最内层的for、switch或select的执行。要想在 select 中退出 for 循环可以使用 break [label] 语法。如下: go func() { loop: // 添加退出 label for { select { case <-time.After(time.Second): fmt.Println("tick") case <-exit: fmt.Println("exiting...") break loop } } fmt.Println("exit!") }()