C++华丽的exception handling(异常处理)背后隐藏的阴暗面及其处理方法

来源:岁月联盟 编辑:exp 时间:2012-02-02
前言
最近在看auto_ptr源码的时候,发现里面的异常说明很多。事实上对于exception handling这块,以前也有很多困惑的地方,只是由于平时代码中很少用到,于是就从来没仔细钻研过。本来这篇是用来写smart pointer的,既然遇到了exception handling这块,那么先把这块硬骨头啃下来再说吧。
翻阅了很多大师的经典著作,发现exception handling在《c++ primer》中只是概念性的提了下,对于技巧型的内容几乎没有涉及到;《effective c++》中只有一个条款中提及,《inside c++ object model》也提到很少,幸运的是《more effective c++》中却有一个专题来研讨这块,而且讲到很多技巧性的内容,令人脍炙人口。。 虽然新公司里很少用到exception handling,所有的状态信息都是以日志文件形式来记录,而谁也不能保证以后的工作中不会用到,对于一般性的异常处理,自认为还是可以应付的来的,至少不会导致因为没有处理的exception而teminate了当前程序,而如果要写出高质量高稳定性的C++代码,不掌握exception handling的技巧性使用应该是很难的(至少我是这么认为的),当然了,C阵营中错误代码或返回状态信息是另一类exception技能了。而正如Scott Meyers在《effective c++》的条款一所说:视C++为一个语言联邦;因而不该因为C++是由C发展而来而全盘忽视了C++的特性。。。
 
VS编译器对异常规范的忽视
异常规范在《C++ Primer》中倒是提及的多一些。这一块也是exception handling中最令人头大的一块,因为尽管编译器能检测出少量异常规范,对于大多数的异常规范,只有在运行期才能知晓,如果违反了异常规范,诸多大师的一致解释是:程序会自动调用标准库的unexpacted函数,此函数又调用teminate函数,从而直接终止程序的运行。来看看如下代码:
1.    #include <iostream>
2.   
3.    using namespace std;
4.   
5.   
6.    typedef void (*CallBackPtr)(int nEventLocationX,int nEventLocationY,void *pDataToPassValue)throw();
7.   
8.    class CallBack
9.    {
10.   public:
11.       CallBack(CallBackPtr fPtr,void *pDataToPassValue):func(fPtr),pData(pDataToPassValue)
12.       {}
13.       void MakeCallBack(int nEventLocationX,int nEventLocationY) const throw()
14.       {
15.           func(nEventLocationX,nEventLocationY,pData);
16.       };
17.  
18.   private:
19.       CallBackPtr func;
20.       void *pData;
21.   };
22.  
23.   void MyFunc(int nEventLocationX,int nEventLocationY,void *pDataToPassValue) throw(runtime_error)
24.   {
25.       cout<<nEventLocationX<<" "<<nEventLocationY<<endl;
26.       throw runtime_error("runtime error example!");
27.   }
28.  
29.   int main(int *argc , char **argv)
30.   {
31.       CallBackPtr Func = MyFunc;
32.       
33.       CallBack MyCallBack(Func,NULL);
34.       MyCallBack.MakeCallBack(10,10);
35.       return 0;
36.   }
 
这是一个回调函数管理类的例子,是《more effective c++》的原例,如果按照Lippman在《C++ Primer》中所说的话,此程序应该不能通过编译,因为它存在一个编译期就能检测出来的违反异常规范的地方:对于函数MyFunc和函数定义CallBackPtr异常声明,MyFunc的规范更为严格,它应该不能转化为CallBackPtr的对象才是,因为CallBackPtr的异常规范为throw(),意味着此函数不会抛出任何异常。。而我在VS2008下却能正常编译通过,只有一个warning:C++ exception specification ignored except to indicate a function is not __declspec(nothrow),MSDN中对其解释是:“使用异常规范声明函数,Visual C++ 接受但并不实现此规范。包含在编译期间被忽略的异常规范的代码可能需要重新编译和链接,以便在支持异常规范的未来版本中重用。”意即VC编译器不支持异常规格说明。。 令我牵肠挂肚的是:倘若一直如此的话,那么C++的异常规范特性得全部由程序员来掌控,感叹VC编译器对于这点多少有些不人道。。 但我也不想因此而以点盖面的全盘否定VC编译器在其它方面的高性能。
使用smart pointer来防止destructor中的资源泄露
对于由于异常处理不当而引发的资源泄露,无疑亦是程序员喜欢讨论的话题之一,因为抛出异常意味着一个抛异常的代码块可能只执行了一部分(前提是当前函数没有处理异常),这样的话,那么异常又会传送到当前代码块的外围去处理,而引发资源泄露的代码块往往却是这块没被执行的代码,看看如下例子:
1.    #include <iostream>
2.    using namespace std;
3.   
4.    class BaseClass
5.    {
6.    public:
7.        BaseClass(){};
8.        ~BaseClass(){};
9.    };
10.  
11.   void ExceptionFunc() throw(runtime_error)
12.   {
13.       throw runtime_error("example exception handling!");
14.   }
15.  
16.   void Function() throw(runtime_error)
17.   {
18.       BaseClass *pBase = new BaseClass;
19.  
20.       ExceptionFunc();
21.       
22.       delete pBase;
23.   }
24.  
25.   int main(int *argc , char **argv)
26.   {
27.       try
28.       {
29.           Function();
30.       }
31.       catch(runtime_error &err)
32.       {
33.           cout<<err.what()<<endl;
34.       }
35.       return 0;
36.   }
 
