如诗如歌

ncg.moe


  • 首页
  • 归档
  •  

© 2024 ncg.moe

Theme Typography by Makito

Proudly published with Hexo

从C的do-while到Golang的defer、Python的with、CPP的RAII

发布于 2024-05-05

从C中的 do-while(1) 说起

看到有推文讨论 do-while(0) 的用法,其实是一种为了流程控制的技巧,主要是用来替代 goto,对异常分支做处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
HRESULT CSampleQueue::Create(CSampleQueue ** ppQueue)
{
HRESULT hr = S_OK;

do
{
if (ppQueue == NULL) {
hr = E_POINTER;
break;
}

PNewQueue = new CSampleQueue();
if (pNewQueue == NULL)
{
hr = E_OUTOFMEMORY;
break;
}
...
} while (false);
...
}

这里的 do-while(false) 相当于定义了一个 goto label,而在每次异常分支中,break 就相当于 goto 当了这个 lable。整体比起 goto,看起来优雅一些;另外可能就是,一个众所周知的原则就是项目中一般都不允许使用 goto(其实好像没有太多道理,尤其是在 C 语言中)。
整体的用法上有点像一个土法的 try-catch的简单炮制。

一个多层嵌套的场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if (condition1) {
if (condition2) {
if (condition3){
// if error
goto ERROR_EXIT;
}
// if error
goto ERROR_EXIT;
}
// if error
goto ERROR_EXIT
}

do_normal_work;

ERROR_EXIT:
clear_something;

换成 do-while(0)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
do
{
if (condition1) {
if (condition2) {
if (condition3) {
// if error
break;
}
// if error
break;
}
// if error
break;
}
do_normal_work;
} while(0);

clear_somethig;

类似于 Python 中的 try-except-finally

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
try:
if condition1:
# if error
raise some_exception1
if condition2:
# if error
raise some_exception2
if condition3:
# if error
raise some_exception3
except Exception as e:
print("some error happened.")
else:
# 正常流程
do_normal_work
finally:
# 不管正常流程还是异常流程
clear_something

为什么 C 中会有这种写法,主要的原因是,C 语言中,没有类似于 try-catch这种异常处理方式,对于一些稍微大型一点的工程,又有一些比较强的编码规范,导致代码实在臃肿和难以阅读。比如,编码规范可能要求了:
1)函数的返回值普遍都是错误码。
2)每个函数的调用的异常都要处理,不能忽略。
如果再加上一些更加苛刻的条件:
1)尽量不能使用 goto。
2)不能有超过X行代码的重复。
这对于写代码的人来讲,实在是一种摧残。

一个普通的写法

可能会写出类似于下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
ErrCode LockAndLoadFileToStrPoint(const char *filename, char **strResult)
{
assert(filename != NULL);
assert(strResult != NULL);

#define MAXBUFLEN 1000000

pthread_mutex_lock(&g_mutex);

/* malloc buffer to store content read */
char *buffer = (char *) malloc(MAXBUFLEN);
if (buffer == NULL) {
pthread_mutex_unlock(&g_mutex);
return E_NOMEMORY;
}

/* open */
FILE *fp = fopen(filename, "r");
if (fp == NULL) {
free(buffer);
pthread_mutex_unlock(&g_mutex);
return E_OPEN_FILE;
}

/* read */
int index = 0;
char symbol = EOF;
while((symbol = getc(fp)) != EOF) {
buffer[index++] = symbol;
}

/* some other operation to call some funcs */
ErrCode e_code = DoSomeOtherOperation(buffer);
if (e_code != E_OK) {
fclose(fp);
pthread_mutex_unlock(&g_mutex);
return e_code;
}

/* clean */
fclose(fp);
pthread_mutex_unlock(&g_mutex);

*strResult = buffer;
return E_OK;
}

代码比较臃肿,并且在不断地异常判定中,需要不断地析构清理资源。

goto 写法

