[Journey with golang] 7. Traps

golang语法简单,类型系统短小精悍,但也不是完美无瑕的。golang也有一些特性让初学者感到困惑。本文介绍golang的语言陷阱,并介绍golang源码书写的惯用方法。

golang支持多值赋值,在函数或方法内部也支持短变量声明并复制,同时golang依据类型字面量的值能够自动进行类型推断。可以一次性声明多个变量,并可以在声明时赋值,而且可以省略类型,但必须遵守一定的规则要求。具体看下面的示例:

 1 package main
 2 
 3 import "fmt"
 4 
 5 func main() {
 6     // 相同类型的变量可以在末尾加上类型
 7     var a, b int = 1, 2
 8     // 如果不带上类型,编译器可以自动推断
 9     var c, d, e = "haha", 3.14, 2
10     // 不同类型的变量声明和隐式初始化可以使用如下语法
11     var (
12         x int
13         y string
14     )
15     // 注意,多值赋值语句中每个变量后面都不能带上类型
16     fmt.Println(a, b, c, d, e, x, y)
17 }

多值赋值有两种格式。一种是右边是一个返回多值的表达式,可以说返回多值的函数调用,也可以是range对map、slice等函数的操作,还可以是类型断言。例如:

 1 package main
 2 
 3 import "fmt"
 4 
 5 func f() (a, b int) { return 1, 2 }
 6 
 7 func main() {
 8     // 函数调用
 9     x, y := f()
10     fmt.Println(x, y)
11     // range表达式
12     a := []int{5, 4, 3, 2, 1}
13     for index, val := range a {
14         fmt.Printf("%d:%d ", index, val)
15     }
16     fmt.Println()
17     // type assertion
18     // v, ok := i.(xxxx)
19 }

另一种是复制的左边操作数和右边的单一返回值的表达式的个数一样,逐个从左往右对左边的操作数赋值。例如: a, b, c := 1, 2, 3 

多值赋值看似简化了代码,但相互引用会产生让人困惑的结果。关键是要理解多值赋值的语义,才能消除这种困惑。多值赋值包含两层语义:

  1. 对左侧操作数中的表达式、索引值进行计算和确定,首先确定左侧的操作数的地址,然后对右侧的赋值表达式进行计算。如果发现右侧的表达式计算引用了左侧的变量,则创建临时变量进行值拷贝,最后完成计算
  2. 从左到右进行赋值

看下面的实例:

 1 package main
 2 
 3 import "fmt"
 4 
 5 func main() {
 6     x := []int{1, 2, 3}
 7     i := 0
 8     i, x[i] = 1, 2
 9     fmt.Println(i, x)
10 
11     x = []int{1, 2, 3}
12     i = 0
13     x[i], i = 2, 1
14     fmt.Println(i, x)
15 
16     x = []int{1, 2, 3}
17     i = 0
18     x[i], i = 2, x[i]
19     fmt.Println(i, x)
20 
21     x[0], x[0] = 1, 2
22     fmt.Println(x[0])
23 }

执行结果为:

1 [2 2 3]
1 [2 2 3]
1 [2 2 3]
2

短变量的声明和赋值是指在golang函数或类型方法内部使用“:=”声明并初始化变量,支持多值赋值。短变量的声明和赋值的语法要约如下:

  1. 使用":="操作符,变量的定义和初始化同时完成
  2. 变量名后不允许跟任何类型名,go编译器完全靠右边的值进行推导
  3. 支持多值短变量声明赋值
  4. 只能用在函数和类型方法的内部

短变量的声明和赋值中最容易产生歧义的是多值短变量的声明和赋值,这个问题的根源是golang的语法允许多值短变量声明和赋值的多个变量中,只要有一个是新变量就可以使用":="进行赋值。也就是说,在多值短变量的声明和赋值时,至少有一个变量是新创建的局部变量,其他的变量可以复用以前的变量,而不是新创建变量并赋值(仅仅只是赋值)。下面来看一个示例:

 1 package main
 2 
 3 import "fmt"
 4 
 5 var n int
 6 
 7 func foo() (int, error) {
 8     return 1, nil
 9 }
