Go 中的Defer,Panic 和 Recover 控制流

1,185 阅读6分钟

Go 中的Defer,Panic 和 Recover 控制流

Go 语言有通用的控制流机制:if, for, switch, goto。还有 go 语句将代码运行在不同的 goroutine 中。这里我要讨论下几个不是那么常用的控制流:defer, panicrecover

defer

一个 defer 语句会将一个函数调用加到特定的列表中。在某个函数返回的时候,这个列表中存储的函数将被执行。Defer 通常用于简化那些有很多清理动作的函数。
例如,下面这个函数打开了两个文件,然后将其中一个文件的内容复制到另一个中:

func CopyFile(dstName, srcName string) (written int64, err error) {
  src, err := os.Open(srcName)
  if err != null {
    return
  }
  
  dst, err := os.Create(dstName)
  if err != nil {
    return
  }

  written, err = io.Copy(dst, src)
  dst.Close()
  src.Close()
  return
}

这样写能正常运行,但是有个 bug。如果函数 os.create 调用失败,整个函数将直接返回,而源文件却没有关闭。修复这个 bug 非常简单,只要在第二个 return 语句前加上 src.Close 即可。但如果这个函数变得非常复杂,那这个 bug 就很难被发现和解决。通过引入 defer 语言,我们总是能保证打开的文件能关闭:

func CopyFile(dstName, srcName string) (written int64, err error) {
  src, err := os.Open(srcName)
  if err != null {
    return
  }
  defer src.Close()
  
  des, err := os.Create(dstName)
  if err != nil {
    return
  }
  def dst.Close()

  return io.Copy(dst, src)
}

Defer 语句能让我们在打开一个文件之后马上考虑关闭的情况,而且是有保证的,函数中无论有多少个 return 语句,打开的文件总能关闭。
Defer 语句的行为是非常简单和可预测的,满足以下三条简单规则:

  1. Derfer 语句中函数的参数是在 defer 语句声明时确定的。

下面例子中,表达式i的值是在 defer 声明出确定的。函数返回后,defer 语句输出为:0。

func a() {
  i := 0
  defer fmt.Println(i)
  i++
  return
}
  1. 当函数返回后,defer 声明的函数按照后进先出 (Last In First Out) 的顺序执行。

下面函数输出是:3210

func b() {
  for i:=0; i < 4; i++ {
    defer fmt.print(i)
  }
}
  1. 如果函数返回的是命名返回值(named return values),defer 声明的函数调用可以读写这个返回值。

下面例子中,函数返回后,defer 声明的函数改变了返回变量 i 的值。因此下面函数返回值为:2

func c() (i int) {
  defer func() { i++ }()
  return 1
}

通过这个特性,能方便的修改函数的错误返回。后面我们会看到一个简单的例子。

Panic

Panic 是一个内建 (bulit-in) 函数,能停止函数正常的控制流并进入 panicking 状态。但某个函数F调用了 panic 函数,函数F停止执行,F中声明的 defer 函数开始执行,之后返回到F的调用者。对于调用者来说,调用F函数就如同调用了 panic 函数。该过程继续向上移动,直到当前goroutine 中的所有函数都返回,此时程序崩溃。Panics 可能源于 panic 函数调用,也可能是运行时错误,比如数组越界。

Recover

Recover 是一个内建函数,能将状态为 panicking 的 goroutine 恢复到正常的控制流。Recover 只能在声明为 defer 的函数才有用。正常情况下调用 recover 函数将返回 nil 且没有任何效果。如果当前 goroutine 为 panicking 状态,调用 recover 函数将捕获 panic 的值并且回到正常控制流。
下面例子演示 panic 和 defer 机制:

package main

import "fmt"

func main() {
  f()
  fmt.Println("Returned normally from f.")
}

func f() {
  defer func() {
    if r := recover(); r != nil {
      fmt.Println("Recovered in f", r)
    }
  }()
  fmt.Println("Calling g.")
  g(0)
  fmt.Println("Returned normally from g.")
}

func g(i int) {
  if i > 3 {
    fmt.Println("Panicking!")
    panic(fmt.Sprintf("%v", i))
  }
  defer fmt.Println("Defer in g", i)
  fmt.Println("Printing in g", i)
  g(i + 1)
}

函数g接受一个参数 int i,如果 i 大于 3 则抛出 panic,否则参数加 1,再次调用自己。函数f defer 了一个函数,这个函数 recover 了 panic 然后输出收到的值(如果不为 nil )。现在请读者停下来,试着分析下上面程序的输出。
这个程序的输出:

Calling g.
Printing in g 0
Printing in g 1
Printing in g 2
Printing in g 3
Panicking!
Defer in g 3
Defer in g 2
Defer in g 1
Defer in g 0
Recovered in f 4
Returned normally from f.

如果移除f中的 defer 函数,那 panic 将不会被捕获,直到到达当前 goroutine 的调用栈顶,程序终止。修改后的输出为:

Calling g.
Printing in g 0
Printing in g 1
Printing in g 2
Printing in g 3
Panicking!
Defer in g 3
Defer in g 2
Defer in g 1
Defer in g 0
panic: 4
 
panic PC=0x2a9cd8
[stack trace omitted]

真实使用 panic 和 recover 的情况,可以查看 Go 标准库中 json package 的实现。它使用一些列递归函数来解析 JSON。但遇到非法 JSON 时,解析器抛出 panic,函数栈上面的函数将会捕获这个 panic,返回一个适当的错误值。(查看 'error' 和 'unmarshal' 方法关于解码状态在decode.go中)
Go 库中的约定是即使包在内部使用 panic,其外部 API 仍然会显示明确的错误返回值。

defer 的其他的用法包括释放互斥锁(mutex):

mu.Lock()
defer mu.Unlock()

打印结尾:

printHeader()
defer printFooter()

当然还有跟多其他的用法。

总结

总的来说,defer 语句(无论有没有 panic 或 recover )提供了一个通用和强大的流控制机制。它可用于模拟由其他编程语言中的专用结构实现的许多功能。自己试试看。

译者总结

  • 在传统的顺序、分支(if,switch)、循环(for,while)控制流的基础上,Go 新增了 defer 控制流。defer 控制流类似于 OOP 中的析构函数,它们都为 cleaup 而生,而且执行顺序都与正常情况相反(从下到上,从里到外)。
    但 defer 是基于函数的,也就是说每个 defer 语句后面都是一个函数调用,如果要实现某些复杂功能,必须得 defer 一个没有参数和返回值的匿名函数,这种写法其实很没有必要,不如写个语句块方便。例如:
// 原始 defer 基于函数,下面 defer 一个无参数和返回值的匿名函数,并立即调用
func f() {
  defer func() {
    if r := recover(); r != nil {
      fmt.Println("Recovered in f", r)
    }
  }()
  fmt.Println("Calling g.")
  g(0)
  fmt.Println("Returned normally from g.")
}
// 改为基于语句块会更简洁
func f() {
  defer {
    if r := recover(); r != nil {
      fmt.Println("Recovered in f", r)
    }
  }
  ...
}
  • Panic 与 Recover 用来抛出错误和捕获错误。值得注意是 Panic 和 Error 的区别,两者都是程序出现了错误,但是 Panic 比 Error 严重。当某个错误发生,如果这种情况下程序应该立即终止,则用 Panic,不然应该使用 Error。