喵の窝

C++中‘缺失’的 defer 关键字

零 - 何为 defer

很多现代的编程语言(Swift, GoLang)都提供了 defer 关键字,其作用就是在 defer 语句所在的函数或者作用域结束时,自动执行一段代码。比如下面伪代码

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
Lock lock;

func someFunc()
{

lock.lock();
defer
{
lock.unlock()
}
if (...)
{
return; // Mark 1
}

if (...)
{
throw ... // Mark 2
}

for (...)
{
switch(...)
{
case ...:
{
if (...)
return; // Mark 3
}
break;
}
}
}

比如上面这段代码,在函数退出时,都会执行 lock.unlock() 对 lock 变量解锁。无论函数是正常的 return (Mark 1, Mark 3),或者函数抛出异常(Mark 2)。然而在 C++ 中,却完全没有看到类似的设计,即便是在 C++ 14 中也如此,这是为什么呢?

壹 - RAII

RAII 的全称是 Resource Acquisition Is Initialization, 即‘资源获取就是初始化’。这种思想贯穿于 C++ 的设计中,用于管理资源,避免内存泄漏。而这种思想的核心就是:在构造函数中申请资源,在析构函数中释放资源

上面的核心思想听起来很简单,可是为什么在其它的语言(Java,Swift)中就不能使用这种思想呢?原因就在于 C++ 对于非静态非全局自动变量的生命周期有着严格的控制。只要(非静态非全局)自动变量出了作用域,它就会被销毁,对应的,它的析构函数就会被调用。因此,在构造函数中申请资源,在析构函数中释放资源这种资源管理理念在 C++ 中得到了广泛的应用。

比如 std::fstream 类。这个类在构造函数中会进行创建文件描述符,创建缓冲区等资源获取操作。对应的,在其析构函数中就会关闭文件描述符,删除缓冲区以释放资源。所以在对单个文件进行操作时,很少看到 C++ 程序员调用 fstream 的 close 方法来关闭文件流。像这种在构造函数中获取资源,在析构函数中释放资源的类,在 C++ 中统称为资源句柄

而对于 std::mutex 这种信号量来说, C++ 也提供了 std::unique_lock 和 std::lock_guard 两种类型的资源句柄来方便我们管理信号量。其工作原理都类似:构造函数需要传入一个 std::mutex 参数,并且在构造函数中对这个互斥量加锁(获取资源),并且在析构函数中解锁(释放资源)

1
2
3
4
5
6
7
8
9
#include <mutex>

static std::mutex s_lock;

void function()
{
std::lock_guard<std::mutex> lg(s_lock);
// do someting
}

在 function 函数中,lg 变量在构造时会对 s_lock 加锁。当 function 函数退出(正常 return 或者抛出异常)时,lg 变量作用域结束,发生析构。在析构函数中就会解锁 s_lock。因此 std::lock_guard 这个类就充当了 defer 关键字的作用。类似的设计还有 std::shared_ptr, std::unique_ptr 等等。

贰 - 还需要 defer 吗?

C++ 中的 RAII 机制似乎让 defer 成了多余的设计。然而,如果在 C++ 工程需要混入 C 语言的话,那你就会发现 defer 是非常有必要的。因为在 C 中,并没有构造函数与析构函数,所以也就没有 RAII。C 代码中,使用 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
void func()
{
FILE *pFile = NULL;
unsigned char *pBuffer = NULL;

pFile = fopen("someFile", "rw+");
if (!pFile)
goto __EXIT;

pBuffer = malloc(1024 * sizeof(unsigned char));
if (!pBuffer)
goto __EXIT;


if (...)
goto __EXIT;


__EXIT:
if (pFile)
{
fclose(pFile);
}
if (pBuffer)
{
free(pBuffer);
}
}

然而这种方法只适用于纯 C 代码,并不适用于 C/C++ 混编的情况。原因如下:

  1. C++ 代码可能抛异常,而 goto 并不能在这种情况下正常回收资源。
  2. C++ 要求 goto 语句后不允许在当前的作用域内定义新的变量。因此,如果要在 C++ 环境中使用 goto 语句,那么必须在第一个 goto 语句前将所有的变量都定义好,而 C++ 代码通常会在使用到变量的时候才定义变量,这样就会导致在 C++ 环境中加入 goto 语句很可能引发编译错误。

叁 - 自己写一个 defer

如果确实有需要的话,我们可以自己写一个 defer。C++ 11 中新增的 std::unique_ptr 允许我们在构造智能指针的时候,传入一个自定义的析构函数。在智能指针析构时,我们传入的析构函数就会被调用。用法如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
#include <memory>

int main(void)
{

std::unique_ptr<int, std::function<void(int *)>> ptrI = {new int(), [](int *i) {
std::cout << u8"自定义析构函数" << std::endl;
delete i;
}};

*ptrI = 2;
std::cout << u8"*ptrI: " << *ptrI << std::endl;
return 0;
}

/*
输出:
==============================
*ptrI: 2
自定义析构函数
*/

可以看到,ptrI 在析构时调用了我们传入的析构函数。利用 unique_ptr 的这个特性,我们可以自己实现一个 defer。

源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/*
cpp_defer.h
author: tyzual
*/
#include <memory>

#define _MACRO_CONTACT_IMPL(x, y) x##y
#define _MACRO_CONTACT(x, y) _MACRO_CONTACT_IMPL(x, y)

#define DEFER(X) \
auto _MACRO_CONTACT(_cpp_defer_obj_, __LINE__) = std::unique_ptr<void, std::function<void(void *)>>{ \
reinterpret_cast<void *>(1), [&](void *) { \
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
47
48
49
50
#include "cpp_defer.h"

void demo(bool bMalloc)
{
std::cout << u8"进入 demo" << std::endl;
unsigned char *pBuffer = NULL;

DEFER(
std::cout << u8"清理 pBuffer" << std::endl;
if (pBuffer) {
std::cout << u8"pBuffer 不为空,调用 free 清理 pBuffer" << std::endl;
free(pBuffer);
} else {
std::cout << u8"pBuffer 为空" << std::endl;
});

if (bMalloc)
{
std::cout << u8"为 pBuffer 分配内存" << std::endl;
pBuffer = (unsigned char *)malloc(1024 * sizeof(unsigned char));
}
std::cout << u8"离开 demo" << std::endl;
}

int main(void)
{
std::cout << u8"===============================" << std::endl;
demo(false);
std::cout << u8"===============================" << std::endl;
demo(true);
std::cout << u8"===============================" << std::endl;

return 0;
}

/*
输出:
===============================
进入 demo
离开 demo
清理 pBuffer
pBuffer 为空
===============================
进入 demo
为 pBuffer 分配内存
离开 demo
清理 pBuffer
pBuffer 不为空,调用 free 清理 pBuffer
===============================
*/

可以看到在第二次测试中,我们是在创建了 defer 对象以后再对 pBuffer 赋值,而 defer 对象也能正确地处理这种情况。当然,在同一个作用域中使用多次 DEFER 也是可以的,这种情况,defer 的调用顺序为 FILO,即最后的 DEFER 语句最先执行。

肆 - 总结

  1. 在 C++ 环境中,由于 RAII 的关系,大多数情况是不需要用到 ‘defer’ 关键字的。
  2. 在设计库时,请提供必要的资源句柄。
  3. 处理 C/C++ 混编工程时,很有可能需要 ‘defer’ 关键字。这个时候可以自己实现一个。