10 
11 // 访问全局变量n
12 func g() {
13     fmt.Println(n)
14 }
15 
16 func main() {
17     // 此时main函数作用域里面没有n,所以创建新的局部变量n
18     n, _ := foo()
19     // 访问全局变量n
20     g() // 0
21     // 访问的是局部变量n
22     fmt.Println(n) // 1
23 }

通过上面的分析得知,形如 a, b := va, vb 这样的语句:

  1. 如果想编译通过,则a和b中至少有一个是新定义的局部变量
  2. 如果在赋值语句中已经存在一个局部变量a,则赋值语句不会创建新变量a,而是直接把va赋值给a
  3. 如果不存在局部变量a、b,但存在全局变量a、b,则该赋值语句会创建新的局部变量a、b,并把va、vb赋值给a和b。全局变量a、b被屏蔽

与“:=”相比,“=”不会声明并创建新辩论,而是在当前赋值语句所在的作用域由内向外逐层去搜寻变量,如果没有搜索到相同变量名,则报编译错误。如何避免":="的副作用呢,一个办法是先声明变量,再用"="赋值。多值短变量声明赋值的最佳使用场景是在错误处理上,例如:

1 a, err := f()
2 if err != nil {
3     ...
4 }
5 
6 // 此时err可以说已存在的err变量,只是重新赋值了
7 b, err := g()

关于range复用临时变量,先看一个例子:

 1 package main
 2 
 3 import (
 4     "fmt"
 5     "sync"
 6 )
 7 
 8 func main() {
 9     wg := sync.WaitGroup{}
10     si := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
11     for i := range si {
12         wg.Add(1)
13         go func() {
14             fmt.Println(i)
15             wg.Done()
16         }()
17     }
18     wg.Wait()
19 }

程序并不会像我们预期那样遍历切片si,有两点原因导致这个问题:

  1. for range下的迭代变量i是共用的
  2. main函数所在的goroutine和后续启动的goroutine存在竞争关系

range在迭代写的过程组,多个goroutine并发地读。所以正确的写法是使用函数参数做一个数据复制,而不是闭包:

 1 package main
 2 
 3 import (
 4     "fmt"
 5     "sync"
 6 )
 7 
 8 func main() {
 9     wg := sync.WaitGroup{}
10     si := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
11     for i := range si {
12         wg.Add(1)
13         // 这里有一个实参到形参的值拷贝
14         go func(a int) {
15             fmt.Println(a)
16             wg.Done()
17         }(i)
18     }
19     wg.Wait()
20 }

执行结果如下:

9
8
3
0
4
6
1
2
5
7

这个运行结果符合预期,这不能说是缺陷,而是golang设计者为了性能而选择的一种设计方案,因为大多数情况下for循环块里的代码都是在同一个goroutine里运行的,为了避免空间的浪费和gc的压力,复用了range迭代临时变量。语言使用者明白这个规约,在for循环下调用并发时要复制迭代变量后再使用,不要直接引用for迭代变量。

之前介绍过defer,接下来讨论defer带来的副作用。第一个副作用是对返回值的影响,第二个副作用是对性能的影响。defer中如果引用了函数的返回值,则因引用形式不同会导致不同的结果,这些结果往往给初学者造成很大的困惑,我们来看一个例子:

 1 package main
 2 
 3 import "fmt"
 4 
 5 func f1() (r int) {
 6     defer func() {
 7         r++
 8     }()
 9     return 0
10 }
11 
12 func f2() (r int) {
13     t := 5
14     defer func() {
15         t = t + 5
16     }()
17     return t
18 }
19 
20 func f3() (r int) {
21     defer func(r int) {
22         r = r + 5
23     }(r)
24     return 1
25 }
26 
27 func main() {
28     fmt.Println("f1 =", f1()) // 1
29     fmt.Println("f2 =", f2()) // 5
30     fmt.Println("f3 =", f3()) // 1
31 }

