听说,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,有需要的同学可自行下载。


源码地址:




目录
相关文章
|
程序员 Go PHP
为什么大部分的 PHP 程序员转不了 Go 语言?
【9月更文挑战第8天】大部分 PHP 程序员难以转向 Go 语言,主要因为:一、编程习惯与思维方式差异,如语法风格和编程范式;二、学习成本高,需掌握新知识体系且面临项目压力;三、职业发展考量,现有技能价值及市场需求不确定性。学习新语言虽有挑战,但对拓宽职业道路至关重要。
238 10
|
Go
Go语言中defer的执行顺序详解
【2月更文挑战第22天】
363 4
|
Cloud Native Go
GO 中的 defer 有哪些注意事项?上
GO 中的 defer 有哪些注意事项?上
112 0
|
Go
Go语言编程的一大杀器!详解defer语句
Go语言编程的一大杀器!详解defer语句
256 0
|
存储 缓存 安全
90%的Go语言程序员map遍历方式都是错的
90%的Go语言程序员map遍历方式都是错的
653 0
|
7月前
|
人工智能 Kubernetes Java
回归开源,两位 Java 和 Go 程序员分享的开源贡献指引
Higress是一个基于Istio和Envoy的云原生API网关,支持AI功能扩展。它通过Go/Rust/JS编写的Wasm插件提供可扩展架构,并包含Node和Java的console模块。Higress起源于阿里巴巴,解决了Tengine配置重载及gRPC/Dubbo负载均衡问题,现已成为阿里云API网关的基础。本文介绍Higress的基本架构、功能(如AI网关、API管理、Ingress流量网关等)、部署方式以及如何参与开源贡献。此外,还提供了有效的开源贡献指南和社区交流信息。
668 33
|
7月前
|
安全 Go
defer关键字:延迟调用机制-《Go语言实战指南》
Go 语言中的 `defer` 是用于延迟执行函数调用的关键字,广泛应用于资源释放、异常捕获和日志记录等场景。它在函数返回前执行,支持栈式后进先出(LIFO)顺序,参数求值时机为声明时而非执行时。常见用法包括文件关闭、锁解锁及结合 `recover` 处理 panic。尽管高效,频繁使用可能带来性能开销,需谨慎处理。总结而言,`defer` 是构建健壮代码的核心工具之一。
|
算法 程序员 Go
PHP 程序员学会了 Go 语言就能唬住面试官吗?
【9月更文挑战第8天】学会Go语言可提升PHP程序员的面试印象,但不足以 solely “唬住” 面试官。学习新语言能展现学习能力、拓宽技术视野,并增加就业机会。然而,实际项目经验、深入理解语言特性和综合能力更为关键。全面展示这些方面才能真正提升面试成功率。
180 10
掌握 Go 语言的 defer 关键字
掌握 Go 语言的 defer 关键字
实验深度理解Go中try...catch...的panic、defer、recover用法
文章通过实验代码演示了Go语言中如何使用panic、defer和recover函数来模拟try...catch...的异常处理机制,并详细解释了每个函数的作用和在异常处理中的使用场景。
179 0