听说,99% 的 Go 程序员都被 defer 坑过

简介: 听说,99% 的 Go 程序员都被 defer 坑过

原文链接:听说,99% 的 Go 程序员都被 defer 坑过


先声明:我被坑过。


之前写 Go 专栏时,写过一篇文章:Go 专栏|错误处理:defer,panic 和 recover。有小伙伴留言说:道理都懂,但还是不知道怎么用,而且还总出现莫名奇妙的问题。


出问题就对了,这个小东西坏的很,一不留神就出错。


所以,面对这种情况,我们今天就不讲道理了。直接把我珍藏多年的代码一把梭,凭借多年踩坑经历和写 BUG 经验,我要站着把这个坑迈过去。


一、


先来一个简单的例子热热身:


package main
import (
    "fmt"
)
func main() {
    defer func() {
        fmt.Println("first")
    }()
    defer func() {
        fmt.Println("second")
    }()
    fmt.Println("done")
}
复制代码


输出:


done
second
first
复制代码


这个比较简单,defer 语句的执行顺序是按调用 defer 语句的倒序执行。


二、


看看这段代码有什么问题?


for _, filename := range filenames {
    f, err := os.Open(filename)
    if err != nil {
        return err
    }
    defer f.Close()
}
复制代码


这段代码其实很危险,很可能会用尽所有文件描述符。因为 defer 语句不到函数的最后一刻是不会执行的,也就是说文件始终得不到关闭。所以切记,一定不要在 for 循环中使用 defer 语句。


那怎么优化呢?可以将循环体单独写一个函数,这样每次循环的时候都会调用关闭函数。


如下:


for _, filename := range filenames {
    if err := doFile(filename); err != nil {
        return err
    }
}
func doFile(filename string) error {
    f, err := os.Open(filename)
    if err != nil {
        return err
    }
    defer f.Close()
}
复制代码


三、


看看这三个函数的输出结果是什么?


package main
import (
  "fmt"
)
func a() (r int) {
  defer func() {
    r++
  }()
  return 0
}
func b() (r int) {
  t := 5
  defer func() {
    t = t + 5
  }()
  return t
}
func c() (r int) {
  defer func(r int) {
    r = r + 5
  }(r)
  return 1
}
func main() {
  fmt.Println("a = ", a())
  fmt.Println("b = ", b())
  fmt.Println("c = ", c())
}
复制代码


公布答案:


a =  1
b =  5
c =  1
复制代码


你答对了吗?


说实话刚开始看到这个结果时,我是相当费解,完全不知道怎么回事。


但可以看到,这三个函数都有一个共同特点,它们都有一个命名返回值,并且都在函数中引用了这个返回值。


引用的方式分两种:分别是闭包和函数参数。


先看 a() 函数:


闭包通过 r++ 修改了外部变量,返回值变成了 1。


相当于:


func aa() (r int) {
  r = 0
  // 在 return 之前,执行 defer 函数
  func() {
    r++
  }()
  return
}
复制代码

再看 b() 函数:


闭包内修改的只是局部变量 t,而外部变量 t 不受影响,所以还是返回 5。


相当于:


func bb() (r int) {
  t := 5
  // 赋值
  r = t
  // 在 return 之前,执行 defer 函数
  // defer 函数没有对返回值 r 进行修改,只是修改了变量 t
  func() {
    t = t + 5
  }()
  return
}
复制代码


最后是 c 函数:


参数传递是值拷贝,实参不受影响,所以还是返回 1。


相当于:


func cc() (r int) {
  // 赋值
  r = 1
  // 这里修改的 r 是函数形参的值
  // 值拷贝,不影响实参值
  func(r int) {
    r = r + 5
  }(r)
  return
}
复制代码


那么,为了避免写出这么令人意外的代码,最好在定义函数时就不要使用命名返回值。或者如果使用了,就不要在 defer 中引用。


再看下面两个例子:


func d() int {
  r := 0
  defer func() {
    r++
  }()
  return r
}
func e() int {
  r := 0
  defer func(i int) {
    i++
  }(r)
  return 0
}
复制代码
d =  0
e =  0
复制代码


返回值符合预期,再也不用绞尽脑汁猜了。


四、


defer 表达式的函数如果在 panic 后面,则这个函数无法被执行。


