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

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

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

在Go中,自定义函数的调用的返回结果都可以被舍弃。 但是,大多数内置函数(除了copyrecover)的调用的返回结果都不可以舍弃(至少对于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() {} // 此行不会阻止恐慌产生
}

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

前面的文章曾经解释过:一个延迟调用的实参也是在此调用被推入延迟调用队列时估值的。 方法的属主实参也不例外。比如,下面这个程序将打印出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编程各种相关知识(比如Go基础、Go优化、Go细节、Go实战、Go测验、Go工具等)。后续将不断有新的内容加入。敬请收藏关注期待。

本丛书微信公众号(联系方式一)名称为"Go 101"。二维码在网站首页。此公众号将时不时地发表一些Go语言相关的原创短文。各位如果感兴趣,可以搜索关注一下。

《Go语言101》系列丛书项目目前托管在Github上(联系方式二)。欢迎各位在此项目中通过提交bug和PR的方式来改进完善《Go语言101》丛书中的各篇文章。我们可以在项目目录下运行go run .来浏览和确认各种改动。

本书的twitter帐号为@Golang_101(联系方式三)。玩推的Go友可以适当关注。

你或许对本书作者老貘开发的一些App感兴趣。

The English version of this book is here.
赞赏
(《Go语言101》系列丛书由老貘从2016年7月开始编写。目前此系列丛书仍在不断改进和增容中。你的赞赏是本系列丛书和此Go101.org网站不断增容和维护的动力。)

目录: