Go 语言
常见陷阱
语法规范类的坑

语法规范类的坑

没有哪一种编程语言是完美和理想的。

短变量声明的坑

1、短变量声明不总是会声明一个新变量

Go 语言是静态编译型语言,因此在 Go 中使用变量之前一定要先声明变量。采用常规变量声明形式和段变量声明形式均可实现对新变量的声明:

var a int = 5
b, c := "hello", 3.1415
println(a, b, c) // 5 hello 3.1415

如果重复声明变量,Go 语言编译器会报错:

var a int = 5
a := 6 // no new variables on left side of :=

但是下面的代码却可以通过编译的检查:

a := 5
a, b := 6, 7
println(a, b) // 6 7

多变量声明语句并未重新声明一个新变量 a,它只是对之前已经声明的变量 a 进行了重新赋值。

2、短变量声明会导致难于发现的变量遮蔽

即便没有使用短变量声明,变量遮蔽也一样可能发生,如下:

var a int = 5
 
func main() {
    println(a) // 5
    var a int = 6
    println(a) // 6
 
    if a == 6 {
        var a int = 7
        println(a) // 7
    }
}

我们再看一个例子:

func main() {
    var err error
    defer func() {
        if err != nil {
            println(err)
        }
    }()
 
    a, err := someFunc()
    if err != nil {
        return
    }
    println(a)
 
    if a == 1 {
        b, err := touchError()
        if err != nil {
            return
        }
    }
}
 
func someFunc() (int, error) {
    return 1, nil
}
 
func touchError() (int, error) {
    return 2, errors.New("error")
}

在上面的代码中,我们在 main 函数中使用了短变量声明,但是在 if 语句中又使用了短变量声明。这样会导致在 if 语句中声明的变量 err 遮蔽了 main 函数中声明的变量 err,导致在 defer 语句中的 err 变量并不是 main 函数中声明的 err 变量。

所以结果为:

1

而不是预期中的:

1
error

修正这个问题的方法是使用常规变量声明:

func main() {
    var err error
    defer func() {
        if err != nil {
            println(err)
        }
    }()
 
    a, err := someFunc()
    if err != nil {
        return
    }
    println(a)
 
    if a == 1 {
        var b int
        b, err = touchError()
        if err != nil {
            return
        }
    }
}

nil 相关的坑

1、不要是所有以 nil 作为零值的类型都是零值可用的

  • 以 nil 为零值的类型:根据 Go 语言规范,诸如切片(slice)、映射(map)、通道(channel)、接口(interface)、函数(function)和指针(pointer)的零值都是 nil。
  • 零值可用的类型:常见的有 sync.Mutex 和 bytes.Buffer。 Go 原生的切片类型只在 append() 下才可以被划到零值可用的范畴。
var s []int = nil
s = append(s, 1)
println(s) // [1]
 
var strs []string = nil
strs[0] = "go" // panic: runtime error: index out of range
 
var m map[string]int = nil
m["one"] = 1 // panic: runtime error: assignment to entry in nil map
 
var ch chan int = nil
ch <- 1 // fatal error: all goroutines are asleep - deadlock!
 
type MyInterface interface {
    Method()
}
var i MyInterface = nil
i.Method() // panic: runtime error: invalid memory address or nil pointer dereference

2、值为 nil 的接口类型变量并不总等于 nil

我们来看一个例子:

type TxtReader struct {}
 
func (t *TxtReader) Read(p []byte) (n int, err error) {
    // ...
    return 0, nil
}
 
func NewTxtReader(path string) io.Reader {
    var r *TxtReader
    if strings.Contains(path, ".txt") {
        r = new(TxtReader)
    }
    return r
}
 
func main() {
    i := NewTxtReader("test.txt")
    if i == nil {
        println("fail to init txt reader")
        return
    }
    println("init txt reader success")
}

在上面的代码中,我们定义了一个 TxtReader 类型,然后定义了一个 NewTxtReader 函数,该函数返回一个 io.Reader 类型的接口变量。在 main 函数中,我们调用 NewTxtReader 函数,然后判断返回的接口变量是否为 nil。但是实际上,返回的接口变量并不是 nil,因此会输出:

init txt reader success

为了便于理解,将上述例子简化为下面的代码:

var r *TxtReader = nil
var i io.Reader = r
println(i == nil) // false

for range 的坑

1、要注意得到的是序号值还是元素值

当使用 for range 针对切片、数组或字符串进行迭代操作时,迭代变量有两个,第一个是元素在迭代集合中的序号值(从 0 开始),第二个值才是元素值。

s := []int{1, 2, 3}
for i, v := range s {
    println(i, v)
}
// 0 1
// 1 2
// 2 3

2、针对 string 的类型的 for range 迭代不是逐字节迭代

s := "go语言"
for i, v := range s {
    println(i, v)
}
// 0 103
// 1 111
// 2 35821
// 5 35328

输出的结果是字符的 Unicode 编码值。也就是说在 Go 中对字符串运用 for range 操作,每次返回的是一个码点,而不是一个字节。

那么要想进行逐字节迭代,我们需要先将字符串转换为字节类型切片后再使用 for range 对字节类型切片进行迭代。

s := []byte("go语言")
for i, v := range s {
    println(i, v)
}

3、对 map 类型内元素的迭代顺序是随机的

m := map[string]int{"one": 1, "two": 2, "three": 3}
for k, v := range m {
    println(k, v)
}

将上面的代码运行多次,会发现输出的顺序是随机的。

要想有序迭代 map 内的元素,我们需要额外的数据结构支持,比如使用一个切片来有序保存 map 内元素的 key 值:

m := map[string]int{"one": 1, "two": 2, "three": 3}
var keys []string
for k := range m {
    keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
    println(k, m[k])
}

4、在副本上进行迭代

func main() {
	var s = []int{1, 2, 3}
	var r = make([]int, 0)
	fmt.Println(s)
	for i, v := range s {
		if i == 0 {
			s = append(s, 4)
		}
		r = append(r, v)
	}
	fmt.Println(r)
	fmt.Println(s)
}
// 输出
// [1 2 3]
// [1 2 3]
// [1 2 3 4]

我们发现 s 的长度增加了,但是 r 的长度没有增加,说明 for range 是在 s 的副本上进行的迭代。

5、迭代变量是重用的

for i, v := range xxx 这条语句中,i、v 都被称为迭代变量。迭代变量总是会参与到每次迭代的处理逻辑中,

func main() {
    var a = []int{1, 2, 3, 4}
    var wg sync.WaitGroup
    for _, v := range a {
        wg.Add(1)
        go func() {
            defer wg.Done()
            fmt.Println(v)
        }()
    }
    wg.Wait()
}

运行结果都会是 4,因为迭代变量 v 是重用的,所以在 goroutine 执行的时候,v 的值已经是 4 了。

改进代码如下:

func main() {
    var a = []int{1, 2, 3, 4}
    var wg sync.WaitGroup
    for _, v := range a {
        wg.Add(1)
        go func(v int) {
            defer wg.Done()
            fmt.Println(v)
        }(v)
    }
    wg.Wait()
}

在 Go 1.22 版本之后,这个问题不会存在,官方修改了默认行为。

切片的坑

1、对内存的过多使用

基于已有切片创建的新切片与原切片共享底层存储,这样如果原切片占用较大内存,新切片的存在又使原切片内存无法得到释放,这样就会导致内存的过多使用。

func main() {
    var s = make([]int, 1e6)
    var r = s[:100]
    fmt.Println(len(r), cap(r))
    s = nil
    fmt.Println(len(r), cap(r))
}

我们可以通过内建函数 copy 为新切片建立独立的存储空间以避免原切片共享底层存储

func main() {
    var s = make([]int, 1e6)
    var r = make([]int, 100)
    copy(r, s[:100])
    fmt.Println(len(r), cap(r))
    s = nil
    fmt.Println(len(r), cap(r))
}

2、隐匿数据的暴露与切片数据募改

我们来看一个例子:

func main() {
	var b = []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
	var b1 = b[3:7]
	var b2 = b1[:6]
	fmt.Println(b, len(b), cap(b))
	fmt.Println(b1, len(b1), cap(b1))
	fmt.Println(b2, len(b2), cap(b2))
}
 
// 输出
// [1 2 3 4 5 6 7 8 9 10] 10 10
// [4 5 6 7] 4 7
// [4 5 6 7 8 9] 6 7

我们期望 b2 的输出结果是 [4 5 6 7 0 0],但由于 b1 b2 b 三个切片共享底层存储,使得原先切片b 对 b1 的隐匿的数据在切片 b2 中暴露出来。