所以一般不得不引入 goto 语句,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
ErrCode LockAndLoadFileToStrPoint(const char *filename, char **strResult)
{
assert(filename != NULL);
assert(strResult != NULL);

#define MAXBUFLEN 1000000

ErrCode errCode = E_OK;
FILE *fp = NULL;
char *buffer = NULL;

pthread_mutex_lock(&g_mutex);

/* malloc buffer to store content read */
buffer = (char *) malloc(MAXBUFLEN);
if (buffer == NULL) {
errCode = E_NOMEMORY;
goto ERR_EXIT;
}

/* open */
fp = fopen(filename, "r");
if (fp == NULL) {
errCode = E_OPEN_FILE;
goto ERR_EXIT;
}

/* read */
int index = 0;
char symbol = EOF;
while((symbol = getc(fp)) != EOF) {
buffer[index++] = symbol;
}

/* some other operation to call some funcs */
ErrCode e_code = DoSomeOtherOperation(buffer);
if (e_code != E_OK) {
goto ERR_EXIT;
}

*strResult = buffer;

ERR_EXIT:
/* clean */
if (fp != NULL) {
fclose(fp);
}
if (buffer != NULL && errCode != E_OK) {
free(buffer);
}
pthread_mutex_unlock(&g_mutex);
return errCode;
}

引入 goto 使得流程控制变得简单,所以我们可以统一在 goto 的 label 处设置一个出口,在出口处判定我们使用过的资源是否需要释放,以及进行清理释放操作。

do-while(0) 写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
ErrCode LockAndLoadFileToStrPoint(const char *filename, char **strResult)
{
assert(filename != NULL);
assert(strResult != NULL);

#define MAXBUFLEN 1000000

ErrCode errCode = E_OK;
FILE *fp = NULL;
char *buffer = NULL;

pthread_mutex_lock(&g_mutex);

do {
/* malloc buffer to store content read */
buffer = (char *) malloc(MAXBUFLEN);
if (buffer == NULL) {
errCode = E_NOMEMORY;
break;
}

/* open */
fp = fopen(filename, "r");
if (fp == NULL) {
errCode = E_OPEN_FILE;
break;
}

/* read */
int index = 0;
char symbol = EOF;
while((symbol = getc(fp)) != EOF) {
buffer[index++] = symbol;
}

/* some other operation to call some funcs */
ErrCode e_code = DoSomeOtherOperation(buffer);
if (e_code != E_OK) {
break;
}

*strResult = buffer;
} while(0);

/* clean */
if (fp != NULL) {
fclose(fp);
}
if (buffer != NULL && errCode != E_OK) {
free(buffer);
}
pthread_mutex_unlock(&g_mutex);
return errCode;
}

整体写法基本就是把 goto 的地方换成了 break,流程控制和 goto 完全一致,只是看起来更优雅一点。

GoLang 中的 defer

为什么设计了 defer 关键字

Go语言的整体设计是比较克制的,总共的关键字也一共只有 25 个,但是其中就包含了 defer。

1
2
3
4
5
break     default      func    interface  select
case defer go map struct
chan else goto package switch
const fallthrough if range type
continue for import return var

go 语言设计 defer 主要是为了更加方便地清理资源——在思维和写法上,都能够在开启资源的时候,就同时处理好了资源的关闭清除操作。

官方 blog 中的样例Defer, Panic, and Recover
不用 defer 的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func CopyFile(dstName, srcName string) (written int64, err error) {
src, err := os.Open(srcName)
if err != nil {
return
}

dst, err := os.Create(dstName)
if err != nil {
return
}

written, err = io.Copy(dst, src)
/* 这里距离我们打开 dst、src 已经很远了 */
dst.Close()
src.Close()
return
}

使用 defer 重写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func CopyFile(dstName, srcName string) (written int64, err error) {
src, err := os.Open(srcName)
if err != nil {
return
}
/* src 打开成功,defer close */
defer src.Close()

dst, err := os.Create(dstName)
if err != nil {
return
}
/* dst 打开成功,defer close */
defer src.Close()

return io.Copy(dst, src)
/* 这里不再需要了 */
// dst.Close()
// src.Close()
}

