GotW #08 CHALLENGE EDITION Exception Safety
著者:Herb Sutter
翻译:kingofark
[声明]:本文内容取自www.gotw.ca网站上的Guru of the Week栏目,其著作权归原著者本人所有。译者kingofark在未经原著者本人同意的情况下翻译本文。本翻译内容仅供自学和参考用,请所有阅读过本文的人不要擅自转载、传播本翻译内容;下载本翻译内容的人请在阅读浏览后,立即删除其备份。译者kingofark对违反上述两条原则的人不负任何责任。特此声明。
Revision 1.0
Guru of the Week 条款08:GotW挑战篇——异常处理的安全性
难度:9 / 10
(异常处理机制是解决某些问题的上佳办法,但同时它也引入了许多隐藏的控制流程;有时候,要正确无误的使用它并不容易。不妨试试自己实现一个简单的container(这是一种可以对其进行push和pop操作的栈),看看它在异常-安全的(exception-safe)和异常-中立的(exception-neutral)情况下,到底会发生哪些事情。)
[问题]
1. 实现如下异常-中立的(exception-neutral)container。要求:Stack对象的状态必须保持其一致性(consistent);即使有内部操作抛出异常,Stack对象也必须是可析构的(destructible);T的异常必须能够传递到其调用者那里。
template <class T>
// T 必须有缺省的构造函数和拷贝构造函数
class Stack
{
public:
Stack();
~Stack();
Stack(const Stack&);
Stack& operator=(const Stack&);
unsigned Count(); // 返回T在栈里面的数目
void Push(const T&);
T Pop(); // 如果为空,则返回缺省构造出来的T
private:
T* v_; // 指向一个用于'vsize_' T对象的
// 足够大的内存空间
unsigned vsize_; // 'v_' 区域的大小
unsigned vused_; // 'v_' 区域中实际使用的T的数目
};
附加题:
2. 根据当前的C++标准,标准库中的container是异常-安全的(exception-safe)还是异常-中立的(exception-neutral)?
3. 应该让container成为异常-中立的(exception-neutral)吗?为什么?有什么折衷方案吗?
4. Container应该使用异常规则吗?比如,我们到底应不应该作诸如“Stack::Stack()throw(bad_alloc);”的声明?
挑战极限的问题:
5. 由于在目前许多的编译器中使用try和catch会给你的程序带来一些额外的负荷,所以在我们这种低级的可复用(reusable)Container中,最好避免使用它们。你能在不使用try和catch的情况下,按照要求实现Stack所有的成员函数吗?
在这里提供两个例子以供参考(注意这两个例子并不一定符合上述题目中的要求,仅供参考,以便于你下手解题):
template<class T>
Stack<T>::Stack()
: v_(0),
vsize_(10),
vused_(0)
{
v_ = new T[vsize_]; // 初始的内存分配(创建对象)
}
template<class T>
T Stack<T>::Pop()
{
T result; //如果为空,则返回缺省构造出来的T
if( vused_ > 0)
{
result = v_[--vused_];
}
return result;
}
[解答]
[作者记:这里的解决方案并不完全正确。本文经修正的增强版本,你可以在C++Report 1997年的9月号、11月号和12月号上面找到;另外,其最终版本在我的《Exceptional C++》里面。]
重要的事项:我确实不敢保证下面的解决方案完全满足了我原题的要求。实际上我连能够正确编译这些代码的编译器都找不到!在这里,我讨论了所有我能想得到的那些交互作用;而本文的主要目的则是希望说明,在编写异常-安全的(exception-safe)代码时需要格外的小心。
另外,Tom Cargill也有一篇非常棒的文章《Exception Handling:A False Sense of Security》(C++Report, vol.9 no.6, Nov-Dec 1994)。他通过这篇文章来说明,异常处理是个棘手的小花招,技巧性非常强,但也并不就是笼统的说不要使用异常处理,而是说人们不要过分的迷信异常处理。只要认识到这一点,并在使用时小心一点就可以了。
[作者再记:最后再说一点。为了简化解决方案,我决定不去讨论用来解决异常-安全(exception-safe)资源之归属问题的基类技术(base class technique)。我会邀请Dave Abrahams(或者其他人)来继续讨论,阐述这个非常有效的技术。]
现在先回顾一下我们的问题。需要的接口如下:
template <class T>
// T 必须有缺省的构造函数和拷贝构造函数
class Stack
{
public:
Stack();
~Stack();
Stack(const Stack&);
Stack& operator=(const Stack&);
unsigned Count(); //返回T在栈里面的数目
void Push(const T&);
T Pop(); //如果为空,则返回缺省构造出来的T
private:
T* v_; //指向一个用于'vsize_' T对象的
// 足够大的内存空间
unsigned vsize_; // 'v_'区域的大小
unsigned vused_; // 'v_' 区域中实际使用的T的数目
};
现在我们来看看实现。我们对T有一个要求,就是T的析构函数(destructor)不能抛出异常。这是因为,如果允许T的析构函数(destructor)抛出异常,那我们就很难甚至是不可能在保证代码安全性的前提下进行实现了。
//----- DEFAULT CTOR ----------------------------------------------
template<class T>
Stack<T>::Stack()
: v_(new T[10]), // 缺省的内存分配(创建对象)
vsize_(10),
vused_(0) // 现在还没有被使用
{
// 如果程序到达这里,说明构造过程没有问题,okay!
}
//----- 拷贝构造函数 -------------------------------------------------
template<class T>
Stack<T>::Stack( const Stack<T>& other )
: v_(0), // 没分配内存,也没有被使用
vsize_(other.vsize_),
vused_(other.vused_)
{
v_ = NewCopy( other.v_, other.vsize_, other.vsize_ );
//如果程序到达这里,说明拷贝构造过程没有问题,okay!
}
//----- 拷贝赋值 -------------------------------------------
template<class T>
Stack<T>& Stack<T>::operator=( const Stack<T>& other )
{
if( this != &other )
{
T* v_new = NewCopy( other.v_, other.vsize_, other.vsize_ );
//如果程序到达这里,说明内存分配和拷贝过程都没有问题,okay!
delete[] v_;
// 这里不能抛出异常,因为T的析构函数不能抛出异常;
// ::operator delete[] 被声明成throw()
v_ = v_new;
vsize_ = other.vsize_;
vused_ = other.vused_;
}
return *this; // 很安全,没有拷贝问题
}
//----- 析构函数 ----------------------------------------------------
template<class T>
Stack<T>::~Stack()
{
delete[] v_; // 同上,这里也不能抛出异常
}
//----- 计数 -----------------------------------------------------
template<class T>
unsigned Stack<T>::Count()
{
return vused_; // 这只是一个内建类型,不会有问题
}
//----- push操作 -----------------------------------------------------
template<class T>
void Stack<T>::Push( const T& t )
{
if( vused_ == vsize_ ) // 可以随着需要而增长
{
unsigned vsize_new = (vsize_+1)*2; // 增长因子
T* v_new = NewCopy( v_, vsize_, vsize_new );
//如果程序到达这里,说明内存分配和拷贝过程都没有问题,okay!
delete[] v_; //同上,这里也不能抛出异常
v_ = v_new;
vsize_ = vsize_new;
}
v_[vused_] = t; // 如果这里抛出异常,增加操作则不会执行,
++vused_; // 状态也不会改变
}
//----- pop操作 ------------------------------------------------------
template<class T>
T Stack<T>::Pop()
{
T result;
if( vused_ > 0)
{
result = v_[vused_-1]; //如果这里抛出异常,相减操作则不会执行,
--vused_; // 状态也不会改变
}
return result;
}
//
// 注意: 细心的读者Wil Evers第一个指出,
// “正如在问题中定义的那样, Pop()强迫使用者编写非异常-安全的代码,
// 这首先就产生了一个负面效应(即从栈中间pop出一个元素);
// 其次,这还可能导致遗漏某些异常(比如将返回值拷贝到代码调用者的目标
// 对象上)。”
//
// 同时这也表明,很难编写异常-安全的代码的一个原因就是因为
// 它不仅影响代码的实现部分,而且还会影响其接口!
// 某些接口(比如这里的这一个)不可能在完全保证异常-安全的情况下被实现。
//
// 解决这个问题的一个可行方法是把函数重新构造成
// "void Stack<T>::Pop( T& result)".
// 这样,我们就可以在栈的状态改变之前得知到结果的拷贝是否真的成功了。
// 举个例子如下,
// 这是一个更具有异常-安全性的Pop()
//
template<class T>
void Stack<T>::Pop( T& result )
{
if( vused_ > 0)
{
result = v_[vused_-1]; //如果这里抛出异常,
--vused_; // 相减操作则不会执行,
} // 状态也不会改变
}
//
// 这里我们还可以让Pop()返回void,然后再提供一个Front() 成员函数,
// 用来访问顶端的对象
//
//----- 辅助函数 -------------------------------------------
// 当我们要把T从缓冲区拷贝到一个更大的缓冲区时,
// 这个辅助函数会帮助分配新的缓冲区,并把元素原样拷贝过来。
// 如果在这里发生了异常,辅助函数会释放占用得所有临时资源,
// 并把这个异常传递出去,保证不发生内存泄漏。
//
template<class T>
T* NewCopy( const T* src, unsigned srcsize, unsigned destsize )
{
destsize = max( srcsize, destsize ); // 基本的参数检查
T* dest = new T[destsize];
// 如果程序到达这里,说明内存分配和构造函数都没有问题,okay!
try
{
copy( src, src+srcsize, dest );
}
catch(...)
{
delete[] dest;
throw; // 重新抛出原来的异常
}
// 如果程序达到这里,说明拷贝操作也没有问题,okay!
return dest;
}
对附加题的解答:
第2题:根据当前的C++标准,标准库中的container是异常-安全的(exception-safe)还是异常-中立的(exception-neutral)?
关于这个问题,目前还没有明确的说法。最近委员会也展开了一些相关的讨论,涉及到应该提供并保证弱异常安全性(即“container总是可以进行析构操作”)还是应该提供并保证强异常安全性(即“所有的container操作都要从语义上具有‘要么执行要么撤销(commit-or-rollback)’的特性”)。正如Dave Abrahams在委员会中的一次讨论以及随后通过电子邮件进行的讨论中所表明的那样,如果实现了对弱异常安全性的保证,那么强异常安全性也就很容易得到保证了。我们在上面提到的几个操作正是这样的。
第3题:应该让container成为异常-中立的(exception-neutral)吗?为什么?有什么折衷方案吗?
有时候,为了保证某些container异常-中立性(exception-neutrality),其内的某些操作将会不可避免的付出一些空间代价。可见异常-中立性(exception-neutrality)本身并不错,但是当实现强异常安全性所要付出的空间或时间代价远远大于实现弱异常安全性的付出的时候,要实现异常-中立性(exception-neutrality)就太不现实了。有一个比较好的折衷方案,那就是用文档记录下T中不允许抛出异常的操作,然后通过遵守这些文档规则来保证其异常-中立性(exception-neutrality)。
第4题:Container应该使用异常规则吗? 比如,我们到底应不应该作诸如“Stack::Stack()throw(bad_alloc);”的声明?
答案是否定的。我们不能这样做,因为我们预先并不知道T中哪些操作会抛出异常,也不知道会抛出什么样的异常。
应该注意的是,有些container的某些操作(例如,Count())只是简单的返回一个数值,所以我们可以断定它不会抛出异常。虽然我们原则上可以用throw()来声明这类操作,但是最好不要这么做;原因有两个:第一,如果你这样做了,那么当你以后想修改实现细节使其可以抛出异常的时候,就会发现其存在着很大的限制;第二,无论异常是否被抛出,异常声明(exception specification)都会带来额外的性能开销。因此,对于那些频繁使用的操作,最好不要作异常声明(exception specification)以避免这种性能开销。
对挑战极限题的解答:
第5题:由于在目前许多的编译器中使用try和catch会给你的程序带来一些额外的负荷,所以在我们这种低级的可复用(reusable)Container中,最好避免使用它们。你能在不使用try和catch的情况下,按照要求实现Stack所有的成员函数吗?
是的,这是可行的,因为我们仅仅只需要捕获“...”部分(见下面的代码)。一般,形如
try { TryCode(); } catch(...) { CatchCode(parms); throw; }
的代码都可以改写成这样:
struct Janitor {
Janitor(Parms p) : pa(p) {}
~Janitor() { if uncaught_exception() CatchCode(pa); }
Parms pa;
};
{
Janitor j(parms); // j is destroyed both if TryCode()
// succeeds and if it throws
TryCode();
}
我们只在NewCopy函数中使用了try和catch。下面就是重写的NewCopy函数,用以体现上面说的改写技术:
template<class T>
T* NewCopy( const T* src, unsigned srcsize, unsigned destsize )
{
destsize = max( srcsize, destsize ); // basic parm check
struct Janitor {
Janitor( T* p ) : pa(p) {}
~Janitor() { if( uncaught_exception() ) delete[] pa; }
T* pa;
};
T* dest = new T[destsize];
// if we got here, the allocation/ctors were okay
Janitor j(dest);
copy( src, src+srcsize, dest );
// if we got here, the copy was okay... otherwise, j
// was destroyed during stack unwinding and will handle
// the cleanup of dest to avoid leaking memory
return dest;
}
我已经说过,我曾与几个擅长靠经验来进行速度测试的人讨论过上述问题。结论是在没有异常发生的情况下,try和catch往往要比其它方法快得多,而且今后还可能变得更快。但尽管如此,这种避免使用try和catch的技术还是非常重要的,一来是因为有时候就是需要写一些比较规整、比较容易维护的代码;二来是因为现有的一些编译器在处理try和catch的时候,无论在产生异常的情况下还是在不产生异常的情况下,都会生成效率极其低下的代码。