技术: C++ RAII 相关技术总结

说说 RAII技术 和 auto_ptr.

在 smart-pointers-again 一文里面我谈 auto_ptr 时略带说过 RAII, 本文细讲.

每次谈到 资源管理 大概都会说Gc, Raii, PIMPL, 智能指针, 健壮指针, 数据的一致性, 泄露等问题, 本文也想探讨一下, 但是以 RAII 为主.

关于只能指针, 可以参考我前面的两篇文章:

第二次修订, 补充以下主题

  • 异常
  • PIMPL

因为 PIMPL, RAII, 异常貌似和资源管理分不开.

引子

自动资源回收机制 Resource Acquisition Is Initialization, 说白了不值得一提, 但是就面向对象的权威和完整性上来, 我要站出来为C++正名.

正文

PIMPL

之所以PIMPL会和RAII扯上关系, 可能是因为PIMPL这种设计风格, 就是在类的内部, 成员保留的是指针, 而不是实际成员.

PIMPL 全称是 Pointer to Implementation, 形式上一般就是保留一个私有指针, 好比下面的代码:

1
2
3
4
5
6
7
8
9
class X;

class C
{
public:
void Fun();
private:
X *pImpl; //pimpl
};

如果你直接保留 X x, 那么势必增加耦合程度, 并且编译的时候, 要知道X的实现(试想一下静态库和动态库的例子). 现在这么做, 可以把具体实现推迟到子类, 达成接口和实现分离的目的.

总结一下, 这么做的好处:

  • 降低模块&类之间的耦合程度
  • 提高编译速度(保留指针时, 编译时分配的大小总是不变的–平台不变的话)
  • 接口和实现分离, 提高了扩展性(对外部类隐藏了具体的实现)

RAII

你要说这个是C++语言的一个资源回收惯用手段, 我觉得应该说, 面向对象的语言都可以有该手段(当然如果有GC, 又封装了指针的话, 没有也不奇怪—我可没有点名说某一种JVM语言啊.笑).

Resource Acquisition Is Initialization, 资源获取就是初始化, 总感觉你去说它的全称或者英文, 比较奇怪. 直接说RAII就好了. 实际上, 我就看到只有Bjarne Stroustrup大佬说全称而已.

RAII手法, 说白了就是依赖构造&析构机制, 和异常交互作用的一种方法&策略. 他还是建立在语言执行流程的机制上, 来完成资源的分配和回收. 计算机中的资源, 常见的: 文件, 内存, 网络套接字, 连接, 锁(mutex locks)等. 正式由于资源不是无线的, 所以一般都会有资源管理, 真正涉及到的流程无非就是:

  • 获取资源
  • 使用资源
  • 释放资源

真正对于我们重要的是使用资源的部分, 无奈要操心获取和释放

为了资源管理, 大佬们想了很多很多办法, 常见的就是 RAII, 智能指针, GC, 内存池, 分配器等等. 但是很多高层次的机制, 其实是建立在RAII手法之上的, 或者说是其构成因素之一. 关于资e源管理, 可以参考我的博文 心好累的资源管理.

回到获取&释放上面来说, 一个常见的案例:

1
2
3
4
5
6
7
8
9
10
void UseFile(char const* fn)  
{
FILE* f = fopen(fn, "r"); // 获取资源
// 使用资源
if (!g()) return; // 如果操作g失败!
// ...
if (!h()) return; // 如果操作h失败!
// ...
fclose(f); // 释放资源
}

显然, 在出错流程上面, 忘记做资源释放处理了, 应该是下面的代码:

1
2
3
4
5
6
7
8
9
10
void UseFile(char const* fn)  
{
FILE* f = fopen(fn, "r"); // 获取资源
// 使用资源
if (!g()) { fclose(f); return; }
// ...
if (!h()) { fclose(f); return; }
// ...
fclose(f); // 释放资源
}

但是你看到了, 有多少流程分支, fclose(f) 我们就要写多少遍. 更加糟糕的是, 我们还没有考虑加入异常处理, 如果加入异常处理, 你看看应该是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void UseFile(char const* fn)  
{
FILE* f = fopen(fn, "r"); // 获取资源
// 使用资源
try {
if (!g()) { fclose(f); return; }
// ...
if (!h()) { fclose(f); return; }
// ...
}
catch (...)
{
fclose(f); // 释放资源
throw;
}
fclose(f); // 释放资源
}

仅仅是一个资源, 代码就已经如此臃肿了, 编码效率也下降了, 当然现实中, 代码的可维护性也差极了. 于是大佬们就开始想一些自动化的办法, 在面向对象的基础上, 把所有资源全部封装在对象内部, 搞了一个RAII. (当然智能指针, GC啥的, 那是后话)

