• Go语言如何使用defer来避免代码重复?
  • 发布于 2个月前
  • 456 热度
    0 评论
首先,我们看一个拷贝文件函数的示例。我们还将管理该文件描述符的关闭,因为一个 *os.File一旦被打开准备读写时,它就必须要使用Close函数进行关闭。最后,在函数的最后,我们将使用 Sync 方法来刷新文件系统的缓冲区以便将内容强制写到磁盘上,使副本持久化。

第一版实现:未使用 defer 函数
func CopyFile(srcName, dstName string) error {
    src, err := os.Open(srcName) ①
    if err != nil {
        return err
    }

    stat, err := src.Stat()
    if err != nil {
        src.Close()
        return err
    }

    if stat.IsDir() { ②
      src.Close()
        return fmt.Errorf("file %q is a directory", srcName)
    }

    dst, err := os.Create(dstName) ③
    if err != nil {
        src.Close()
        return err 
    }

    _, err = io.Copy(dst, src) ④
    if err != nil {
        src.Close()
        dst.Close()
        return err
    }

    err = dst.Sync() ⑤
    src.Close()
    dst.Close()
    return err
}
① 打开源文件
② 检查是否是目录
③ 创建目标文件
④ 拷贝源文件到目标文件
⑤ 刷新文件系统缓冲区
注意: 关闭*os.File 将会返回一个错误。然而,在该例中该错误可以被安全的忽略,因为我们强制刷新了文件系统的缓冲区。否则,如果错误发生时,我们至少应该记录一条日志。在错误管理一章,我们将会看到在 defer 语句中如何优雅地处理错误。

这个实现是可以工作的。我们打开一个源文件,检查是否是目录,然后处理拷贝逻辑。然而,我们注意到一些重复的代码:
src.Close()重复了 5 次
dst.Close()重复了 2 次
在代码中必须考虑源文件和目标文件被关闭的路径,这使得我们的代码非常容易出错。幸运的是,Go 通过 defer 关键词提供了一种解决该问题的方案,如图 2.1:

在函数返回的时候会调用defer函数。即使在主函数 panics 或意外终止时defer函数也能保证被执行。调用 defer 会被推送到栈中。当主函数返回时,defer 函数会从栈中弹出(先进后出的顺序)。这里,将会先调用c(),然后b(),最后是a()。

注意:一个 defer 调用的时机是在函数返回时,而非在所在的块退出时。
func main() {
    fmt.Println("a")
    if true {
      defer fmt.Print("b")
    }
    fmt.Print("c")
}
该段代码打印结果是 acb,而非 abc
让我们回到 CopyFile 函数的例子并使用 defer 关键词再实现一版

第二版:defer 版本的实现
func CopyFile(srcName, dstName string) error {
    src, err := os.Open(srcName)
    if err != nil {
        return err 
    }
    defer src.Close() ①

    stat, err := src.Stat()
    if err != nil {
        return err 
    }

    if stat.IsDir() {
        return fmt.Errorf("file %q is a directory", srcName)
    }

    dst, err := os.Create(dstName)
    if err != nil {
        return err 
    }
    defer dst.Close() ②

    _, err = io.Copy(dst, src)
    if err != nil {
        return err
    }

    return dst.Sync()
}
① 延迟调用 src.Close()
② 延迟调用 dst.Close()
在这个版本的实现中,我们通过 defer 关键词的使用,移除了重复的 close 调用。这使得函数更轻量并且更易读。我们不必在每一个代码路径的末尾都关闭 src 和 dst,这样就不容易出错了。

defer 语句经常会跟成对出现的操作函数一起使用,就像 open/close,connect/disconnect,以及 lock/unlock 函数以确保在所有的场景下资源都能够得到释放。这是另一个使用 sync.Mutex 的例子:
type Store struct {
    mutex sync.Mutex
    data map[string]int
}

func (s *Store) Set(key string, value int) {
    s.mutex.Lock() ①
    defer s.mutex.Unlock() ②

    s.data[key] = value
}
① Mutex lock
② 在 defer 语句中 unlock
我们使用 s.mutex.Lock 函数锁定了 mutex 并在 defer 中调用 s.mutex.Unlock()函数的配对操作。
注意:如果我们必须实现一个 pre 和 post 操作,比如不返回任何值的 mutex lock/unlock,我们也可以这样实现:
func (s *Store) Set(key string, value int) {
    defer s.lockUnlock()() ①
    s.data[key] = value
}

func (s *Store) lockUnlock() func() {
    s.mutex.Lock()
    return func() {
    s.mutex.Unlock()
    }
}
① 会立刻执行 s.lockUnlock(),但会延迟执行 s.lockUnlock()()。

延迟执行的函数调用是 s.lockUnlock()(),而非 s.lockUnlock()。 因此,s.lockUnlock()部分会立即执行(s.mutex.Lock),但是返回的闭包会操作被延迟执行(s.mutex.Unlock())。它添加了一些语法糖,用一行代码来处理函数中的前/后操作,这有时非常方便。如果使用这种模式,还需要注意的是,面对带有两组括号的 s.lockUnlock()() 可能会非常混乱,这取决于您团队的资历。

当重构代码时,我们还需要注意可能的影响。例如,假设我们需要将一个包含 defer 调用的 main 函数拆分成多个函数时,这种情况下,一旦应用程序执行完,defer 语句并不会执行,但是当子函数执行完时 defer 调用才会调用:
// Before
func main() {
    consumer := createConsumer()
    defer consumer.Close() ①
    // ...
}

// After
func main() {
    consumer := handleConsumer()
    // ...
}

func handleConsumer() Consumer {
    consumer := createConsumer()
    defer consumer.Close() ②
    return consumer
}
① 一旦程序结束 defer 调用将会被执行
② 当 handleConsumer 函数结束 defer 调用才会被执行
这里,我们通过重构 consumer 引入了一个 bug。因为一旦 handleConsumer 函数结束,consumer.Close()就会被执行。它看起来像一个简单的注释,但是当我们必须重构大量的代码时,有时很容易忽略 defer 语句。

同时也需要注意 Go 1.14 之前的版本,defer 语句不是内联的。内联是编译器通过将函数调用直接保存在调用函数中的一种优化技术。这就是为什么在一些性能是关键因素的项目中,defer 关键词很少被用到的原因。但是,在 Go 1.14 版本之后,defer 语句可以通过内联来优化了。

总之,defer 可以避免死板的代码以及减少忘记释放资源的风险,例如释放资源,断开链接,mutex 解锁等等。在下一节中,我们继续讨论 defer 以及其参数和接受者是如何被评估的。
用户评论