是什么原因导致这个结果呢?f1、f2、f3三个函数的共同点就是它们都是带命名返回值的函数,返回值都是变量r。之前介绍函数的时候我们知道:

  1. 函数调用方负责开辟栈空间,包括形参和返回值的空间
  2. 有名的函数返回值相当于函数的局部变量,被初始化为类型的零值

现在分析一下f1。f1中的defer语句后面的匿名函数是对函数返回值r的闭包引用,f1函数的逻辑如下:

  1. r是函数的有名返回值,分配在栈上,其地址又被称为返回值所在栈区。首先r被初始化为0
  2. ”return 0“会复制0到返回值栈区,返回值r被赋值为0
  3. 执行defer语句,由于匿名函数对返回值r是闭包引用,所以r++执行后r变成1
  4. defer语句执行完后返回,此时函数的返回值r仍然为1

同理,分析f2的逻辑:

  1. 返回值r被初始化为0
  2. 引入局部变量t,并初始化为5
  3. 赋值t的值到返回值r所在栈区
  4. defer语句后面的匿名函数是对局部变量t的闭包引用,t的值被设置为10
  5. 函数返回,r仍然为5

最后分析f3的逻辑:

  1. 返回值r被初始化为0
  2. 复制1到函数返回值r所在栈区
  3. 执行defer,defer后面匿名函数使用的是传参引用,在注册defer函数时将函数返回值r作为实参传进去,由于函数调用的是值拷贝,所以defer函数执行后只是形参值变为5,对实参没有任何影响
  4. 函数返回,r仍然为1

类似地,可以编写f4:

1 func f4() (r int) {
2     defer func(r *int) {
3         *r = *r + 5
4     }(&r)
5     return 1
6 }

显然,调用f4会得到6。综上所述,对于带defer的函数返回整体上有三个步骤:

  1. 执行return的值拷贝,将return语句返回的值复制到函数返回值栈区(如果只有一个return,不带任何变量或值,则此步骤不做任何操作)
  2. 执行defer语句,多个defer按照FILO顺序执行
  3. 执行调整RET指令,退出函数

当然,在defer中修改函数返回值不是一种明智的编程方法,在实际编程中应尽可能避免此种情况。还有一种彻底解决该问题的方法是,在定义函数时使用不带返回值名的格式。通过这种方式,defer就不能直接引用返回值的栈区,也就避免了返回值被修改的问题,看如下代码:

 1 package main
 2 
 3 import "fmt"
 4 
 5 func f4() int {
 6     r := 0
 7     defer func() {
 8         r++
 9     }()
10     return r
11 }
12 
13 func f5() int {
14     r := 0
15     defer func(i int) {
16         i++
17     }(r)
18     return 0
19 }
20 
21 func main() {
22     fmt.Println(f4(), f5()) // 0, 0
23 }

从上面的代码可以看出,不管defer如何操作,都不会改变函数return的值,这是一种好的编程方式。

前面已经学习过切片的基础知识,本节深入切片的底层结构和实现,分析多个切片因为共享底层数组而导致不稳定的表现。在介绍切片前先复习数组的概念,毕竟数组是切片实现的基础。看一段简单的代码:

 1 package main
 2 
 3 import "fmt"
 4 
 5 func main() {
 6     // 指定大小的显式初始化
 7     a := [3]int{1, 2, 3}
 8     // 通过"..."由后面的元素个数推断数组大小
 9     b := [...]int{1, 2, 3}
10     // 指定大小,并通过索引值初始化,未显式初始化的元素被置为零值
11     c := [3]int{1: 1, 2: 3}
12     // 指定大小但不显式初始化,数组元素全被置为零值
13     var d [3]int
14     fmt.Println(a, b, c, d)
15 }

数组名无论作为函数实参,还是作为struct嵌入字段,或者数组之间的直接赋值,都是值拷贝,不像C语言数组名因场景不同,可能是值拷贝,也可能是指针传递。C语言中,数组名作为函数实参传递时,直接退化为指针,int a[10] / int a[] / int *a在C语言中都是一个意思,就是一个指向int类型的指针;但是当数组内签到C的struct里面时,又表现的是值拷贝的语义。golang的数组不存在这种歧义,数组的一切都是值拷贝,体现在以下三个方面:

  1. 数组间的直接复制
  2. 数组作为函数参数
  3. 数组内嵌到struct中

下面以一个示例来证明这三条:

package main

import "fmt"

func f(a [3]int) {
    a[2] = 10
    fmt.Printf("%p, %v\n", &a, a)
}

func main() {
    a := [3]int{1, 2, 3}
    // 直接赋值是值拷贝
    b := a
    // 修改a元素并不影响b
    a[2] = 4
    fmt.Printf("%p, %v\n", &a, a)
    fmt.Printf("%p, %v\n", &b, b)
    // 数组作为函数参数仍然是值拷贝
    f(a)
    c := struct {
        s [3]int
    }{
        s: a,
    }
    // 结构是值拷贝,内部的数组也是值拷贝
    d := c
    // 修改c中的数组元素值并不影响a
    c.s[2] = 30
    // 修改d中的数组元素值并不影响c
    d.s[2] = 20
    fmt.Printf("%p, %v\n", &a, a)
    fmt.Printf("%p, %v\n", &c, c)
    fmt.Printf("%p, %v\n", &d, d)
}

运行结果如下:

0xc0000ba000, [1 2 4]
0xc0000ba020, [1 2 3]
0xc0000ba0a0, [1 2 10]
0xc0000ba000, [1 2 4]
0xc0000ba0e0, {[1 2 30]}
0xc0000ba100, {[1 2 20]}

由于数组的大小一旦声明就不可修改,所以实际使用场景并不广泛。下面介绍切片的底层数据结构及多个切片共享底层数组可能导致的困惑。

创建切片的方法如下:

  1. 通过数组创建:array[b:e]创建一个包括e-b个元素的切片,第一个元素是array[b],最后一个元素是array[e-1]
  2. make:通过内置的make函数创建,make([]T, len, cap)中的T是切片元素类型,len是长度,cap是底层数组的容量,cap是可选参数
  3. 直接声明:可以直接声明一个切片,也可以在声明切片的过程中使用字面量进行初始化,直接声明但不进行初始化的切片其值为nil。例如:
    1 var a []int // a is nil
    2 var a []int = []int{1,2,3,4}

通常我们说的切片是一种类似的引用类型,原因是其存放数据的数组是通过指针间接引用的。所以切片名作为函数参数和指针传递是一样的效果。切片的底层数据结构如下:

1 type slice struct{
2     array unsafe.Pointer
3     len   int
4     cap   int
5 }

可以看到切片的数据结构有三个成员,分别是指向底层数组的指针、切片的当前大小和底层数组的大小。当len增长超过cap时,会申请一个更大容量的底层数组,并将数据从老数组复制到新申请的数组中。

make([]int, 0)与var a []int创建的切片是有区别的。前者的切片指针有分配,后者的内部指针为0。示例如下:

 1 package main
 2 
 3 import (
 4     "fmt"
 5     "reflect"
 6     "unsafe"
 7 )
 8 
 9 func main() {
10     var a []int
11     b := make([]int, 0)
12     if a == nil {
13         fmt.Println("a is nil") // is nil
14     } else {
15         fmt.Println("a is not nil")
16     }
17     // 虽然b底层数组大小为0,但切片并不是nil
18     if b == nil {
19         fmt.Println("b is nil")
20     } else {
21         fmt.Println("b is not nil") // not nil
22     }
23     // 使用反射中的SliceHeader来获取切片运行时的数据结构
24     as := (*reflect.SliceHeader)(unsafe.Pointer(&a))
25     bs := (*reflect.SliceHeader)(unsafe.Pointer(&b))
26     fmt.Printf("len = %d, cap = %d, type = %d\n", len(a), cap(a), as.Data) // len=0, cap=0, type=0
27     fmt.Printf("len = %d, cap = %d, type = %d\n", len(b), cap(b), bs.Data) // len=0, cap=0, type=824634236352
28 }

