对于资源,这里我分为两类:
1. 内存资源
2. 非内存资源(文件、网络资源等)
C++ 对于内存资源的管理部分是自动的:栈上的内存资源将被自动释放,堆上的内存资源需要程序员自己释放。
手动管理内存资源,总会让我们在一些极端情况下犯错。一个最简单,最常见的例子如下:
void f()
{
A* a = new A();
B* b = new B();
C* c = new C();
// ...
if (某种条件)
{
// 失败时
delete a;
delete b;
delete c;
// ...
return;
}
// 成功继续处理
Z* z = new Z();
try
{
f2(); // f2 可能抛出异常
} catch (const xxx&) {
delete a;
delete b;
delete c;
// 忘记释放 z
}
// 没有异常继续处理
}
不论是多么优秀的程序员,他也不能保证永远正确的释放了内存资源,遇见上面情况的解决办法之一就是使用 autoptr,我们可以这样写:
void f()
{
autoptr<A> a(new A());
autoptr<B> b(new B());
autoptr<C> c(new C());
// ...
if (某种条件)
{
// 失败时,无需释放资源
return;
}
// 成功继续处理
autoptr<Z> z(new Z());
try
{
f2(); // f2 可能抛出异常
} catch (const xxx&) {
// 无需释放资源
}
// 没有异常继续处理
}
不错,这看起来很完美,我们解决了异常情况下内存资源释放的问题。但是,对于非内存资源呢?例如:
void f()
{
FILE* pf1 = fopen("killercat.blog1", "w");
FILE* pf2 = fopen("killercat.blog2", "w");
FILE* pf3 = fopen("killercat.blog3", "w");
// ...
try
{
f2(); // f2 可能抛出异常
} catch (const xxx&) {
fclose(pf1); // 又回到了丑陋的代码
fclose(pf2);
fclose(pf3);
}
}
很显然,FILE 不是一个类,我们无法使用诸如 autoptr 这样的管理器来进行资源的生命周期管理,对非内存资源的生命周期的管理,我们有另外一种方式可循:把非内存资源的生命周期的管理转化为内存资源的生命周期的管理。
RAII(Resource Acquisition Is Initialization),资源(这里的资源通常是指的是非内存资源)获取即使初始化,换而言之,我们将利用 C++ 构造函数和析构函数来维护一个不变式(invariant):对象存在则资源(常常指非内存资源也可以只内存资源)有效。一个经典的例子就是:
// use an object to represent a resource ("resource acquisition is initialization")
class File_handle
{
// belongs in some support library
FILE* p;
public:
File_handle(const char* pp, const char* r)
{
p = fopen(pp,r);
if (p==0)
throw Cannot_open(pp);
}
File_handle(const string& s, const char* r)
{
p = fopen(s.c_str(),r);
if (p==0)
throw Cannot_open(pp);
}
~File_handle()
{
fclose(p);
} // destructor
// copy operations and access functions
};
void f(string s)
{
File_handle file(s, "r");
// use file
}
从上面的 File_handle 类可以看出,构造对象的时候,我们初始化非内存资源 FILE,当对象释放时,FILE 同时被释放。
通过 RAII 我们能够将非内存资源生命周期管理转换到内存资源生命周期的管理上来,那么上面丑陋的代码转化为:
void f()
{
autoptr<File_handle> pf1(new File_handle("killercat.blog1", "w"));
autoptr<File_handle> pf2(new File_handle("killercat.blog2", "w"));
autoptr<File_handle> pf3(new File_handle("killercat.blog3", "w"));
// ...
try
{
f2(); // f2 可能抛出异常
} catch (const xxx&) {
// 资源能够被正确释放
}
}
RAII 的实质:
1. 通过把非内存资源的生命周期管理转化为内存资源的生命周期管理,达到对非内存资源的安全释放。
2. 通过维护一个 invariant(对象存在即资源有效),来简化编程。
当然,这里不仅仅可以使用 autoptr 也可以使用 shared_ptr 等智能指针。不过,除此之外,RAII 还维护了一个 invariant --- 对象存在即资源有效,invariant 的出现,必定带来一个结果:简化编程。看下面的例子:
class File_handle
{
FILE* p;
public:
File_handle(const char* pp, const char* r)
{
p = fopen(pp,r);
if (p==0)
throw Cannot_open(pp);
}
File_handle(const string& s, const char* r)
{
p = fopen(s.c_str(),r);
if (p==0)
throw Cannot_open(pp);
}
~File_handle()
{
fclose(p);
} // destructor
int GetChar()
{
// 无需检查 p,因为 p 一定有效
return fgetc(p);
}
};
我们可以看到,在对象存在的时候,文件资源总是有效的,因为我们无需判断指针 p 是否合法,这简化了编程,使得代码更加优雅。相比之下,这样实现就不那么美观:
class File_handle
{
FILE* p;
public:
File_handle()
{
// 必须初始化为 NULL,否则下面的所有检查将无效,代码将非常脆弱
p = NULL;
}
~File_handle() {}
void Open(const char* pp, const char* r)
{
// 必须判断,是否调用两次 Open
if (p == NULL)
{
p = fopen(pp,r);
if (p==0)
throw Cannot_open(pp);
}
else
{
throw Close_first();
}
}
void Close()
{
if (p != NULL)
{
fclose(p);
// 必须置空,否则程序其他地方判断将失效
p == NULL;
}
else
{
throw Open_first();
}
}
int GetChar()
{
// 类似于 GetChar 的函数都需要判断,否则在调用 Open 之前调用程序将出错
if (p != NULL)
{
return fgetc(p);
}
else
{
throw Open_first();
}
}
};
显而易见,对于资源是否有效的判断已经使得程序员是否疲惫,而且,他们还需要不停的维护资源的指针,只要任何一步出错,程序都将变得脆弱,更作用的是,不但编写这样的程序不会有任何快感,使用这样的代码也十分郁闷,比如调用 GetChar 的时候,需要当心函数抛出的来异常,不断的写 try catch(如果使用 error codes 来表示错误,也需要不断的判断函数执行是否出错),在一些复杂的情况下,更加让人抓狂:
bool AddGroup(int idUser)
{
// 检查 idUser 是否允许加入组
// ...
// 检查成功,允许加入,扣去入会费
SpendUserMoney(idUser, 10000);
// 对于入会成功用户增加一个等级
AddUserLevel(idUser);
// 等等操作
// 将其写入文件中
try
{
// 在文件中写入用户当前等级,入会花费等信息
file.Write("xxx");
} catch (const Open_first&)
{
// 异常出现
// 恢复现场
AwardUserMoney(10000);
ReduceUserLevel(idUser);
// 等等
}
}
由上例可见,为了处理异常情况而做的工作太大,而且我们每次调用时都要进行相似的处理。虽然上例不一定完全合乎实际情况,或许你认为它还有改良的空间,不过无论如何,我们难免要做太多多余的工作。
自此,已经谈完了 RAII 设计方式的好处,那么下面谈谈应该注意一些什么:
1. 如果没有必要,应该禁止复制。
如果没有必要应该禁用复制构造函数和赋值操作符,这点很重要,如果没有按需要定义复制构造函数和赋值操作符,那么得到的结果通常是:非内存资源被创建一次,释放多次。
2. 如果需要复制,应该谨慎考虑。
重新定义复制构造函数和赋值操作符是必须的。怎么写它们是一个问题,具体的方案依赖于实际的需要,可以使用深拷贝,也可以使用类似于 shared_ptr 的引用计数机制,或者传递所有权。
总结一下:
1. 使用 RAII 能够将非内存资源的生命周期管理转为内存资源生命周期的管理,使得我们能够使用管理内存资源的手段来管理非内存资源,例如 auto_ptr,shared_ptr。
2. 维护 invariant 能够简化编程,使的代码简洁优雅。RAII 设计方式维护了这么一条 invariant:对象存在则资源有效。除此之外,我们还可以使用异常机制来维护另外一条 invariant:函数要么执行成功得到正确的结果,要么抛出异常。对此条,这里就不做任何阐述了。
3. 使用 RAII 时,应该警惕复制行为。