func main() {
    panic("a")
    defer func() {
        fmt.Println("b")
    }()
}
复制代码


输出如下,b 没有打印出来。


panic: a
goroutine 1 [running]:
main.main()
  xxx.go:87 +0x4ce
exit status 2
复制代码


而如果 defer 在前,则可以执行。


func main() {
  defer func() {
    fmt.Println("b")
  }()
  panic("a")
}
复制代码


输出:


b
panic: a
goroutine 1 [running]:
main.main()
    xxx.go:90 +0x4e7
exit status 2
复制代码


五、


看看下面这段代码的执行顺序:


func G() {
  defer func() {
    fmt.Println("c")
  }()
  F()
  fmt.Println("继续执行")
}
func F() {
  defer func() {
    if err := recover(); err != nil {
      fmt.Println("捕获异常:", err)
    }
    fmt.Println("b")
  }()
  panic("a")
}
func main() {
  G()
}
复制代码


顺序如下:


  1. 调用 G() 函数;


  1. 调用 F() 函数;


  1. F() 中遇到 panic,立刻终止,不执行 panic 之后的代码;


  1. 执行 F()defer 函数,遇到 recover 捕获错误,继续执行 defer 中代码,然后返回;


  1. 执行 G() 函数后续代码,最后执行 G()defer 函数。


输出:


捕获异常: a
b
继续执行
c
复制代码


五、


看看下面这段代码的执行顺序:


func G() {
  defer func() {
    if err := recover(); err != nil {
      fmt.Println("捕获异常:", err)
    }
    fmt.Println("c")
  }()
  F()
  fmt.Println("继续执行")
}
func F() {
  defer func() {
    fmt.Println("b")
  }()
  panic("a")
}
func main() {
  G()
}
复制代码


顺序如下:


  1. 调用 G() 函数;


  1. 调用 F() 函数;


  1. F() 中遇到 panic,立刻终止,不执行 panic 之后的代码;


  1. 执行 F()defer 函数,由于没有 recover,则将 panic 抛到 G() 中;


  1. G() 收到 panic 则不会执行后续代码,直接执行 defer 函数;


  1. defer 中捕获 F() 抛出的异常 a,然后继续执行,最后退出。


输出:


b
捕获异常: a
c
复制代码


六、


看看下面这段代码的执行顺序:


func G() {
  defer func() {
    fmt.Println("c")
  }()
  F()
  fmt.Println("继续执行")
}
func F() {
  defer func() {
    fmt.Println("b")
  }()
  panic("a")
}
func main() {
  G()
}
复制代码


顺序如下:


  1. 调用 G() 函数;


  1. 调用 F() 函数;


  1. F() 中遇到 panic,立刻终止,不执行 panic 之后的代码;
  2. 执行 F()defer 函数,由于没有 recover,则将 panic 抛到 G() 中;


  1. G() 收到 panic 则不会执行后续代码,直接执行 defer 函数;


  1. 由于没有 recover,直接抛出 F() 抛过来的异常 a,然后退出。


输出:


b
c
panic: a
goroutine 1 [running]:
main.F()
  xxx.go:90 +0x5b
main.G()
  xxx.go:82 +0x48
main.main()
  xxx.go:107 +0x4a5
exit status 2
复制代码


七、


看看下面这段代码的执行顺序:


func G() {
  defer func() {
    // goroutine 外进行 recover
    if err := recover(); err != nil {
      fmt.Println("捕获异常:", err)
    }
    fmt.Println("c")
  }()
  // 创建 goroutine 调用 F 函数
  go F()
  time.Sleep(time.Second)
}
func F() {
  defer func() {
    fmt.Println("b")
  }()
  // goroutine 内部抛出panic
  panic("a")
}
func main() {
  G()
}
复制代码


顺序如下:


  1. 调用 G() 函数;


  1. 通过 goroutine 调用 F() 函数;


  1. F() 中遇到 panic,立刻终止,不执行 panic 之后的代码;


  1. 执行 F()defer 函数,由于没有 recover,则将 panic 抛到 G() 中;


  1. 由于 goroutine 内部没有进行 recover,则 goroutine 外部函数,也就是 G() 函数是没办法捕获的,程序直接崩溃退出。


输出:


b
panic: a
goroutine 6 [running]:
main.F()
  xxx.go:96 +0x5b
created by main.G
  xxx.go:87 +0x57
exit status 2
复制代码


八、


最后再说一个 recover 的返回值问题:


defer func() {
  if err := recover(); err != nil {
    fmt.Println("捕获异常:", err.Error())
  }
}()
panic("a")
复制代码


recover 返回的是 interface {} 类型,而不是 error 类型,所以这样使用的话会报错:


err.Error undefined (type interface {} is interface with no methods)
复制代码


可以这样来转换一下:


defer func() {
  if err := recover(); err != nil {
    fmt.Println("捕获异常:", fmt.Errorf("%v", err).Error())
  }
}()
panic("a")
复制代码


或者直接打印结果:


defer func() {
  if err := recover(); err != nil {
    fmt.Println("捕获异常:", err)
  }
}()
panic("a")
复制代码


输出:


捕获异常: a
复制代码


以上就是本文的全部内容,其实写过其他的语言的同学都知道,关闭文件句柄,释放锁等操作是很容易忘的。而 Go 语言通过 defer 很好地解决了这个问题,但在使用过程中还是要小心。


本文总结了一些容踩坑的点,希望能够帮助大家少写 BUG,如果大家觉得有用的话,欢迎点赞和转发。



文章中的脑图和源码都上传到了 GitHub,有需要的同学可自行下载。


源码地址:




目录
相关文章
|
2月前
|
程序员 Go PHP
为什么大部分的 PHP 程序员转不了 Go 语言?
【9月更文挑战第8天】大部分 PHP 程序员难以转向 Go 语言,主要因为:一、编程习惯与思维方式差异,如语法风格和编程范式;二、学习成本高,需掌握新知识体系且面临项目压力;三、职业发展考量,现有技能价值及市场需求不确定性。学习新语言虽有挑战,但对拓宽职业道路至关重要。
86 10
|
6月前
|
Go
Go语言中defer的执行顺序详解
【2月更文挑战第22天】
110 4
|
Cloud Native Go
GO 中的 defer 有哪些注意事项?上
GO 中的 defer 有哪些注意事项?上
|
Go
Go语言编程的一大杀器!详解defer语句
Go语言编程的一大杀器!详解defer语句
70 0
|
存储 缓存 安全
90%的Go语言程序员map遍历方式都是错的
90%的Go语言程序员map遍历方式都是错的
131 0
|
2月前
|
算法 程序员 Go
PHP 程序员学会了 Go 语言就能唬住面试官吗?
【9月更文挑战第8天】学会Go语言可提升PHP程序员的面试印象,但不足以 solely “唬住” 面试官。学习新语言能展现学习能力、拓宽技术视野,并增加就业机会。然而,实际项目经验、深入理解语言特性和综合能力更为关键。全面展示这些方面才能真正提升面试成功率。
57 10
|
3月前
|
存储 Go
掌握 Go 语言的 defer 关键字
掌握 Go 语言的 defer 关键字
|
3月前
|
Go
实验深度理解Go中try...catch...的panic、defer、recover用法
文章通过实验代码演示了Go语言中如何使用panic、defer和recover函数来模拟try...catch...的异常处理机制,并详细解释了每个函数的作用和在异常处理中的使用场景。
35 0
|
6月前
|
Go 开发者
Golang深入浅出之-Go语言 defer、panic、recover:异常处理机制
Go语言中的`defer`、`panic`和`recover`提供了一套独特的异常处理方式。`defer`用于延迟函数调用,在返回前执行,常用于资源释放。它遵循后进先出原则。`panic`触发运行时错误,中断函数执行,直到遇到`recover`或程序结束。`recover`在`defer`中捕获`panic`,恢复程序执行。注意避免滥用`defer`影响性能,不应对可处理错误随意使用`panic`,且`recover`不能跨goroutine捕获panic。理解并恰当使用这些机制能提高代码健壮性和稳定性。
134 2
|
6月前
|
Java Go 区块链
【Go语言专栏】Go语言中的延迟执行与defer语句
【4月更文挑战第30天】Go语言的延迟执行与defer语句用于资源释放和错误处理。defer通过关键字定义,函数返回时执行,顺序与定义相反。参数在定义时求值。应用包括资源释放、错误处理、成对操作和函数包装,是Go编程的关键特性。
55 0