1. 异常安全
接下来两次,我将讨论“异常安全”,C++标准中使用了(在auto_ptr中)却没有定义的术语。在C++范围内,不同的作者使用这个术语却表达不同的含义。在我的专题中,我从两个方面来定义“异常安全”:
l 如果一个实体捕获或抛出一个异常,但仍然维持它公开保证的语义,它就是“接口安全”的。依赖于它保证的力度,实体可能不允许将任何异常漏给其用户。
l 如果异常没有导致资源泄漏或产生未定义的行为,实体就是“行为安全”的。“行为安全”一般是强迫的。幸运的是,如果做到了“行为安全”,通常也间接提供了“接口安全”。
异常安全有点象const:好的设计必须在一开始就考虑它,它不能够事后补救。但是我们开始使用异常还没有多少年,所以还没有“异常安全问题集”这样的东西来指导我们。实际上,我期望大家通过一条艰辛的道路来掌握异常安全:通过经历异常故障在编码时绕过它们;或关闭异常特性,认为它们“太难”被正确掌握。
我不想撒谎:分析设计上的异常安全性太难了。但是,艰辛的工作也有丰厚的回报。不过,这个主题太难了,想面面俱到的话将花我几个月的时间。我最小的目标是:通过缺乏异常安全的例子来展示怎么使它们变得安全,并激励你在此专题之外去看和学更多的东西。
1.1 构造函数
如果一个普通的成员函数
x.f()
抛出一个异常,你可以容忍此异常并试图再次调用它:
X x;
bool done;
do
{
try
{
done = true;
x.f();
}
catch (...)
{
// do something to recover, then retry
done = false;
}
}
while (!done);
但,如果你试图再次调用一个构造函数,你实际上是调用了一个完全不同的对象:
bool done(false);
while (!done)
{
try
{
done = true;
X x; // calls X::X()
}
// from this point forward, `x` does not exist
catch (...)
{
// do something to recover, then retry
done = false;
}
}
你不能挽救一个构造函数抛异常的对象;异常的存在表明那个对象已经死了。
当一个构造函数抛异常时,它杀死了其宿主对象而没有调用析构函数。这样的抛异常行为危害了“行为安全”:如果这个抛异常的构造函数分配了资源,你无法依赖析构函数释放它们。一般构造和析构是成对的,并期待后者清理前者。如果析构函数没有被调用,这个期望是不满足的。
最后,如果你从构造函数中抛了一个异常,并且你的类是用户类的一个基类或子对象,那么用户类的构造函数必须处理你抛出的异常。或者它将异常抛给另外一个用户类的构造函数,如此递推下去,直到程序调用terminate()。实际上用户必须做你没有做的工作(维持构造函数的安全性)。
1.2 关于取舍的问题
构造函数抛异常同时降低了接口安全和行为安全。除非有迫不得以的理由,不要让构造函数抛异常。
也有不同的意见认为:异常应该被本来就做这事的专门代码捕获的。那些只是静静地接收异常而没有处理它们的异常处理函数违背了这些异常的初衷。如果一个函数没有准备好正确地处理一个异常,它应该将这个异常传递下去。
最低事实是:必须有人处理异常;如果所有人都放过它,程序将终止。还必须同时捕获触发异常的条件;如果没人标记它,程序可能以任何方式终止,并且恐怕不怎么文雅。
一个异常对象警示我们存在一个不该忽略的错误状况。不幸的是,这个对象的存在可能导致一个全新的不同的错误状况。在设计异常安全的时候,你必须在两个有时冲突的设计原则间进行取舍。
1.在错误发生时进行通报
2.防止这个通报行为导致其它错误。
因为构造函数抛异常可能有有害的副作用,你必须小心权衡这两个原则。我不允许我写的构造函数中抛异常,这样设计倾向于原则2;但我不想将它推荐为普遍原则,在其它情况下这两个原则是等重的。自己好自判断吧。
1.3 析构函数
析构函数抛异常可能使程序有奇怪的反应。它可能彻底地杀死程序。根据C++标准(subclause 15.1.1,“the terminate() function” ),简述如下:
在某些情况下,异常处理必须被抛弃以减少一些微妙的错误。这些情况中包括:当因为异常而退栈过程中将要被析构的对象的析构函数。在这些情况下,函数void terminate()被调用。退栈不会完成。
简而言之,析构函数不该提示是否发生了异常。但,如我上次所说,新的C++标准运行库程序uncaught_exception()可以让析构函数确定其所处的异常环境。不幸的是,我上次也说了,Visual C++未能正确地支持这个函数。
问题比我提示的还要糟。我上次写到,Microsoft的uncaught_exception()函数版本一定返回false,所以Visaul C++总告诉你的析构函数当前没有发生异常,在其中抛异常是可以的。如果你从一个支持uncaught_exception的环境转到Visual C++,以前正常工作的代码可能开始调用terminate()了。
要尝试一下的话,试下面的例子:
#include <exception>
#include <stdio.h>
#include <stdlib.h>
using namespace std;
static void my_terminate_handler(void)
{
printf("Library lied; I'm in the terminate handler.\n");
abort();
}
class X
{
public:
~X()
{
if (uncaught_exception())
printf("Library says not to throw.\n");
else
{
printf("Library says I'm OK to throw.\n");
throw 0;
}
}
};
int main()
{
set_terminate(my_terminate_handler);
try
{
X x;
throw 0;
}
catch (...)
{
}
printf("Exiting normally.\n");
return 0;
}
在C++标准兼容的环境下,你得到:
Library says not to throw.
Exiting normally.
但Visual C++下,你得到:
Library says I'm OK to throw.
Library lied; I'm in the terminate handler.
并跟随一个程序异常终止。
And with six you get egg roll.
建议:除非你确切知道你现在及以后所用的平台都正确支持uncaught_exception(),不要调用它。
1.4 部分删除
即使你知道当前不在处理异常,你仍然不应该在析构函数中抛异常。考虑如下的例子:
class X
{
public:
~X()
{
throw 0;
}
};
int main()
{
X *x = new X;
delete x;
return 0;
}
当main()执行到delete x,如下两步将依次发生:
x的析构函数被调用。
operator delete被调用了来释放x的内存空间。
但因为x的析构函数抛了异常,operator delete没有被调用。这危及了行为安全。如果还不信,试一下这个更完整的例子:
#include <stdio.h>
#include <stdlib.h>
class X
{
public:
~X()
{
printf("destructor\n");
throw 0;
}
void *operator new(size_t n) throw()
{
printf("new\n");
return malloc(n);
}
void operator delete(void *p) throw()
{
printf("delete\n");
if (p != NULL)
free(p);
}
};
int main()
{
X *x = new X;
try
{
delete x;
}
catch (...)
{
printf("catch\n");
}
return 0;
}
如果析构函数没有抛异常,程序输出:
new
destructor
delete
实际上程序输出:
new
destructor
catch
operator delete没有进入,x的内存空间没有被释放,程序有资源泄漏,the press hammers your product for eating memory, and you go back to flipping burgers for a living。
原则:异常安全要求你不能在析构函数中抛异常。和在构造函数抛异常上有不同意见不一样,这条是绝对的。为了明确表明意图,应该在申明析构函数时加上异常规格申明throw()。
1.5 预告
我本准备覆盖模板安全的,但没地方了。我将留到下次介绍,并开出推荐读物表。