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

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

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

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

幸运的是,在实践中,延迟调用内置函数的需求很少见。 根据我的经验,只有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")
	}()
}

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

一个被延迟调用的函数值可能是一个nil函数值。这种情形将导致一个恐慌。 对于这种情形,恐慌产生在此延迟调用被推入延迟调用堆栈之前。 一个例子:
package main

import "fmt"

func main() {
	defer fmt.Println("reachable")
	var f func() // f == nil
	defer f()    // 将产生一个恐慌
	// 下面的代码不会被执行。
	fmt.Println("not reachable")
	f = func() {}
}

一个延迟调用的实参也是在此调用被推入延迟调用堆栈之前估值的

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

一个例子:
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()
}

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

延迟调用并非没有缺点。到目前为止(Go 1.12),对于官方标准编译器来说,延迟调用将导致一些性能损失。

比如,下面的例子中的CounterBIncreaseB方法比CounterAIncreaseA方法的效率要高。
import "sync"

type T struct {
	mu sync.Mutex
	n  int64
}

func (t *T) CounterA() int64 {
	t.mu.Lock()
	defer t.mu.Unlock()
	return t.n
}

func (t *T) CounterB() (count int64) {
	t.mu.Lock()
	count = t.n
	t.mu.Unlock()
	return
}

func (t *T) IncreaseA() {
	t.mu.Lock()
	defer t.mu.Unlock()
	t.n++
}

func (t *T) IncreaseB() {
	t.mu.Lock()
	t.n++ // this line will not panic for sure
	t.mu.Unlock()
}

在上面的俩个B版本方法中,我们必须确保LockUnlock调用之间的代码不会产生恐慌。 一般说来,A版本方法更推荐使用,尽管它们的效率略低一点,因为它们更鲁棒。 我们只应该在确实很在意这点性能损失的情况下才使用B版本方法。

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

一个较大的延迟调用堆栈可能会消耗很多内存,而且延迟调用堆栈中尚未执行的延迟调用可能会导致某些资源未被及时释放。 比如,如果下面的例子中的函数需要处理大量的文件,则在此函数推出之前,将有大量的文件句柄得不到释放。
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
}

Go语言101项目目前同时托管在GithubGitlab上。 欢迎各位在这两个项目中通过提交bug和PR的方式来改进完善Go语言101中的各篇文章。

本书微信公众号名称为"Go 101"。每个工作日此公众号将尽量发表一篇和Go语言相关的原创短文。各位如果感兴趣,可以搜索关注一下。

赞赏