想法都不是凭空的, 是按照原有的流程优化创新而来的, RAII其实是基于这样的使用流程:

1
2
3
4
5
6
7
8
9
10
11
12
void UseResources()  
{
// 获取资源1
// ...
// 获取资源n

// 使用这些资源

// 释放资源n
// ...
// 释放资源1
}

不难看出资源管理技术的关键在于:要保证资源的释放顺序与获取顺序严格相反。这自然使我们联想到局部对象的创建和销毁过程。在C++中,定义在栈空间上的局部对象称为自动存储(automatic memory)对象。管理局部对象的任务非常简单,因为它们的创建和销毁工作是由系统自动完成的。我们只需在某个作用域(scope)中定义局部对象(这时系统自动调用构造函数以创建对象),然后就可以放心大胆地使用之,而不必担心有关善后工作;当控制流程超出这个作用域的范围时,系统会自动调用析构函数,从而销毁该对象。

也就是说 RAII 其实是借鉴了局部对象在指定的作用于内的自动创建, 自动销毁的特性. 将资源抽象为类, 用局部对象来表示资源, 把管理资源的任务转化为管理局部对象的任务.

那么原来的文件管理, 锁管理完全可以交给对象, 例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Widget  
{
public:
Widget(char const* myFile,
char const* myLock) : file_(myFile), // 获取文件myFile
lock_(myLock) // 获取互斥锁myLock
{}
// ...

private:
FileHandle file_;
LockHandle lock_;
};

看到啦, 资源已经和具体的类(对象)绑定了. 实际上结合 PIMP应该是这样的:

1
2
3
4
5
6
7
8
9
10
11
class FileHandle  
{
public:
FileHandle(char const* n, char const* a) { p = fopen(n, a); }
~FileHandle() { fclose(p); }
private:
// 禁止拷贝操作
FileHandle(FileHandle const&);
FileHandle& operator= (FileHandle const&);
FILE *p;
};

RAII 的本质就可以概括了: 用对象代表资源, 把管理资源的任务转化为管理对象的任务. 将资源的获取和释放与对象的构造和析构对应起来,从而确保在对象的生存期内资源始终有效,对象销毁时资源必被释放.

换句话说, 拥有对象, 就是拥有资源; 对象在, 则资源在. (但是实现起来语言细节和工程细节, 也有很多要注意的地方, 比如上面的拷贝赋值等问题)

既然RAII是一种局部对象管理资源技术, 那么如果在使用资源中抛出了异常, 构造或者析构还能正常作用么? 其实是不必担心的, 因为c++的异常机制里面, 在调用栈不断向上寻找catch的时候系统保证调用所有已经创建了的局部对象的析构函数. (也就是说 栈解旋 的过程中, 是自动按顺序析构局部栈对象的)

例如下面的例子:
``
void Foo()
{
FileHandle file1(“n1.txt”, “r”);
FileHandle file2(“n2.txt”, “w”);

Bar(); // 可能抛出异常

FileHandle file3(“n3.txt”, “rw”);
}
```
此时的已经分配的对象是file2, file1, 那么它们资源会随着异常的执行流程, 自动释放: file2->file1. (file3还没有创建, 所以没它啥事儿)

总结来说:

  • RAII其实是解决了多个资源的获取和释放问题, 资源越多, 越能看出这种机制的优势, 局部对象自动管理优势.

但是引入了一些严峻的问题, 异常处理问题:

  • 异常处理问题(我以前可以不写的异常代码, 现在可能为了对象在解旋过程中自动调用, 而必须要写异常处理)
  • 赋值和拷贝过程中资源的处理问题(move还是不move, 托管权转移了, 释放问题)
  • 对象封装资源的时候的手法, 是IMPL还是直接保存对象
  • 多个对象获取是, 是要么全部获取, 还是要么一个失败了,其他的都不要了 (一般是要么全获取, 要么全不获取; 保证对象存在的完整性)

比较好的实现, 可以参考一下 auto_ptr, unique_ptr 源码, 参考我的博文 smart-pointer-again


尾巴

RAII 问题, 本身并不难理解, 但是由于RAII的实现, 所引发的问题, 比如赋值拷贝, 多资源事务性问题, 异常问题等, 才是需要关注的问题. 关于异常, 这个C++一直争论的没完了的问题 也可以参考我的文章 再争异常.

参考资料

  1. cppreference参考
  2. 《Exceptional C++》 作者Herb Sutter, 主要谈了异常问题
  3. 《C++程序设计语言(第3版)》 Bjarne Stroustrup, 讲资源管理的时候
文章目录
  1. 1. 引子
  2. 2. 正文
    1. 2.1. PIMPL
    2. 2.2. RAII
  3. 3. 尾巴
  4. 4. 参考资料
|