Guru of the Week #8 具有挑战性的话题:异常安全(exception safety)

    技术2022-05-11  80

    #8 具有挑战性的话题:异常安全(exception safety)难度:9/10

    C++的异常机制是解决某些问题的很好的方法,但它引入了许多隐藏的控制流程,难以使用。尝试自己实现一个非常简单的容器(一个可以push和pop的stack),看看要达到异常安全(exception-safe)和异常中立(exception-neutral)需要涉及哪些问题。

    问题:

    1.实现下面的容器,要求是异常中立的(exception-neutral)。Stack对象必须始终状态一致而且即使在内部操作抛出异常时也是可析构的,而且应该允许T内部抛出的异常传递到调用者。    template <class T>        // T must have default ctor and copy assignment    class Stack    {    public:        Stack();        ~Stack();        Stack(const Stack&);        Stack& operator=(const Stack&);

            unsigned Count();   // returns # of T's in the stack        void     Push(const T&);        T        Pop();     // if empty, returns default-                            // constructed T

        private:        T*       v_;        // pointer to a memory area big                            //  enough for 'vsize_' T objects        unsigned vsize_;    // the size of the 'v_' area        unsigned vused_;    // the number of T's actually                            //  used in the 'v_' area    };

    附加题:

    2.根据目前的标准草案,标准库中的容器是异常安全(exception-safe)或异常中立(exception-neutral)的吗?[译者注:此期GotW问题在1997年4月提出]

    3.容器是否应该是异常中立(exception-neutral)的?为什么?折衷办法是什么?

    4.容器是否应该使用异常声明?例如,我们是否应该声明"Stack::Stack()throw(bad_alloc);"?

    挑战:   对于很多现行的编译器,使用"try"和"catch"经常给你的程序增加额外负担,在这种低层次的可重用的容器中最好避免使用。你是否能够实现所有Stack的成员函数使之既能够满足上述要求,又不使用"try"和"catch"?

       ****************************************  这儿有两个示例函数(但是并不能完全满足上述要求)可以参考:

      template<class T>    Stack<T>::Stack()      : v_(0),        vsize_(10),        vused_(0)    {        v_ = new T[vsize_]; // initial allocation    }

        template<class T>    T Stack<T>::Pop()    {        T result; // if empty, return default-constructed T        if( vused_ > 0)        {            result = v_[--vused_];        }        return result;    }

    解答      [这个解答现在看来并不是完全正确,更新并作了很多扩充的解答,请看我在1997年9月和10/11月的C++ Report杂志上发表的文章,比此更深入的讨论请参看我的"Exceptional C++"]   重要说明:我并不声称下面的解答满足所有的上述要求。实际上,我还没有找到编译器能够编译通过。虽然我把我能够想到的所有的各种相互作用和影响都提出来了,但这个练习的主要目的是为了说明写出异常安全的代码需要程序员非常的小心。   另外,可以参看Tom Cargill的一篇非常优秀的文章:"Exception Handling: A False Sense of Security"( C++ Report,vol.9 no.6,Nov-Dec 1994).他展示了异常处理是非常需要技巧的,但请注意他的文章并不是主张完全摒弃异常处理,只是告诉人们使用异常机制需要非常的小心。   最后一个说明:为了使解答更加简单,我决定不采用用基类的方法来解决异常安全的资源归属(resource ownership)问题。我将邀请Dave Abrahams(或者其他人)继续我们的解答并展示这个非常有效的方法。   回顾我们的问题,下面是需要实现的接口:   template <class T>        // T must have default ctor and copy assignment    class Stack    {    public:        Stack();        ~Stack();        Stack(const Stack&);        Stack& operator=(const Stack&);

            unsigned Count();   // returns # of T's in the stack        void     Push(const T&);        T        Pop();     // if empty, returns default-                            // constructed T

        private:        T*       v_;        // pointer to a memory area big                            //  enough for 'vsize_' T objects        unsigned vsize_;    // the size of the 'v_' area        unsigned vused_;    // the number of T's actually                            //  used in the 'v_' area    };      现在开始讨论实现。首先我们对于T有一个要求:就是T的析构函数必须不能抛出异常。如果析构函数可以抛出异常,要安全的实现许多操作就会是非常的困难甚至是不可能的。

    //----- DEFAULT CTOR ----------------------------------------------template<class T>Stack<T>::Stack()  : v_(new T[10]),  // 缺省的内存分配    vsize_(10),    vused_(0)       // 还没有使用任何内存{    // 如果程序执行到此,构造函数就成功了}

    //----- COPY CTOR -------------------------------------------------template<class T>Stack<T>::Stack( const Stack<T>& other )  : v_(0),      // nothing allocated or used yet    vsize_(other.vsize_),    vused_(other.vused_){    v_ = NewCopy( other.v_, other.vsize_, other.vsize_ );    // 如果程序执行到此, 拷贝构造函数就成功了}

    //----- COPY ASSIGNMENT -------------------------------------------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_ );        //如果程序执行到此, 内存分配和拷贝就成功了

            delete[] v_;        // 注意这条语句不能抛出异常,因为T的析构函数不会抛出异常        // 而delete操作符声明为throw()

            v_ = v_new;        vsize_ = other.vsize_;        vused_ = other.vused_;    }

        return *this;   // 安全,不涉及拷贝}

    //----- DTOR ------------------------------------------------------template<class T>Stack<T>::~Stack(){    delete[] v_;    // 此处同样不会抛出异常}

    //----- COUNT -----------------------------------------------------template<class T>unsigned Stack<T>::Count(){    return vused_;  // 这是一个原生类型,不会导致异常}

    //----- PUSH ------------------------------------------------------template<class T>void Stack<T>::Push( const T& t ){    if( vused_ == vsize_ )  // grow if necessary    {        unsigned vsize_new = (vsize_+1)*2; // grow factor        T* v_new = NewCopy( v_, vsize_, vsize_new );        file://如果程序执行到此, 内存分配和拷贝就成功了

            delete[] v_;    // 此处同样不会抛出异常        v_ = v_new;        vsize_ = vsize_new;    }

        v_[vused_] = t; // 如果拷贝操作抛出异常,    ++vused_;       // 因为vused_并没有增长,所以状态没有改变}

    //----- POP -------------------------------------------------------template<class T>T Stack<T>::Pop(){    T result;    if( vused_ > 0)    {        result = v_[vused_-1];  // 如果拷贝操作抛出异常,因为vused_并没有减小,        --vused_;               // 所以状态没有改变    }                               return result;}

    //// 注意: 读者Wil Evers第一个指出,//  “从问题中的Pop()的接口定义就可以知道,//其使用者的代码肯定不是异常安全的,//它首先产生一个副作用(从栈中弹出一个元素)[译者注:也就是已经改变了Stack对象的状态]//然后使得一个可能发生异常的地方(将返回值拷贝到调用者的目标对象)遗漏在

    //异常捕获代码之外。”//  //这反映了书写安全异常的代码很难不仅是因为它影响到实现,也//影响到接口自身的定义!有些接口,比如这个接口(译者:也就是问题中定义的Pop()接口),//无法做到完全的异常安全的实现。//// 改正这个问题的一个方法是重新定义这个函数的接口为// "void Stack<T>::Pop( T& result)".  通过这种方法,

    //我们可以在改变栈的状态之前知道拷贝元素到result的操作//已经成功。比如,下面就是更加异常安全的Pop()的另一个版本//template<class T>void Stack<T>::Pop( T& result ){    if( vused_ > 0)    {        result = v_[vused_-1];  // 如果拷贝操作抛出异常,因为vused_并没有减小,        --vused_;               // 所以状态没有改变    }                           }//// 另外一种解决方法就是让Pop返回void并且提供一个Front()的成员函数// 访问栈顶元素//

    //----- 辅助函数 -------------------------------------------// 当我们想拷贝存放T元素的内存缓冲(这个缓冲可能比原来缓冲要大),// 这个函数将负责分配新的缓冲,//并将原来的元素全部拷贝过来。如果遇到任何异常,这个函数释放所有的临时资源//并将异常传递出去,这样就不会有任何泄漏。//template<class T>T* NewCopy( const T* src, unsigned srcsize, unsigned destsize ){    destsize = max( srcsize, destsize ); // basic parm check    T* dest = new T[destsize];    //如果执行到此,内存分配和各个构造函数都已成功

        try    {        copy( src, src+srcsize, dest );    }    catch(...)    {        delete[] dest;        throw;  // rethrow the original exception    }    // 如果执行到此,拷贝成功完成

        return dest;}

    附加题      2.根据目前的标准草案,标准库中的容器是异常安全或异常中立的吗?    目前来讲这个问题的答案是不确定的。最近C++标准委员会内部有一些讨论,内容是标准库容器是应该提供弱异常安全(“容器总是可析构的”)还是强异常安全(“所有容器的操作都具有提交或回滚的语义”)。Dave Abrahams在通过邮件进行的讨论中指出,通常如果你实现了弱的异常安全保证,就同时达到了强异常安全。上面的几个操作的实现就是属于这种情况。

       3.容器是否应该是异常中立的?为什么?折衷办法是什么?

       对某些容器而言,如果要实现为异常中立(exception-neutral)的,有些操作就会产生不可避免的空间代价。异常中立本身是一件好事情,但是当实现强异常安全所需的空间和时间的代价远远大于只实现弱异常安全时,实现异常中立也许并不是非常实际的做法。一个通常的折衷办法就是在文档里说明T的哪些操作是不应该抛出异常的,然后在基于用户遵循这种默认条件的情况下确保异常中立。    4.容器是否应该使用异常声明?例如,我们是否应该声明"Stack::Stack()throw(bad_alloc);"?

       不应该使用异常声明,因为事先是不知道T的哪些操作会抛出异常,也不知道会抛出什么异常。

       注意有些容器的操作(比如Count())只是简单的返回一个数值,我们可以断定是不会抛出异常的。虽然对于这种操作我们可以声明为"throw()",但是有两个理由致使我们不那样做:首先,这样做限制了我们以后改变内部实现为一个可能抛出异常的实现;再者,无论异常是否发生,异常声明都会带来性能上的额外开销。对于频繁使用的操作,最好不要使用异常声明以免这种性能上的额外负担。

       挑战:      对于很多现行的编译器,使用"try"和"catch"经常给你的程序增加额外负担,在这种低层次的可重用的容器中最好避免使用。你是否能够实现所有Stack的成员函数使之既能够满足上述要求,又不使用"try"和"catch"?

       是的,因为我们仅仅需要捕获"...",一般来讲,类似下面的代码

         try { TryCode(); } catch(...) { CatchCode(parms); throw; }  可以写为        struct Janitor {        Janitor(Parms p) : pa(p) {}        ~Janitor() { if uncaught_exception() CatchCode(pa); }        Parms pa;    };

        {        Janitor j(parms); //无论TryCode成功执行还是抛出异常,j都会被析构        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];    //果执行到此,内存分配和各个构造函数都已成功

        Janitor j(dest);    copy( src, src+srcsize, dest );    // 如果程序执行到此,拷贝已经成功...否则,     // j 在栈回展(stack-unwinding)时被析构,其析构函数负责    // 回收dest以免内存泄漏

        return dest;}

       前面我已经说过,我已经跟几位做过实验性速度测试的人交谈过。当没有异常发生时,try/catch通常是比较快的,而且可望更快。然而,了解这种方法还是很重要的,因为它常常带给我们更加优美更加容易维护的代码,而且一些现行的编译器对于try/catch在发生异常和不发生异常时产生的代码都不是非常高效的。   

     


    最新回复(0)