我们依然可以采用通过内建函数 copy 为新切片建立独立的存储空间以避免原切片共享底层存储。

func main() {
    var b = []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
    var b1 = b[3:7]
    var b2 = make([]int, 6)
    copy(b2, b1)
    fmt.Println(b, len(b), cap(b))
    fmt.Println(b1, len(b1), cap(b1))
    fmt.Println(b2, len(b2), cap(b2))
}

3、新切片与原切片底层存储可能会分家

Go 中的切片支持自动扩容。当然扩容发生时,新切片与原切片底层存储便会出现分家。一旦发生分家,后续对新切片的任何操作都不会影响到原切片:

string 的坑

string 在 Go 中是原生类型,其长度为 string 类型底层数组的字节数量:

func main() {
    var s = "go语言"
    fmt.Println(len(s)) // 8
}

字符串长度并不等于该字符串中的字符个数:

func main() {
    var s = "go语言"
    fmt.Println(len(s)) // 8
    fmt.Println(utf8.RuneCountInString(s)) // 4
}

sting 类型支持下标操作符[],我们可以通过下标操作符读取字符串中的每一字节:

func main() {
	s := "hello"
	for i := 0; i < len(s); i++ {
		fmt.Printf("%c\n", s[i])
	}
}

还有 string 类型是不可改变的,我们不能通过下标操作符对字符串进行修改:

func main() {
    s := "hello"
    s[0] = 'H' // cannot assign to s[0]
}

可以通过将 string 转换为 切片再修改的方案:

func main() {
    s := "hello"
    b := []byte(s)
    b[0] = 'H'
    s = string(b)
    fmt.Println(s) // Hello
}

string 类型是零值是 "",而不是 nil,因此判断一个字符串是否为空应该使用 len(s) == 0s == ""

func main() {
    var s string
    fmt.Println(s == "") // true
    fmt.Println(len(s) == 0) // true
    fmt.Println(s == nil) // invalid operation: s == nil (mismatched types string and nil)
}

switch 的坑

Go 中的 switch 跟别的语言有一个区别,就是不需要显式地使用 break 语句来终止 case 的执行。当一个 case 的执行体执行完毕后,程序会自动终止 switch 语句的执行。

func main() {
    var i = 1
    switch i {
    case 1:
        fmt.Println("1")
    case 2:
        fmt.Println("2")
    case 3:
        fmt.Println("3")
    }
}
// 输出
// 1

但是如果我们想要继续执行下一个 case 的执行体,我们可以使用 fallthrough 关键字:

func main() {
    var i = 1
    switch i {
    case 1:
        fmt.Println("1")
        fallthrough
    case 2:
        fmt.Println("2")
    case 3:
        fmt.Println("3")
    }
}
// 输出
// 1
// 2

goroutine 的坑

1、无法得到 goroutine 的退出状态

利用 channel,我们可以得到 goroutine 的退出状态:

func main() {
    c := make(chan error, 1)
 
    go func() {
        // do something
        c <- nil
    }()
 
    err := <-c
    if err != nil {
        fmt.Println("goroutine exit with error:", err)
    }
}

2、程序随着 main goroutine 的退出而退出,不等待其他 goroutine

func main() {
    println("start")
    go func() {
        time.Sleep(2*time.Second)
        println("goroutine 1")
    }()
 
    go func() {
        time.Sleep(3*time.Second)
        println("goroutine 2")
    }()
 
    println("end")
}
 
// 输出
// start
// end

main goroutine 丝毫没有顾及正在运行的两个 goroutine,它在自己的任务结束后就立即退出了。我们可以通过 sync.WaitGroup 来解决这个问题:

func main() {
    println("start")
    var wg sync.WaitGroup
    wg.Add(2)
 
    go func() {
        defer wg.Done()
        time.Sleep(2*time.Second)
        println("goroutine 1")
    }()
 
    go func() {
        defer wg.Done()
        time.Sleep(1*time.Second)
        println("goroutine 2")
    }()
 
    wg.Wait()
    println("end")
}
// 输出
// start
// goroutine 2
// goroutine 1
// end

3、任何一个 goroutine 的 panic ,如果没有及时捕获,那么整个程序都将退出

解决方法是采用防御性代码,即在每个 goroutine 的启动函数中加上对 panic 的捕获:

func safeRun(g func()) {
    defer func() {
        if err := recover(); err != nil {
            fmt.Println("catch a panic:", err)
        }
    }()
 
    g()
}
 