个人的想法是,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
2
3
4
5
6
7
8
TEXT runtime·jmpdefer(SB), NOSPLIT, $0-16
MOVQ fv+0(FP), DX // 取出延迟回调函数 fn 地址
MOVQ argp+8(FP), BX // 取出 caller 函数的 rsp 值
LEAQ -8(BX), SP // rsp 向下扩展8,此时已经覆盖了 deferreturn 的栈帧,变成了构造 defer func 的栈帧
MOVQ -8(SP), BP // 还原 caller 栈帧寄存器
SUBQ $5, (SP) // 重要操作,这个把压栈在栈顶的值修改了。之前压的是 caller 函数内,调用 deferreturn 之后下一行指令,现在压的是 call runtime.deferreturn 这行值。
MOVQ 0(DX), BX // 取出 fn 函数指令地址,存到 rbx 寄存器
JMP BX // 跳到延迟回调函数执行

整体的思想就是,调用 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
2
3
4
5
6
func a() {
i := 0
defer fmt.Println(i) // 这里打印的结果是 0
i++
return
}

虽然 caller 对 defer func 的调用是标准的函数调用,但是出于将 defer 的参数设计成预计算的,对编译器是更加友好的,同时也简化了实现,使得 defer 的调用的结果更加具有明确性。
从实现上看,_defer结构体是一个类似于 header 的定义,所以其第一个字段就是 siz,记录了自己占用的内存的大小,在结构体本身确定内存长度的字段后,有一块内存区域用来放置参数,紧挨着 _defer 放置。
在 deferreturn 构造 caller 调用 defer func 的代码中,同时也构造了 caller 调用 defer func 所需要的参数,即从 _defer结构体内存中拷贝到 caller 的栈帧中。

1
2
3
4
5
6
7
8
switch siz {
case 0:
// Do nothing.
case sys.PtrSize:
*(*uintptr)(deferArgs(d)) = *(*uintptr)(unsafe.Pointer(argp))
default:
memmove(deferArgs(d), unsafe.Pointer(argp), uintptr(siz))
}

如果想要不使用预计算的特性,也可以使用 golang 中的闭包的特性,定义 defer 函数。

3)defer 可以修改 caller 的有名返回值
这个特性应该是故意设计出来的,可以方便在 caller 函数返回之前,对函数的返回值做一些修改,比如修改 caller 的返回错误码。

1
2
3
4
5
// return 2
func c() (i int) {
defer func() { i++ }()
return 1
}

4)defer 执行的时机
都知道 defer 是在函数返回之前执行,但是实际上,return 本身并不是一个原子指令。
callee 返回时,需要设置 caller 的返回值,需要恢复 rbp,缩减 rsp。
根据官方说法,defer 是在设置完返回值后,返回前执行的,即:

1
设置返回值 -> 调用 defer 链 -> 函数返回

Python 中的上下文管理

go 语言中的 defer 实现,很容易让人联想到 Python 中的 with 实现。
使用 with,资源可以做到自动的释放管理,在离开 with 的 scope 时,with 打开的资源,会被自动释放:

1
2
3
def fun():
with open(file, "w") as f:
f.write("hello world")

这里并不再需要手动调用 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 特性,也是为了相同的目的。

参考引用

  • 一个关于do-while-false的推文讨论
  • Golang 最细节篇 — 解密 defer 原理
  • 深入 Go 语言 defer 实现原理
  • Defer, Panic, and Recover
  • golang 的 defer 真是个好设计
  • Go 语言设计与实现 – defer
  • cpp reference – RAII

分享到 

 上一篇: 论文阅读笔记:The Native POSIX Thread Library for Linux 下一篇: 一边好玩,一遍改变世界 

© 2024 ncg.moe

Theme Typography by Makito

Proudly published with Hexo