第29章:更多关于延迟函数调用的知识点

第29章:更多关于延迟函数调用的知识点



更多关于延迟函数调用的知识点

延迟调用函数已经在前面介绍过了(第13章)。 限于当时对Go的了解程度,很多延迟调用函数相关的细节和用例并没有在之前的文章中提及。 这些细节和用例将在本文中列出。

很多有返回值的内置函数是不能被延迟调用的

在Go中,自定义函数的调用的返回结果都可以被舍弃。 但是,大多数内置函数(除了copy和recover)的调用的返回结果都不可以舍弃(第49章)(至少对于Go 1.23来说是如此)。 另一方面,我们已经了解到延迟函数调用的所有返回结果必须都舍弃掉。 所以,很多内置函数是不能被延迟调用的。

幸运的是,在实践中,延迟调用内置函数的需求很少见。 根据我的经验,只有append函数有时可能会需要被延迟调用。 对于这种情形,我们可以延迟调用一个调用了append函数的匿名函数来满足这个需求。

package main

import "fmt"

func main() {
   s := []string{"a", "b", "c", "d"}
   defer fmt.Println(s) // [a x y d]
   // defer append(s[:1], "x", "y") // 编译错误
   defer func() {
      _ = append(s[:1], "x", "y")
   }()
}

延迟调用的函数值的估值时刻

一个被延迟调用的函数值是在其调用被推入延迟调用队列之前被估值的。 例如,下面这个例子将输出false。

package main

import "fmt"

func main() {
   var f = func () {
      fmt.Println(false)
   }
   defer f()
   f = func () {
      fmt.Println(true)
   }
}

一个被延迟调用的函数值可能是一个nil函数值。这种情形将导致一个恐慌。 对于这种情形,恐慌产生在此延迟调用被执行而不是被推入延迟调用队列的时候。 一个例子:

package main

import "fmt"

func main() {
   defer fmt.Println("此行可以被执行到")
   var f func() // f == nil
   defer f()    // 将产生一个恐慌
   fmt.Println("此行可以被执行到")
   f = func() {} // 此行不会阻止恐慌产生
}

延迟方法调用的属主实参的估值时刻

前面的文章曾经解释过:一个延迟调用的实参也是在此调用被推入延迟调用队列时估值的(第13章)。 方法的属主实参也不例外。比如,下面这个程序将打印出1342。

package main

type T int

func (t T) M(n int) T {
  print(n)
  return t
}

func main() {
   var t T
   // t.M(1)是方法调用M(2)的属主实参,因此它
   // 将在M(2)调用被推入延迟调用队列时被估值。
   defer t.M(1).M(2)
   t.M(3).M(4)
}

延迟调用使得代码更简洁和鲁棒

一个例子:

import "os"

func withoutDefers(filepath string, head, body []byte) error {
   f, err := os.Open(filepath)
   if err != nil {
      return err
   }

   _, err = f.Seek(16, 0)
   if err != nil {
      f.Close()
      return err
   }

   _, err = f.Write(head)
   if err != nil {
      f.Close()
      return err
   }

   _, err = f.Write(body)
   if err != nil {
      f.Close()
      return err
   }

   err = f.Sync()
   f.Close()
   return err
}

func withDefers(filepath string, head, body []byte) error {
   f, err := os.Open(filepath)
   if err != nil {
      return err
   }
   defer f.Close()

   _, err = f.Seek(16, 0)
   if err != nil {
      return err
   }

   _, err = f.Write(head)
   if err != nil {
      return err
   }

   _, err = f.Write(body)
   if err != nil {
      return err
   }

   return f.Sync()
}

上面哪个函数看上去更简洁?显然,第二个使用了延迟调用的函数,虽然只是简洁了些许。 另外第二个函数将导致更少的bug,因为第一个函数中含有太多的f.Close()调用,从而有较高的几率漏掉其中一个。

下面是另外一个延迟调用使得代码更鲁棒的例子。 如果doSomething函数产生一个恐慌,则函数f2在退出时将导致互斥锁未解锁。 所以函数f1更鲁棒。

var m sync.Mutex

func f1() {
   m.Lock()
   defer m.Unlock()
   doSomething()
}

func f2() {
   m.Lock()
   doSomething()
   m.Unlock()
}

延迟调用可能会导致性能损失

延迟调用并非没有缺点。对于早于1.13版本的官方标准编译器来说,延迟调用将导致一些性能损失。 从Go官方工具链1.13版本开始,官方标准编译器对一些常见的延迟调用场景做了很大的优化。 因此,一般我们不必太在意延迟调用导致的性能损失。感谢Dan Scales实现了此优化。

延迟调用导致的暂时性内存泄露

一个较大的延迟调用队列可能会消耗很多内存。 另外,某些资源可能因为某些调用被延迟的太久而未能被及时释放。

比如,如果下面的例子中的函数需要处理大量的文件,则在此函数退出之前,将有大量的文件句柄得不到释放。

func writeManyFiles(files []File) error {
   for _, file := range files {
      f, err := os.Open(file.path)
      if err != nil {
         return err
      }
      defer f.Close()

      _, err = f.WriteString(file.content)
      if err != nil {
         return err
      }

      err = f.Sync()
      if err != nil {
         return err
      }
   }

   return nil
}

对于这种情形,我们应该使用一个匿名函数将需要及时执行延迟的调用包裹起来。比如,上面的函数可以改进为如下:

func writeManyFiles(files []File) error {
   for _, file := range files {
      if err := func() error {
         f, err := os.Open(file.path)
         if err != nil {
            return err
         }
         defer f.Close() // 将在此循环步内执行

         _, err = f.WriteString(file.content)
         if err != nil {
            return err
         }

         return f.Sync()
      }(); err != nil {
         return err
      }
   }

   return nil
}

本书由老貘历时三年写成。目前本书仍在不断改进和增容中。你的赞赏是本书和Go101.org网站不断增容和维护的动力。

赞赏

(请搜索关注微信公众号“Go 101”或者访问github.com/golang101/golang101获取本书最新版)