先说明一下: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 mapnil 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) // panicnil 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代码少踩坑,多产出。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录