先说明一下:Go 1.19在本文写作时(2022年4月)尚未发布(Go 1.19于2022年8月发布)。本文基于Go 1.18的实际使用经验,总结Go语言开发中的常见坑和实战经验。这些经验也适用于未来的Go 1.19+版本,帮你写出更健壮的Go代码。

我用Go语言做开发有几年了,从最初的喜欢到后来的深入,踩了不少坑。Go语言以简洁著称,但简洁不代表没有坑。有些坑是语言设计导致的,有些是标准库的行为,有些是并发编程的陷阱。

今天总结一下我在Go开发中踩过的坑和实战经验,帮你少走弯路。

一、nil相关的坑

nil是Go里最容易踩坑的地方之一。

坑一:nil接口不等于nil指针

这是Go里最经典的坑。

var p *MyType = nil
var i interface{} = p

fmt.Println(i == nil) // 输出 false!

为什么?因为interface{}内部有两个字段:类型和值。当你把一个nil指针赋给interface{}时,类型是*MyType,值是nil。所以interface{}本身不是nil。

这个坑在函数返回error时特别常见:

func getError() error {
    var e *MyError = nil
    return e // 返回的error不是nil!
}

err := getError()
fmt.Println(err == nil) // false

正确的做法是,只有在真的有错误时才返回error:

func getError() error {
    var e *MyError = nil
    if e != nil {
        return e
    }
    return nil
}

坑二:nil map可以读但不能写

var m map[string]int // nil map

fmt.Println(m["key"]) // 可以读,返回零值
m["key"] = 1 // panic: assignment to entry in nil map

nil map可以读,但不能写。写的时候会panic。所以使用map之前一定要初始化。

坑三:nil slice可以用,但要注意append

var s []int // nil slice

fmt.Println(len(s)) // 0
s = append(s, 1) // 可以,append会自动分配

nil slice可以用,len和append都没问题。但要注意,nil slice和空slice不是一回事:

var s1 []int      // nil slice
s2 := []int{}     // 空slice

fmt.Println(s1 == nil) // true
fmt.Println(s2 == nil) // false

虽然它们的len都是0,但JSON序列化时不一样:

  • nil slice序列化为null
  • 空slice序列化为[]

如果对JSON格式有要求,要注意这个区别。

坑四:nil channel

var c chan int // nil channel

c <- 1   // 永久阻塞
<-c      // 永久阻塞
close(c) // panic

nil channel的读写会永久阻塞,close会panic。使用channel之前一定要初始化。

二、for range的坑

for range是Go里常用的语法,但也有坑。

坑一:循环变量复用

funcs := []func(){}
for i := 0; i < 3; i++ {
    funcs = append(funcs, func() {
        fmt.Println(i)
    })
}

for _, f := range funcs {
    f() // 输出 3 3 3,不是 0 1 2!
}

因为闭包捕获的是变量i的引用,而不是值。循环结束后i变成了3,所以所有函数都输出3。

解决方法:在循环内创建一个新变量。

for i := 0; i < 3; i++ {
    i := i // 创建新变量
    funcs = append(funcs, func() {
        fmt.Println(i)
    })
}

Go 1.22已经修复了这个问题(循环变量每次迭代都是新的),但1.22之前的版本要注意。

坑二:range slice时修改原slice

s := []int{1, 2, 3}
for _, v := range s {
    s = append(s, v) // 这样写会导致无限循环吗?
}

不会无限循环。因为range在开始时就确定了循环次数(len(s)),后续append不影响循环次数。

但如果在循环中修改slice的元素,是会影响的:

s := []int{1, 2, 3}
for i := range s {
    s[i] = s[i] * 2 // 会修改原slice
}

坑三:range map的顺序不确定

m := map[string]int{"a": 1, "b": 2, "c": 3}
for k, v := range m {
    fmt.Println(k, v) // 每次运行顺序可能不一样
}

Go的map遍历顺序是随机的(故意的,防止依赖顺序)。如果需要有序遍历,要先把key取出来排序。

三、并发编程的坑

Go的并发很强大,但坑也多。

坑一:goroutine泄漏

func leak() {
    ch := make(chan int)
    go func() {
        val := <-ch // 永远等不到数据,goroutine泄漏
        fmt.Println(val)
    }()
    // 没有往ch写数据,也没有close
}

goroutine如果阻塞在channel上,而channel永远不会有数据,这个goroutine就永远不会退出,造成泄漏。

解决方法:

  • 使用context控制goroutine的生命周期
  • 确保channel会被close或写入数据
  • 使用select + timeout避免永久阻塞

坑二:sync.WaitGroup的Add要在goroutine外调用

// 错误的写法
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
    go func() {
        wg.Add(1) // 可能在wg.Wait()之后才执行,导致Wait提前返回
        defer wg.Done()
        // do something
    }()
}
wg.Wait()

