[TOC]
golang语言defer特性详解
defer语句是go语言提供的一种用于注册延迟调用的机制,它可以让函数在当前函数执行完毕后执行,是go语言中一种很有用的特性。由于它使用起来简单又方便,所以深得go语言开发者的欢迎。但是,真正想要使用好这一特性,却得对这一特性深入理解它的原理,不然很容易掉进一些奇怪的坑里还找不到原因。接下来,我们将一起来探讨defer的使用方式,使用场景及一些容易产生误解、混淆的规则。
什么是defer
首先我们来看下defer语句的官方解释
A "defer" statement invokes a function whose execution is deferred to the moment the surrounding function returns, either because the surrounding function executed a return statement, reached the end of its function body, or because the corresponding goroutine is panicking.
defer语句注册了一个函数调用,这个调用会延迟到defer语句所在的函数执行完毕后执行,所谓执行完毕是指该函数执行了return语句、函数体已执行完最后一条语句或函数所在协程发生了恐慌。
如何使用defer
defer的使用方式很简单,只需要在一个正常函数调用前面加上defer关键字即可(类似起协程时的go关键字),defer后面的函数调用会在defer所在的函数执行完毕后执行, 需要注意的是defer后面只能是函数调用,不能是表达式如 a++
等。
//demo1 defer使用实例
func main() {
fmt.Println("test1")
defer fmt.Println("defer")
fmt.Println("test2")
}
如demo1所示,我们先按正常函数调用调用了一行打印"test1",随后用defer关键字编写了一个延迟调用,打印“defer”, 最后再编写了一个正常函数调用语句打印"test2",通过三个打印的输出顺序来简单看一下defer的执行时机,运行结果如下图所示
从demo1的运行结果我们可以看到,输出顺序是 "test1","test2","defer",因此,虽然fmt.Println(defer)
函数调用语句出现的早于fmt.Println(test2)
,但由于其前面加了defer关键字,延迟到了最后test2打印执行完了才真正执行函数调用
为什么需要defer
我们在写代码的时候,经常会需要申请一些资源,比如申请可用数据库连接、打开文件句柄、申请锁、获取可用网络连接、申请内存空间等,这些资源都有一个共同点那就是在我们使用完之后都需要将其释放掉,否则会造成内存泄漏或死锁等其它问题。但由于开发人员一时疏忽忘记释放资源是常有的事。此外,对于一些出口比较多的函数,需要在每个出口处都重复的编写资源释放代码,既容易造成遗漏,也导致很多重复代码,代码不够简洁。
golang直接在语言层面提供defer关键字来解决上述问题。当我们成功申请了一项资源后,马上使用defer语句注册资源的释放操作,在函数运行完毕后,就会自动执行这些操作释放资源,可以极大程度的避免了对资源释放的遗忘。此外,对于出口较多的函数,也无需在每个出口处再去编写释放资源的代码。如示例demo2是一个打开文件获得句柄处理文件再关闭文件的操作
//demo2 通过紧跟着资源申请代码的defer来保证资源得到释放
f, err := os.Open(filename)
if err != nil {
panic(err)
}
defer f.Close() //释放资源
/*
读取和处理文件内容
*/
当打开文件成功获得文件句柄资源后,马上通过defer定义一个释放资源的延迟调用f.Close()
,避免后续忘记释放资源,然后再编写实际文件内容处理的代码。
因此,在诸如打开连接/关闭连接;申请/释放锁;打开文件/关闭文件等成对出现的操作场景里,defer会显得格外方便和适用。
defer使用规则
Each time a “defer” statement executes, the function value and parameters to the call are evaluated as usual and saved anew but the actual function is not invoked. Instead, deferred functions are invoked immediately before the surrounding function returns, in the reverse order they were deferred. If a deferred function value evaluates to nil, execution panics when the function is invoked, not when the “defer” statement is executed.
上面这段是官方对defer使用机制的描述,大概意思是:每次defer语句执行时,会将defer定义的函数以及函数参数拷贝出来压到一个专门的defer函数栈中,此时,函数并不会真正执行;当在外层函数退出之前,defer函数会按照定义的顺序逆序执行;如果defer要执行的函数为nil,会在函数退出之前defer函数真正执行时panic,而不是在defer语句执行时。
文字不长,理解起来似乎也不难,但是如果不深入了解,坑却不少,总结下来大概有这么几个要注意的地方:
- 一个函数中有多个defer时的运行顺序
- defer语句执行时的拷贝机制
- defer如何影响函数返回值
此外,还有一些在这段话里没有讲述的如defer与闭包、defer和panic等知识点。接下里,我们会挨个分析一下。
多个defer运行顺序
当一个函数中含有多个defer语句时,函数return前会按defer定义的顺序逆序执行,先进后出,也就是说最先注册的defer函数调用最后执行。这一机制也好理解,后申请的资源有可能对前面申请的资源有依赖,如果将先申请的资源直接释放掉了可能会导致后申请的资源释放时各种异常。我们可以通过一个例子来验证一下执行顺序。
//demo3 多个defer执行顺序
func main() {
fmt.Println("test1")
defer fmt.Println("defer1")
fmt.Println("test2")
defer fmt.Println("defer2")
fmt.Println("test3")
}
运行结果如下图所示:
当执行完函数正常执行语句test1,test2和test3的打印后,先执行了后定义的延迟调用fmt.Println("defer2")
,最后执行了最先定义的延迟调用fmt.Println("defer1")
defer语句执行时的拷贝机制
经过前文的讲述,我们知道,当我们执行defer语句时,函数调用不会马上发生,语言层面会先把defer注册的函数及变量拷贝到defer栈中保存,直到函数return前才执行defer中的函数调用。需要格外注意的是,这一拷贝拷贝的是那一刻函数的值和参数的值。注册之后再修改函数值或参数值时,不会生效。接下来我们同样用代码说话:
//demo4 defer函数在defer语句执行那一刻就已经确定
func main() {
test := func() {
fmt.Println("I am function test1")
}
defer test()
test = func() {
fmt.Println("I am function test2")
}
}
运行结果如下图所示:
在demo4中,我们定义了一个函数变量test,然后将test调用添加为一个延迟调用,随后,修改test的值,defer虽然是最后运行,但是从结果中我们可以看到,执行的依旧是defer注册时那一刻test对应的函数调用,也即是打印了test1的函数调用。
函数参数也是同样的道理,接下来我们看一个函数参数的例子
//demo5 defer函数参数的值在注册那一刻就已经确定
func f5() {
x := 10
defer func(a int) {
fmt.Println(a)
}(x)
x++
}
可以看到,执行的输出的是10而不是11。这也是同样的道理,在使用defer注册延迟函数那一刻,函数参数的值已经确定是10,后续x的变化不会影响到已经拷贝储存好的函数参数。
到这里,拷贝规则似乎很明确了,然而,我们再来看看以下两个demo,读者可以在看结果之前,自己先想一下输出结果。
//demo6 defer 函数传递参数为指针传递
func main() {
x := 10
defer func(a *int) {
fmt.Println(*a)
}(&x)
x++
}
//demo7 defer 延迟函数为闭包
func main() {
x := 10
defer func() {
fmt.Println(x)
}()
x++
}
运行结果为:
demo6:
demo7:
很多人可能会觉得应该输出10,然而运行下来两个程序最后输出的结果都为11而不是10,这是怎么回事呢,不是说函数调用都已经在defer语句执行时就已经确认了吗,怎么最后输出的结果都为11而不是10呢,是这个规则是错的吗?其实并不是,我们来具体分析一下。
在demo6中,与demo5的区别在于,demo5传递的是一个int型的值,而demo6传递的是一个int型的指针,那我们按照拷贝规则想一下,在defer语句执行时,函数参数实际上传递的是一个指针,指向变量x的地址,当函数return之前defer定义的函数调用执行时,该指针指向的地址对应的值即x已经变成了11,所以打印11是正常的,也并没有违反该拷贝规则。
demo7与demo5、demo6稍稍有些不一样,demo7的x并不是通过函数调用的参数传进去的,而是一个闭包,闭包里的变量本质上是对上层变量的引用,因此最后的值就是引用的值,也可以说,defer函数闭包变量的值实际上到最后执行时,才最终确认是多少,因此与前面的拷贝规则也并不冲突,我们可以通过如下demo做个验证,即将两处x的地址打印出来看是否一致
//demo8 defer 闭包验证
func main() {
x := 10
fmt.Printf("normal:%p\n", &x)
defer func() {
fmt.Printf("defer:%p\n", &x)
fmt.Println(x)
}()
x++
}
运行结果如下:
地址一致,可证实闭包里和外层引用了同一块内存空间,外层的改变会影响到闭包里面值的改变
defer和函数返回值
从官方话术中我们可以知道defer发生的时机是在函数执行return语句之后,既然在return之后,是不是意味着我们可以利用defer来对函数的返回值做一些事情呢,那么什么情况下defer会影响到函数返回值,什么时候不会影响呢?
defer和非命名返回值
我们先来看以下两个例子
//demo10 defer函数与非命名返回值之间的关系
func f10() int {
x := 10
defer func() {
x++
}()
return x
}
//demo11 defer函数与非命名返回值之间的关系
func f11() *int {
a := 10
b := &a
defer func() {
*b++
}()
return b
}
func main() {
fmt.Println("f10", f10())
fmt.Println("f11", *f11())
}
我们可以推算一下结果,然后再实际运行一下看结果和自己所想是否一致,在本demo中,f10和f11执行的结果如下图所示
f10中,延迟函数的调用并没有影响到返回值,f11中,延迟函数的调用成功"影响"到了返回值, 这个怎么来理解呢。其实我们可以对函数返回进行"拆解","拆解"后的代码如下所示:
//demo10_1 defer函数与非命名返回值之间的关系, return拆解
func f10_1() int {
x := 10
defer func() {
x++
}()
//return x => 拆解
_result := x
return _result //实际返回的是_result的值,因此defer中修改x的值对返回值没有影响
}
//demo11_1 defer函数与返回值之间的关系, return拆解
func f11_1() *int {
a := 10
b := &a
defer func() {
*b++
}()
//return b => 拆解
_result := b
return _result //执行defer函数调用*b++, 修改了b指向的内存空间的值,实际返回的是result指针
}
注:拆解成这样只是为了方便理解,拆解后的代码会更加清晰,函数返回值的变化也更加直观,当我们无法判断时,就可以将return操作一分为二,一部分是计算返回值,一部分是真正的返回,再去判断就不容易出错了。各部分执行顺序如下
- 计算返回值
- 执行defer函数调用
- 函数返回第一步中计算的返回值
因此,实际上在这种模式中,defer无法实际影响到函数的返回值,对于f11中,函数返回的指针的值并没有变化,受影响的只是该指针指向的区域对应的值,可以说时间接上改变了返回值,跟普通函数传入指针的做法没什么区别。我们可以通过直接在defer函数调用中,改变b指针指向来证实这一规则。
//demo12 defer函数与非命名返回值之间的关系
func f12() *int {
a := 10
b := &a
fmt.Println("b", b)
defer func() {
c := 12
b = &c
fmt.Println("defer", b)
}()
return b
}
从输出结果可以看到,虽然defer中把b的指针重新指向了值为12的c的地址,但是最终返回值并未改变。其实这一特性,与前文讲述的defer的特性很像,函数内容都是在return/defer语句执行那一刻就已经确定,延迟函数调用并不会改变返回值/参数值/函数值
defer和命名返回值
那么,defer就真的无法影响函数的返回值了吗?其实也不然。在go语言中,一个函数返回返回值有两种形式,除了前面讲的那种之外,还有一种返回形式叫命名返回值,那么,在命名返回值中,defer会是什么效果呢。我们依旧通过代码来看一下。
//demo13 defer函数与命名返回值之间的关系
func f13() (result int) {
defer func() {
result++
}()
return 10
}
//demo14 defer函数与命名返回值之间的关系
func f14() (result int) {
result = 10
defer func() {
result++
}()
return result
}
//demo15 defer函数与命名返回值之间的关系
func f15() (b *int) {
a := 10
b = &a
fmt.Println("b", b)
defer func() {
c := 12
b = &c
fmt.Println("defer", b)
}()
return
}
func main() {
fmt.Println("f13", f13())
fmt.Println("f14", f14())
t := f15()
fmt.Println("f15", *t, t)
}
执行结果如下图所示:
从结果可以看到,三个示例中返回值都被defer函数调用成功修改,我们同样可以通过return拆解来理解这一现象。在前面的非命名函数中,最后一步我们可以拆解成有一个专门储存函数返回值的临时变量,最终函数返回的是该变量的值,因此defer函数对原有返回值的修改无效,但是在命名返回方式中,最终函数返回的就是命名返回变量的值,因此,对该命名返回变量的修改会影响到最终的函数返回值
defer和recover
在go语言中,当程序发生异常时,一般我们可以选择直接panic来让程序停止运行,但很多时候,在程序异常停止前,我们希望做一些“扫尾工作”。此外,对于服务端程序来说,很多异常情况是可以容忍程序继续执行的,并不希望程序因此宕掉,此时我们可能更希望捕获异常然后通过异常的类型来判断是否需要将程序从异常中恢复。因此,go语言提供了recover函数来进行panic捕获。由于程序任何位置都可能发生恐慌,因此,作为函数退出必定执行的defer延迟调用里,是最适合捕获panic的位置(我们前面提到,defer延迟调用执行的时机之一就是发生panic时),所以,在go语言的设计里,recover只会在defer中生效,且此时defer延迟调用必须是匿名函数,defer+recover起到了很多语言里面try...catch...的效果。同样来看一个例子
//demo16 defer与recover
func f16() {
defer func() {
if err := recover(); err != nil {
fmt.Println("catch err:", err)
}
}()
panic(errors.New("TEST"))
}
func main() {
f16()
fmt.Println("I am OK.")
}
如果f16中没有异常捕获,panic会导致整个程序直接退出,fmt.Println("I am OK.")
这一语句将无法执行,当我们在defer中将入了recover异常捕获后,执行结果如下图所示
程序成功捕获异常并从异常中恢复成功的继续往下执行打印出了"I am OK."。最后有兴趣的读者可以再试想以下,如果defer中发生异常,又会发生什么事呢?这个由于不算defer的知识点,我们就不在此验证了。
总结
在本文中,我们主要通过一些示例探讨了如下一些defer的特性
defer本质上是注册了一个延时函数,当defer语句所在上下文函数执行完毕后再进行延迟函数的实际调用
defer函数及对应参数在defer语句执行时就已经确定,只不过将函数执行延后
当存在多个defer时,依照defer语句执行的先后顺序,逆序进行延迟函数调用
defer和闭包一起用时,闭包变量的值在函数调用执行时才最终确定
对于非命名返回值函数,defer无法修改返回值,但对于命名返回值函数,可以通过defer来修改函数的返回值,因此,当我们想通过defer来灵活操作函数返回值时,可使用命名返回值方式
defer + recover 有点类似于其它语言的try…catch,recover只在defer延迟函数调用里才能生效
defer是go语言中极其实用又方便的特性,使用好了可以使程序更加安全,让代码简洁又优雅,但前提是对其本身的特性掌握透彻。只要我们对本文中这些规则都理解清楚了,相信可以在defer的使用上更加得心应手。