Deferred函数

在 findLinks 的例子中,我们用 http.Get 的输出作为 html.Parse 的输入。只有 url 的内容的确是 HTML 格式的,html.Parse 才可以正常工作,但实际上,url 指向的内容很丰富,可能是图片,纯文本或是其他。将这些格式的内容传递给 html.parse ,会产生不良后果。

下面的例子获取 HTML 页面并输出页面的标题。title 函数会检查服务器返回的 Content-Type 字段,如果发现页面不是 HTML ,将终止函数运行,返回错误。

ch5/title1
Unresolved include directive in modules/ROOT/pages/ch5/ch5-08.adoc - include::example$/ch5/title1/title.go[]

下面展示了运行效果:

$ go build gopl.io/ch5/title1
$ ./title1 http://gopl.io
The Go Programming Language
$ ./title1 https://golang.org/doc/effective_go.html
Effective Go - The Go Programming Language
$ ./title1 https://golang.org/doc/gopher/frontpage.png
title1: https://golang.org/doc/gopher/frontpage.png has type image/png, not text/html

resp.Body.close 调用了多次,这是为了确保 title 在所有执行路径下(即使函数运行失败)都关闭了网络连接。随着函数变得复杂,需要处理的错误也变多,维护清理逻辑变得越来越困难。而 Go 语言独有的 defer 机制可以让事情变得简单。

你只需要在调用普通函数或方法前加上关键字 defer ,就完成了 defer 所需要的语法。当执行到该条语句时,函数和参数表达式得到计算,但直到包含该 defer 语句的函数执行完毕时,defer 后的函数才会被执行,不论包含 defer 语句的函数是通过 return 正常结束,还是由于 panic 导致的异常结束。你可以在一个函数中执行多条defer 语句,它们的执行顺序与声明顺序相反。

defer 语句经常被用于处理成对的操作,如打开、关闭、连接、断开连接、加锁、释放锁。通过 defer 机制,不论函数逻辑多复杂,都能保证在任何执行路径下,资源被释放。释放资源的 defer 应该直接跟在请求资源的语句后。在下面的代码中,一条 defer 语句替代了之前的所有 resp.Body.Close 。

ch5/title2
Unresolved include directive in modules/ROOT/pages/ch5/ch5-08.adoc - include::example$/ch5/title2/title.go[]

在处理其他资源时,也可以采用 defer 机制,比如对文件的操作:

io/ioutil
package ioutil
func ReadFile(filename string) ([]byte, error) {
    f, err := os.Open(filename)
    if err != nil {
        return nil, err
    }
    defer f.Close()
    return ReadAll(f)
}

或是处理互斥锁(9.2章)

var mu sync.Mutex
var m = make(map[string]int)
func lookup(key string) int {
    mu.Lock()
    defer mu.Unlock()
    return m[key]
}

调试复杂程序时,defer 机制也常被用于记录何时进入和退出函数。下例中的 bigSlowOperation 函数,直接调用 trace 记录函数的被调情况。 bigSlowOperation 被调时,trace 会返回一个函数值,该函数值会在bigSlowOperation 退出时被调用。通过这种方式, 我们可以只通过一条语句控制函数的入口和所有的出口,甚至可以记录函数的运行时间,如例子中的 start 。

需要注意一点:不要忘记 defer 语句后的圆括号,否则本该在进入时执行的操作会在退出时执行,而本该在退出时执行的,永远不会被执行。

ch5/trace
Unresolved include directive in modules/ROOT/pages/ch5/ch5-08.adoc - include::example$/ch5/trace/main.go[]

每一次 bigSlowOperation 被调用,程序都会记录函数的进入,退出,持续时间。(我们用 time.Sleep 模拟一个耗时的操作)

$ go build gopl.io/ch5/trace
$ ./trace
2015/11/18 09:53:26 enter bigSlowOperation
2015/11/18 09:53:36 exit bigSlowOperation (10.000589217s)

我们知道,defer 语句中的函数会在 return 语句更新返回值变量后再执行,又因为在函数中定义的匿名函数可以访问该函数包括返回值变量在内的所有变量,所以,对匿名函数采用 defer 机制,可以使其观察函数的返回值。

以 double 函数为例:

func double(x int) int {
    return x + x
}

我们只需要首先命名 double 的返回值,再增加 defer 语句,我们就可以在 double 每次被调用时,输出参数以及返回值。

func double(x int) (result int) {
    defer func() { fmt.Printf("double(%d) = %d\n", x,result) }()
    return x + x
}
_ = double(4)
// Output:
// "double(4) = 8"

可能 double 函数过于简单,看不出这个小技巧的作用,但对于有许多 return 语句的函数而言,这个技巧很有用。

被延迟执行的匿名函数甚至可以修改函数返回给调用者的返回值:

func triple(x int) (result int) {
    defer func() { result += x }()
    return double(x)
}
fmt.Println(triple(4)) // "12"

在循环体中的 defer 语句需要特别注意,因为只有在函数执行完毕后,这些被延迟的函数才会执行。下面的代码会导致系统的文件描述符耗尽,因为在所有文件都被处理之前,没有文件会被关闭。

for _, filename := range filenames {
    f, err := os.Open(filename)
    if err != nil {
        return err
    }
    defer f.Close() // NOTE: risky; could run out of file descriptors
    // ...process f…
}

一种解决方法是将循环体中的 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()
    // ...process f…
}

下面的代码是 fetch(1.5节)的改进版,我们将 http 响应信息写入本地文件而不是从标准输出流输出。我们通过 path.Base 提出 url 路径的最后一段作为文件名。

ch5/fetch
Unresolved include directive in modules/ROOT/pages/ch5/ch5-08.adoc - include::example$/ch5/fetch/main.go[]

对 resp.Body.Close 延迟调用我们已经见过了,在此不做解释。上例中,通过 os.Create 打开文件进行写入,在关闭文件时,我们没有对 f.close 采用 defer 机制,因为这会产生一些微妙的错误。许多文件系统,尤其是 NFS,写入文件时发生的错误会被延迟到文件关闭时反馈。如果没有检查文件关闭时的反馈信息,可能会导致数据丢失,而我们还误以为写入操作成功。如果 io.Copy 和 f.close 都失败了,我们倾向于将 io.Copy 的错误信息反馈给调用者,因为它先于 f.close 发生,更有可能接近问题的本质。


练习5.18: 不修改 fetch 的行为,重写 fetch 函数,要求使用 defer 机制关闭文件。