可以看到var a[]int创建的切片是一个nil切片(底层数组没有分配,指针指向nil),make([]int,0)创建的是一个空切片(底层数组指针非空,但底层数组是空的),因为用make创建切片时调用的makeslice函数会调用mallocgc来分配空间,当len和cap都为0时,mallocgc会让指针指向一个固定的zerobase全局变量的地址。

切片可以由数组创建,一个底层数组可以创建多个切片,这些切片共享底层数组,使用append扩展切片过程中可能修改底层数组的元素,间接地影响其他切片的值;也可能发生数组复制重建,共用底层数组的切片,由于其行为不明朗,不推荐使用。下面来看一个示例:

 1 package main
 2 
 3 import (
 4     "fmt"
 5     "reflect"
 6     "unsafe"
 7 )
 8 
 9 func main() {
10     a := []int{0, 1, 2, 3, 4, 5, 6}
11     b := a[0:4]
12     as := (*reflect.SliceHeader)(unsafe.Pointer(&a))
13     bs := (*reflect.SliceHeader)(unsafe.Pointer(&b))
14     // a、b共享底层数组
15     fmt.Printf("a = %v, len = %d, cap = %d, type = %d\n", a, len(a), cap(a), as.Data)
16     fmt.Printf("b = %v, len = %d, cap = %d, type = %d\n", b, len(b), cap(b), bs.Data)
17     b = append(b, 10, 11, 12)
18     // a、b继续共享底层数组,修改b会影响共享的底层数组,间接影响a
19     fmt.Printf("a = %v, len = %d, cap = %d, type = %d\n", a, len(a), cap(a), as.Data)
20     fmt.Printf("b = %v, len = %d, cap = %d, type = %d\n", b, len(b), cap(b), bs.Data)
21     // len(b) == 7, 底层数组容量是7,此时需要重新分配数组,并将原来数组值复制到新数组
22     b = append(b, 13, 14)
23     as = (*reflect.SliceHeader)(unsafe.Pointer(&a))
24     bs = (*reflect.SliceHeader)(unsafe.Pointer(&b))
25     // 可以看到a和b指向底层数组的指针已经不同了
26     fmt.Printf("a = %v, len = %d, cap = %d, type = %d\n", a, len(a), cap(a), as.Data)
27     fmt.Printf("b = %v, len = %d, cap = %d, type = %d\n", b, len(b), cap(b), bs.Data)
28 }

运行结果如下:

a = [0 1 2 3 4 5 6], len = 7, cap = 7, type = 824633827712
b = [0 1 2 3], len = 4, cap = 7, type = 824633827712
a = [0 1 2 3 10 11 12], len = 7, cap = 7, type = 824633827712
b = [0 1 2 3 10 11 12], len = 7, cap = 7, type = 824633827712
a = [0 1 2 3 10 11 12], len = 7, cap = 7, type = 824633827712
b = [0 1 2 3 10 11 12 13 14], len = 9, cap = 14, type = 824633860320

问题总结:多个切片共享一个底层数组,其中一个切片的append操作可能引发如下两种情况。

  1. append追加的元素没有超过底层元素的容量,此种append操作会直接操作共享的底层数组,如果其他切片有引用数组被覆盖的元素,则会导致其他切片的值也隐式地发生变化
  2. append追加的元素加上原来的元素如果超出底层数组的容量,则此种append操作会重新申请新数组,并将原来数组值复制到新数组

由于有这种二义性,所以在使用切片过程中应该尽量避免多个切片共享底层数组,可以使用copy进行显式的复制。

在介绍函数和接口的时候讲过,golang只有一种参数传递规则,那就是值拷贝,这种规则包括两种含义:

  1. 函数参数传递时使用的是值拷贝
  2. 实例赋值给接口变量,接口对实例的引用是值拷贝

