从C的do-while到Golang的defer、Python的with、CPP的RAII
从C中的 do-while(1) 说起
看到有推文讨论 do-while(0) 的用法,其实是一种为了流程控制的技巧,主要是用来替代 goto,对异常分支做处理。
1 | HRESULT CSampleQueue::Create(CSampleQueue ** ppQueue) |
这里的 do-while(false) 相当于定义了一个 goto label,而在每次异常分支中,break 就相当于 goto 当了这个 lable。整体比起 goto,看起来优雅一些;另外可能就是,一个众所周知的原则就是项目中一般都不允许使用 goto(其实好像没有太多道理,尤其是在 C 语言中)。
整体的用法上有点像一个土法的 try-catch
的简单炮制。
一个多层嵌套的场景:
1 | if (condition1) { |
换成 do-while(0)
1 | do |
类似于 Python 中的 try-except-finally
1 | try: |
为什么 C 中会有这种写法,主要的原因是,C 语言中,没有类似于 try-catch
这种异常处理方式,对于一些稍微大型一点的工程,又有一些比较强的编码规范,导致代码实在臃肿和难以阅读。比如,编码规范可能要求了:
1)函数的返回值普遍都是错误码。
2)每个函数的调用的异常都要处理,不能忽略。
如果再加上一些更加苛刻的条件:
1)尽量不能使用 goto。
2)不能有超过X行代码的重复。
这对于写代码的人来讲,实在是一种摧残。
一个普通的写法
可能会写出类似于下面的代码:
1 | ErrCode LockAndLoadFileToStrPoint(const char *filename, char **strResult) |
代码比较臃肿,并且在不断地异常判定中,需要不断地析构清理资源。
goto 写法
所以一般不得不引入 goto 语句,如下:
1 | ErrCode LockAndLoadFileToStrPoint(const char *filename, char **strResult) |
引入 goto 使得流程控制变得简单,所以我们可以统一在 goto 的 label 处设置一个出口,在出口处判定我们使用过的资源是否需要释放,以及进行清理释放操作。
do-while(0) 写法
1 | ErrCode LockAndLoadFileToStrPoint(const char *filename, char **strResult) |
整体写法基本就是把 goto 的地方换成了 break,流程控制和 goto 完全一致,只是看起来更优雅一点。
GoLang 中的 defer
为什么设计了 defer 关键字
Go语言的整体设计是比较克制的,总共的关键字也一共只有 25 个,但是其中就包含了 defer。
1 | break default func interface select |
go 语言设计 defer 主要是为了更加方便地清理资源——在思维和写法上,都能够在开启资源的时候,就同时处理好了资源的关闭清除操作。
官方 blog 中的样例Defer, Panic, and Recover
不用 defer 的写法:
1 | func CopyFile(dstName, srcName string) (written int64, err error) { |
使用 defer 重写:
1 | func CopyFile(dstName, srcName string) (written int64, err error) { |
个人的想法是,Go 是一门偏向工程化设计的语言,defer 关键字的设计是这种思想的一个具象化体现。Python 或者 Java 等中的 try-catch(except)-finally 语法相比 defer 有更明确的 scope,而 defer 像是一个语法糖,则是有一种暴力直接的美。
defer 的实现原理
defer 的直接意思是延迟执行
,defer 将其声明的方法调用推迟到函数 return 之前调用。
defer 的实现上是借助了自己对函数调用栈的管理。
defer 声明的方法,在编译时被实现为对回调函数 deferproc 或者 deferprocStack 的调用。deferproc 和 deferprocStack 的区别在于 defer 对应的结构体 _defer
的空间是在堆上还是在栈上以及带来的性能差异,其他的无区别,以下统一称作 deferproc。
deferproc 会将 defer 生成的 _defer
对象,以链表的形式,挂在 Goroutine 的 _defer
表头上。从而在 defer 所在的函数返回时,依次对注册的 defer 方法进行调用。
另外,含有 defer 的函数,在编译中,也被添加了对函数 deferreturn 的调用。
deferreturn 方法中,会依次遍历该函数注册过的 defer,依次调用后从链表中删除。
按照一般的函数栈的实现,调用顺序或者栈如下:
1 | caller -> deferreturn -> defer func -> deferreturn -> defer func ... -> return to deferreturn -> return to caller |
Go在实际的实现中,通过一小段汇编jmpdefer
将调用顺序改成如下:
1 | caller -> deferreturn -> defer func -> return to caller(这里直接返回到了 caller)-> deferreturn -> ... |
实现的原理是:
caller 虽然调用了 deferreturn 形成了 deferreturn 的栈帧,但是并不使用它,在获取了 defer func 的信息之后,调用 defer func 之前,改写了 函数栈帧的返回地址,并且重置了 rbp。
能够改写函数栈帧的返回地址的原因是,_defer
结构体中记录了 defer 注册时的 rsp 的值,实际上就是 caller 的 rsp 的值,从而拿到调用 deferreturn 时的返回地址的栈空间地址,并且该地址指向 deferreturn 的下一个指令,将该值倒退5,就拿到了 deferreturn 本身的指令地址,并设置到了返回地址——从而实现了 deferreturn 的循环调用,知道 Goroutine 的 _defer
中没有了该函数注册的 defer 调用。
其中关键的 jmpdefer 解释如下(来自参考1):
1 | TEXT runtime·jmpdefer(SB), NOSPLIT, $0-16 |
整体的思想就是,调用 deferreturn 只是为了调用 defer func,deferreturn 的栈帧是没有意义的,直接在 deferreturn 的栈帧上覆盖构造了 defer func 的栈帧,手动设置了栈帧的 |返回地址|rbp|defer 处理的栈帧|
,保证返回到 caller 循环调用 deferreturn 这条指令。
defer 的使用、注意事项
结合 defer 的实现,可以更容易理解 defer 的一些特点和使用过程中需要注意的地方。
1)多个 defer 的 FILO 特性
因为一个函数中的所有 defer 是用链表串起来的,因此实现上就需要取舍是 FIFO 还是 FILO。从 defer 的设计场景来看,FILO 是更合适的——比如申请使用的资源之间可能是有一定的逻辑关系的,先申请的资源,一般最后释放比较好。
2)defer 调用的参数是预计算的。
1 | func a() { |
虽然 caller 对 defer func 的调用是标准的函数调用,但是出于将 defer 的参数设计成预计算的,对编译器是更加友好的,同时也简化了实现,使得 defer 的调用的结果更加具有明确性。
从实现上看,_defer
结构体是一个类似于 header 的定义,所以其第一个字段就是 siz,记录了自己占用的内存的大小,在结构体本身确定内存长度的字段后,有一块内存区域用来放置参数,紧挨着 _defer
放置。
在 deferreturn 构造 caller 调用 defer func 的代码中,同时也构造了 caller 调用 defer func 所需要的参数,即从 _defer
结构体内存中拷贝到 caller 的栈帧中。
1 | switch siz { |
如果想要不使用预计算的特性,也可以使用 golang 中的闭包的特性,定义 defer 函数。
3)defer 可以修改 caller 的有名返回值
这个特性应该是故意设计出来的,可以方便在 caller 函数返回之前,对函数的返回值做一些修改,比如修改 caller 的返回错误码。
1 | // return 2 |
4)defer 执行的时机
都知道 defer 是在函数返回之前执行,但是实际上,return 本身并不是一个原子指令。
callee 返回时,需要设置 caller 的返回值,需要恢复 rbp,缩减 rsp。
根据官方说法,defer 是在设置完返回值后,返回前执行的,即:
1 | 设置返回值 -> 调用 defer 链 -> 函数返回 |
Python 中的上下文管理
go 语言中的 defer 实现,很容易让人联想到 Python 中的 with 实现。
使用 with,资源可以做到自动的释放管理,在离开 with 的 scope 时,with 打开的资源,会被自动释放:
1 | def fun(): |
这里并不再需要手动调用 f.close(),看起来更优雅一点,思想有点像析构函数。
with 的实现是基于 Python 中的上下文管理的。一个类,给其定义 __enter__
方法和 __exit__
方法,那么上下文管理器,就可以在使用该类时,在对应时机调用 __enter__
方法和 __exit__
方法。
with 是可以用来替代 try-finally 的,__exit__
的参数包含了异常的类型、对象以及堆栈信息。
CPP 中的 RAII 和 scope_exit
cpp 中有类似的思想实践,叫做 RAII。RAII 的想法是,将资源的生命周期绑定到一个对象上,利用对象的析构的特性在 scope 结束的时候,自动调用资源的释放方法。
因此,RAII 的另外一个名字叫做,SBRM(Scope Bound Resource Manger),资源作用域绑定管理——在使用完对象之后,scope exit 的时候 RAII 的生命周期也结束。
RAII 总结如下:
1)将对象封装到 class 中,在 class 的构造方法中,获取资源;在 class 的析构方法中,释放资源。
2)始终使用 class 封装的 RAII 对象。
另外,CPP 中有叫做 scope_exit 的 experimental 特性,也是为了相同的目的。
参考引用