func main() {
    println("start")
    var wg sync.WaitGroup
    wg.Add(1)
 
    go safeRun(func() {
        defer wg.Done()
        panic("something wrong")
    })
 
    wg.Wait()
    println("end")
}

channel 的坑

1、向已关闭的 channel 发送数据会 panic

func main() {
    c := make(chan int)
    close(c)
    c <- 1 // panic: send on closed channel
}

2、从已关闭的 channel 接收数据不会 panic

func main() {
    c := make(chan int)
    close(c)
    v, ok := <-c
    fmt.Println(v, ok) // 0 false
}

3、从已关闭的 channel 接收数据会一直返回零值

func main() {
    c := make(chan int)
    close(c)
    for i := 0; i < 3; i++ {
        v := <-c
        fmt.Println(v) // 0 0 0
    }
}

方法的坑

1、使用值类型的 receiver 的方法无法改变类型实例的状态

Go 语言的方法(method)很独特,除了参数和返回值,它还拥有一个代表着类型实例的 receiver 参数。这个 receiver 参数可以是值类型或者指针类型。 而采用值类型的 receiver 的方法无法改变类型实例的状态。

我们来看一个例子:

type foo struct {
    name string
    age int
}
 
func (f foo) changeNameByValueReceiver(name string) {
    f.name = name
}
 
func (p *foo) changeNameByPointReceiver(name int) {
    p.name = name
}
 
func main() {
    f := foo{"foo", 20}
    f.changeNameByValueReceiver("bar")
    fmt.Println(f) // {foo 20}
    f.changeNameByPointReceiver("bar")
    fmt.Println(f) // {bar 20}
}

之所以会这样,是因为方法本质上是一个以 receiver 为第一个参数的函数。当 receiver 为值类型时,方法内部对 receiver 的任何修改都是对 receiver 的拷贝进行的,不会影响到原 receiver。

2、值类型实例可以调用采用指针类型的 receiver 的方法,指针类型实例也可以调用采用值类型的 receiver 的方法

type foo struct {}
 
func (foo) methodWithValueReceiver() {
    fmt.Println("value receiver")
}
 
func (*foo) methodWithPointerReceiver() {
    fmt.Println("pointer receiver")
}
 
func main() {
    f := foo{}
    f.methodWithValueReceiver() // value receiver
    f.methodWithPointerReceiver() // pointer receiver
 
    p := &f
    p.methodWithValueReceiver() // value receiver
    p.methodWithPointerReceiver() // pointer receiver
}

这个语法糖的影响范围局限在类型实例调用方法的范畴。当我们将类型实例赋值给某个接口类型变量时,只有真正实现了该接口类型的实例类型才能赋值成功。

break 的坑

我们来看一个例子:

func breakWithForSwitch(b bool) {
    for {
        time.Sleep(1*time.Second)
        fmt.Println("enter for-switch loop")
        switch b {
        case true:
            break
        case false:
            fmt.Println("go on for-switch loop")
        }
    }
    fmt.Println("exit for-switch loop")
}
 
func breakWithForSelect(c <-chan int) {
    for {
        time.Sleep(1*time.Second)
        fmt.Println("enter for-select loop")
        select {
        case <-c:
            break
        default:
            fmt.Println("go on for-select loop")
        }
    }
    fmt.Println("exit for-select loop")
}
 
func main() {
    go func() {
        breakWithForSwitch(true)
    }()
 
    c := make(chan int, 1)
    c <- 11
    breakWithForSelect(c)
}

运行结果:

enter for-switch loop
enter for-select loop
enter for-select loop
go on for-select loop
enter for-switch loop
...

我们看到无论是 switch 还是 select,break 都无法终止最外层的 for 循环的执行,仅仅跳出了 switch 或 select 语句的执行体。 这就是 Go 语言 break 语句的原生语义:不接标签(label)的 break 语句会跳出最内层的 switch、select 或 for 语句的执行体。

如果要跳出最外层的 for 循环,我们可以使用标签(label):

func breakWithForSwitch(b bool) {
    outerLoop:
    for {
        time.Sleep(1*time.Second)
        fmt.Println("enter for-switch loop")
        switch b {
        case true:
            break outerLoop
        case false:
            fmt.Println("go on for-switch loop")
        }
    }
    fmt.Println("exit for-switch loop")
}