有时命名是值拷贝的地方,结果却修改了变量的内容,有以下两种情况:

  1. 直接传递的是指针。指针传递同样是值拷贝,但指针和指针副本的值指向的地址是同一个地方,所以能修改实参值
  2. 参数是复合数据类型,这些符合数据类型内部有指针类型的元素,此时参数的值拷贝并不影响指针的指向

golang复合类型中chan、map、slice、interface内部都是通过指针指向具体的数据,这些类型的变量在作为函数参数传递时,实际上相当于指针的副本。chan的底层数据结构如下:

type hchan struct {
    qcount   uint           // total data in the queue
    dataqsiz uint           // size of the circular queue
    buf      unsafe.Pointer // points to an array of dataqsiz elements
    elemsize uint16
    closed   uint32
    elemtype *_type // element type
    sendx    uint   // send index
    recvx    uint   // receive index
    recvq    waitq  // list of recv waiters
    sendq    waitq  // list of send waiters

    // lock protects all fields in hchan, as well as several
    // fields in sudogs blocked on this channel.
    //
    // Do not change another G's status while holding this lock
    // (in particular, do not ready a G), as this can deadlock
    // with stack shrinking.
    lock mutex
}

从chan在runtime里面的数据结构可知,通道元素的存放地址由buf指针确定,chan内部的数据也是间接通过指针访问的。

map的底层数据结构如下:

// A header for a Go map.
type hmap struct {
    // Note: the format of the hmap is also encoded in cmd/compile/internal/gc/reflect.go.
    // Make sure this stays in sync with the compiler's definition.
    count     int // # live cells == size of map.  Must be first (used by len() builtin)
    flags     uint8
    B         uint8  // log_2 of # of buckets (can hold up to loadFactor * 2^B items)
    noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details
    hash0     uint32 // hash seed

    buckets    unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
    oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
    nevacuate  uintptr        // progress counter for evacuation (buckets less than this have been evacuated)

    extra *mapextra // optional fields
}

从map在runtime里面的数据结构同样可以清楚地看到,其通过buckets指针来间接引用map中的存储结构。

slice的底层数据结构如下:

// SliceHeader is the runtime representation of a slice.
// It cannot be used safely or portably and its representation may
// change in a later release.
// Moreover, the Data field is not sufficient to guarantee the data
// it references will not be garbage collected, so programs must keep
// a separate, correctly typed pointer to the underlying data.
type SliceHeader struct {
    Data uintptr
    Len  int
    Cap  int
}

slice一样用uintptr指针指向底层存放数据的数组。interface的底层数据结构如下:

// emptyInterface is the header for an interface{} value.
type emptyInterface struct {
    typ  *rtype
    word unsafe.Pointer
}

// nonEmptyInterface is the header for an interface value with methods.
type nonEmptyInterface struct {
    // see ../runtime/iface.go:/Itab
    itab *struct {
        ityp *rtype // static interface type
        typ  *rtype // dynamic concrete type
        hash uint32 // copy of typ.hash
        _    [4]byte
        fun  [100000]unsafe.Pointer // method table
    }
    word unsafe.Pointer
}

同样可以看到接口内部通过一个指针指向实例值或地址的副本。

golang的函数名和匿名函数字面量的值有三层含义:

  1. 类型信息,表明其数据类型是函数类型
  2. 函数名代表函数的执行代码的起始位置
  3. 可以通过函数名进行函数调用,函数通用格式为func_name(param_list),在底层执行层面包含以下4部分内容:
    1. 准备好参数
    2. 修改PC值,跳转到函数代码起始位置开始执行
    3. 复制值到函数的返回值栈区
    4. 通过RET返回到函数调用的下一条指令处继续执行