// 正确的写法
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
    wg.Add(1) // 在goroutine外调用
    go func() {
        defer wg.Done()
        // do something
    }()
}
wg.Wait()

坑三:mutex的复制

type MyStruct struct {
    mu sync.Mutex
    data int
}

func bad(s MyStruct) { // 值传递,复制了mutex!
    s.mu.Lock()
    defer s.mu.Unlock()
    s.data = 1
}

sync.Mutex不能复制,复制后使用会panic或导致死锁。包含mutex的结构体,一定要用指针传递。

坑四:channel的close

ch := make(chan int)
close(ch)
ch <- 1 // panic: send on closed channel
close(ch) // panic: close of closed channel
  • 向已关闭的channel发送数据,会panic
  • 重复关闭channel,会panic
  • 从已关闭的channel读取,可以读到零值,不会panic

最佳实践:只有发送方关闭channel,而且只关闭一次。

坑五:race condition

var count int
for i := 0; i < 1000; i++ {
    go func() {
        count++ // 数据竞争!
    }()
}

多个goroutine同时修改同一个变量,会有数据竞争。用go run -race可以检测出来。

解决方法:

  • 用sync.Mutex保护
  • 用sync/atomic的原子操作
  • 用channel通信

四、错误处理的坑

坑一:忽略错误

result, _ := someFunction() // 忽略错误

Go的错误处理是显式的,但很多人图省事用_忽略错误。这是很危险的,出错了都不知道。

除非你真的确定错误不重要,否则不要忽略。至少要打个日志。

坑二:错误包装

Go 1.13引入了错误包装(%w):

err := fmt.Errorf("failed to do something: %w", originalErr)

包装后可以用errors.Is和errors.As判断:

if errors.Is(err, os.ErrNotExist) {
    // 文件不存在
}

var pathErr *os.PathError
if errors.As(err, &pathErr) {
    // 是PathError类型
}

不要用字符串比较来判断错误类型,太脆弱了。

坑三:defer中的错误

func foo() error {
    f, err := os.Open("file.txt")
    if err != nil {
        return err
    }
    defer f.Close() // Close的错误被忽略了
    
    // do something
    return nil
}

defer中的f.Close()可能返回错误,但被忽略了。对于写文件的场景,Close时的错误很重要(可能是flush失败)。

如果需要处理Close的错误,可以这样:

func foo() (err error) {
    f, err := os.Open("file.txt")
    if err != nil {
        return err
    }
    defer func() {
        closeErr := f.Close()
        if closeErr != nil && err == nil {
            err = closeErr
        }
    }()
    
    // do something
    return nil
}

五、内存和性能的坑

坑一:slice的内存泄漏

func getFirstN(s []int, n int) []int {
    return s[:n] // 返回的slice引用了原slice的底层数组
}

如果原slice很大,而返回的slice很小,底层数组不会被回收,造成内存泄漏。

解决方法:复制一份:

func getFirstN(s []int, n int) []int {
    result := make([]int, n)
    copy(result, s[:n])
    return result
}

坑二:defer在循环中

for i := 0; i < 1000; i++ {
    f, err := os.Open("file.txt")
    if err != nil {
        return err
    }
    defer f.Close() // 所有defer都在函数返回时才执行,文件句柄泄漏
}

在循环中用defer,所有的Close都要等函数返回时才执行,会导致文件句柄泄漏。

解决方法:把循环体放到一个函数里:

for i := 0; i < 1000; i++ {
    func() {
        f, err := os.Open("file.txt")
        if err != nil {
            return
        }
        defer f.Close() // 函数结束时就执行
        // do something
    }()
}

坑三:字符串和byte的转换

s := "hello"
b := []byte(s) // 会复制内存
s2 := string(b) // 也会复制内存

字符串和[]byte的转换会复制内存。在性能敏感的场景(比如处理大文件),频繁转换会影响性能。

如果不需要修改,可以用类型转换的优化方式(但不安全,不推荐)。大多数情况下,正常转换就行,不用过度优化。

坑四:interface{}的内存占用

var x interface{} = 42 // 不是只占一个int的大小

interface{}内部有两个指针(类型指针和数据指针),在64位系统上占16字节。而且小整数会被分配到堆上。

如果对内存敏感,尽量用具体类型,少用interface{}。

六、标准库的坑

坑一:time.After的内存泄漏

for {
    select {
    case <-time.After(time.Second): // 每次循环都创建一个新的timer,直到触发才释放
        // do something
    case <-done:
        return
    }
}

time.After创建的timer,在触发之前不会被GC回收。如果循环很快,会创建大量timer,造成内存泄漏。

解决方法:用time.NewTicker:

ticker := time.NewTicker(time.Second)
defer ticker.Stop()

for {
    select {
    case <-ticker.C:
        // do something
    case <-done:
        return
    }
}

坑二:http.Client的默认超时

resp, err := http.Get("http://example.com") // 默认没有超时!

http.DefaultClient没有设置超时,如果服务器不响应,请求会永久阻塞。

一定要设置超时:

client := &http.Client{
    Timeout: 10 * time.Second,
}
resp, err := client.Get("http://example.com")

坑三:json的omitempty

type User struct {
    Name string `json:"name,omitempty"`
    Age  int    `json:"age,omitempty"`
}

u := User{Name: "", Age: 0}
data, _ := json.Marshal(u)
fmt.Println(string(data)) // {}

omitempty会忽略零值。如果零值是有意义的(比如Age=0表示年龄未知),就不要用omitempty。

另外,omitempty对false、0、""、nil都生效,要注意。

坑四:context的传递

func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    go func() {
        // 在goroutine中使用ctx
        // 请求结束后,ctx会被cancel,goroutine可能被中断
    }()
}

http.Request的Context在请求结束后会被cancel。如果在goroutine中使用这个ctx,请求结束后goroutine的操作会被取消。

如果goroutine需要在请求结束后继续运行,要用context.WithoutCancel(Go 1.21新增)或创建新的context。

七、Go 1.18泛型的坑

Go 1.18引入了泛型,也带来了一些新的坑。

坑一:泛型函数的性能

泛型函数在编译时会为每个类型生成一份代码。如果用了很多不同的类型,可能导致代码膨胀。

但大多数情况下,这不是问题。Go的编译器会做优化,相同底层类型的会共享代码。

坑二:泛型的约束

type Number interface {
    int | int64 | float64
}

func Add[T Number](a, b T) T {
    return a + b
}

泛型约束要用接口类型,而且约束里的类型必须支持操作符。比如上面的Number类型,都支持+操作符。

如果约束里的类型不支持某个操作符,编译会报错。

坑三:泛型方法

Go 1.18不支持泛型方法(方法有自己的类型参数),只支持泛型函数和泛型类型。

type MyStruct[T any] struct {
    data T
}

// 不支持!
func (m MyStruct[T]) Method[U any](u U) {
    // ...
}

这个限制在未来的版本可能会放开,但1.18/1.19还不行。

八、实战经验和最佳实践

分享一些实战经验。

1. 用go vet和staticcheck

go vet ./...
staticcheck ./...

这些工具能帮你发现很多潜在的问题。一定要在CI里跑。

2. 写测试

Go的测试很方便。养成写单元测试的习惯,覆盖率尽量高。

用table-driven tests写测试:

func TestAdd(t *testing.T) {
    tests := []struct {
        name string
        a, b int
        want int
    }{
        {"1+1", 1, 1, 2},
        {"0+0", 0, 0, 0},
        {"-1+1", -1, 1, 0},
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            if got := Add(tt.a, tt.b); got != tt.want {
                t.Errorf("Add() = %v, want %v", got, tt.want)
            }
        })
    }
}

3. 用context控制超时和取消

所有可能阻塞的操作,都应该接受context参数:

func doSomething(ctx context.Context) error {
    select {
    case <-time.After(time.Second):
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

4. 错误要带上下文

// 不好
return err

// 好
return fmt.Errorf("failed to process user %d: %w", userID, err)

错误带上下文,排查问题时更容易定位。

5. 小函数,单一职责

Go的函数不要太长。一个函数做一件事,保持短小精悍。

长函数难读、难测、难维护。

6. 接口要小

Go的接口要小,最好只有一两个方法。

// 好
type Reader interface {
    Read(p []byte) (n int, err error)
}

// 不好
type BigInterface interface {
    Read(p []byte) (n int, err error)
    Write(p []byte) (n int, err error)
    Close() error
    Seek(offset int64, whence int) (int64, error)
    // ...更多方法
}

小接口更灵活,更容易组合和测试。

九、写在最后

Go语言以简洁著称,但简洁不代表没有坑。很多坑是因为Go的设计哲学(显式错误处理、值语义、并发原语)导致的。

踩坑不可怕,可怕的是踩了坑还不知道。了解这些常见的坑,写代码时多注意,就能避免大部分问题。

至于Go 1.19+,预计会在性能、工具链、标准库等方面有提升,但核心的语言特性和坑不会有太大变化。这些经验依然适用。

2022年了,Go语言越来越成熟,生态也越来越好。希望这篇文章能帮你写出更健壮的Go代码。

最后,用一句话总结:"Go的简洁是给使用者的礼物,但简洁的背后是需要理解的细节。"

祝大家写Go代码少踩坑,多产出。