不得不承认在main函数返回之前,所有的异常确实得到了处理,让人难以忽视的是:Function里面抛出异常后,delete pBase没有执行,这就意味着发生了内存泄露。。指针无处不在,如果我们不想用指针而想提高代码的效率和质量,那几乎是不可能的。事实上,我们可以在Function函数里捕捉异常,然后在异常处理块中执行delete pBase,可以避免由此引发的内存泄露,然而这样做的缺陷是要写两个delete,Scott Meyers对于这种引发内存的更好的处理方式是:使用smart pointer。 如果将Function改为如下:
1.    void Function() throw(runtime_error)
2.    {
3.        BaseClass *pBase = new BaseClass;
4.        auto_ptr<BaseClass> PtrBase(pBase);
5.        ExceptionFunc();
6.    }
 
用类来管理资源是防止资源泄露的有力法器之一,这种情况下异常抛出后,auto_ptr对象肯定会执行析构函数,此时会自动释放其指针成员指向的对象资源,即便它的对象为NULL,由于C++保证了delete空指针无异常的特性,所以资源是肯定会正确的释放。然而在我看来,smart pointer的使用也只是一种折中而已,因为使用auto_ptr而带来的负面性后果其实也可以大作讨论了,有待我之后的smart pointer文章再作详细讨论。 
异常逃离destructor的灾难性后果
这一点也是唯一一条Sotte Meyyers在《effective c++》(条款8)和《more effective c++》(第五章节)中重复讨论了两次的条款,当destructor中无法处理异常的话,程序会直接调用teminate从而终止。。如果试图在destructor外部捕获异常,那将是徒劳的,正如一般重载delete运算符的声明式一样,往往在后面又加个异常规范throw(),这意味着delete外部根本无法捕捉到其内部的异常。看看下面这个简单例子:
1.    #include <memory>
2.    #include <iostream>
3.    using namespace std;
4.    class BaseClass
5.    {
6.    public:
7.       BaseClass(){};
8.        ~BaseClass()
9.        {
10.           throw runtime_error("example runtime error.");
11.       };
12.   };
13.  
14.   int main(int *argc , char **argv)
15.   {
16.       BaseClass *pBase = new BaseClass;
17.       delete pBase;
18.       return 0;
19.   }
 
在VS2008下调用teminate时候还会调用abort,这个程序会非正常结束,如果在main函数中试图这样做:
1.    int main(int *argc , char **argv)
2.    {
3.        BaseClass *pBase = new BaseClass;
4.        try
5.        {
6.            delete pBase;
7.        }
8.        catch(runtime_error &err)
9.        {
10.           cout<<err.what()<<endl;
11.       }
12.       return 0;
13.   }
 
结果会跟上面一样(非正常结束),因为delete是不会将任何异常传递到其外面的;一种比较折中的解决方法是,当destructor中存在异常抛出时,在destructor最后添加一个能捕获所有异常的catch处理块,catch处理块又什么工作都不做,如下:
 
1.    ~BaseClass()
2.    {
3.        try
4.        {
5.            throw runtime_error("error in destructor");
6.        }
7.        catch(...)
8.        {
9.        }
10.   };
看起来是一种很坏很无奈的办法,但正如Scott Meyers在《effective c++》中所说:
“一般而言,将异常吞掉是个坏主意,因为它压制了"某些动作失败"的重要信息!然而有时候吞下异常也比负担"草率结束程序"或"不明确行为带来的风险好”。
 
 
后记
对于很多exception handling的概念性细节(比如何时使用引用类型的异常捕捉、异常捕获层次的类型转换等等)我没做任何阐述,可以去看看《C++ PRIMER》的第十七章,有着很想尽的讲解。。。 对于MS编译器对异常规范的不支持,我很难理解,因为G++编译器确实是支持的。之前在讨论C++的object布局时(点击这里)也曾感叹MS的编译器在优化方面没G++走得快,对于这些,或许是我运气不好,老是碰到MS不如G++的地方,也或许是我现在几乎不用G++编译器的而体会不到其不如MS编译器的地方的缘故吧。。。exception handling的确能为提高代码质量的改善作出或多说少的贡献,但华丽丽的外表下,因为用不好它而导致的程序的很多不明确(如teminate当前程序)和不正常(如资源泄露)行为也是令人比较头大的地方。貌似只有多熟用有技巧性的用是唯一能解决所有问题的方法了。。
 
 
本文出自 “酋长” 博客