C++里面的引用的函数就是别名,golang规范中并没有引用的概念,但为了论述方便,闭包对外部变量的引用,我们可以认为是建立了一个和外部变量同名的“引用”,该引用和外部变量指向相同的地址。还有一种解释就是golang针对闭包,显式地扩大了形参的可见域,使其在函数返回的闭包中仍然可见。这两种论述都没错,本质上描述的是同一件事情,就是闭包可以访问和改变外部环境中的变量。至于是“同名引用”,还是“扩大作用域”,这些只是对闭包这个语言特性的规范表述。示例如下:

 1 package main
 2 
 3 import "fmt"
 4 
 5 func fa(a int) func(i int) int {
 6     return func(i int) int {
 7         fmt.Println(&a, a)
 8         a = a + i
 9         return a
10     }
11 }
12 
13 func main() {
14     // f是一个闭包,包括对函数fa形式参数a的“同名引用”
15     f := fa(1)
16     fmt.Println(f(1))
17     fmt.Println(f(2))
18 }
19 /* 
20 0xc0000b2010 1
21 2
22 0xc0000b2010 2
23 4 
24 */

下面介绍golang代码样式,包括代码风格和习惯用法两部分。有些规则是强制要求的,有些规则是非强制的“潜规则”。遵照这些规则写出来的代码看起来“地道、纯正”,一看就是Gopher写的。golang在代码干净上有近乎苛刻的要求,主要体现在如下几个方面:

  1. 编译器不能通过未使用的局部变量(包括未使用的标签)
  2. “import”未使用的包同样通不过编译
  3. 所有的控制结构、函数和方法定义的“{”放到行尾,而不能另起一行
  4. 提供go fmt工具格式化代码,使所有的代码风格保持统一

golang对代码的干净和整洁要求到了强迫症的程度,但这是一种好的约束,虽然很多人难以接受。

golang有comma,ok表达式,常见的几个comma,ok表达式如下:

  1. 获取map值。获取map中不存在key的值不会发生异常,而是会返回值类型的零值。如果想确定map中是否存在key,则可以使用获取map值的comma,ok语法。示例如下:
     1 package main
     2 
     3 import "fmt"
     4 
     5 func main() {
     6     m := make(map[string]string)
     7     v, ok := m["some"]
     8     // 通过ok进行判断
     9     if !ok {
    10         fmt.Println("m[some] is nil")
    11     } else {
    12         fmt.Println("m[some] is not nil, it is", v)
    13     }
    14 }
  2. 读取chan值。读取已经关闭的通道,不会阻塞,也不会引起panic,而是返回该通道的零值。怎么判断通道已经关闭?有两种方法,一种方法是使用comma,ok表达式,如果通道已经关闭,则ok的返回值为false,另一种就是range循环迭代。示例如下:
    package main
    
    import "fmt"
    
    func main() {
        c := make(chan int)
        go func() {
            c <- 1
            c <- 2
            close(c)
        }()
        // for {
        //     // 使用comma,ok判断通道是否关闭
        //     v, ok := <-c
        //     if ok {
        //         fmt.Println(v)
        //     } else {
        //         break
        //     }
        // }
    
        // 使用range更加简洁
        for v := range c {
            fmt.Println(v)
        }
    }
  3. 类型断言。接口的类型断言通常可以使用comma,ok语句来确定接口是否绑定某个实例类型,或者判断接口绑定的实例类型是否实现另一个接口。

golang很多重复的引用或声明可以用“()”进行简写。当包中多个相关全局变量声明时,建议使用“()”进行合并声明。

很多包的开发者会在内部实现两个“同名”的函数或方法,一个首字母大写,用于导出API供外部调用;一个首字母小写,用于实现具体逻辑。一般首字母大写的函数调用首字母小写的函数,同时包装一些功能;首字母小写的函数负责更多的底层细节。大部分情况下我们不需要两个同名且只是首字母大小写不一样的函数,只有在函数逻辑很复杂,而且函数在包的内外部都被调用的情况下,才考虑拆分为两个函数进行实现。一方面减少单个函数的复杂性,另一方面进行调用隔离。

多值返回函数里如果有error或bool类型的返回值,则应该将error或bool作为最后一个返回值。这是一种编程风格,没有对错。golang标准库的写法也遵循这样的规则。

posted @ 2020-01-31 18:01  JHSeng  阅读(246)  评论(0编辑  收藏  举报