[收藏]错误处理和异常处理,你用哪一个

ChinaUnix.net  CU-C/C++讨论区精华帖   

ChinaUnix.net > 论坛首页 > 精华首页 > C/C++精华区 > 正文
bline.gif
错误处理和异常处理,你用哪一个
http://www.chinaunix.net 作者:apollolegend  发表于:2003-08-19 13:28:13

张笑猛
2003年1月
1.简介
    异常是由语言提供的运行时刻错误处理的一种方式。提到错误处理,即使不提到异常,你大概也已经有了丰富的经验,但是为了可以清楚的看到异常的好处,我们还是不妨来回顾一下常用的以及不常用的错误处理方式。

1.1 常用的错误处理方式
返回值。我们常用函数的返回值来标志成功或者失败,甚至是失败的原因。但是这种做法最大的问题是如果调用者不主动检查返回值也是可以被编译器接受的,你也奈何不了他icon_smile.gif 这在C++中还导致另外一个问题,就是重载函数不能只有不同的返回值,而有相同的参数表,因为如果调用者不检查返回值,则编译器会不知道应该调用哪个重载函数。当然这个问题与本文无关,我们暂且放下。只要谨记返回值可能被忽略的情况即可。

全局状态标志。例如系统调用使用的errno。返回值不同的是,全局状态标志可以让函数的接口(返回值、参数表)被充分利用。函数在退出前应该设置这个全局变量的值为成功或者失败(包括原因),而与返回值一样,它隐含的要求调用者要在调用后检查这个标志,这种约束实在是同样软弱。全局变量还导致了另外一个问题,就是多线程不安全:如果多个线程同时为一个全局变量赋值,则调用者在检查这个标志的时候一定会非常迷惑。如果希望线程安全,可以参照errno的解决办法,它是线程安全的。

1.2 不常用的处理方式
setjmp()/longjmp()。可以认为它们是远程的goto语句。根据我的经验,它们好象确实不常被用到,也许是多少破坏了结构化编程风格的原因吧。在C++中,应该是更加的不要用它们,因为致命的弱点是longjmp()虽然会unwinding stack(这个词后面再说),但是不会调用栈中对象的析构函数--够致命吧。对于不同的编译器,可能可以通过加某个编译开关来解决这个问题,但太不通用了,会导致程序很难移植。

1.3 异常
现在我们再来看看异常能解决什么问题。对于返回值和errno遇到的尴尬,对异常来说基本上不存在,如果你不捕获(catch)程序中抛出的异常,默认行为是导致abort()被调用,程序被终止(core dump)。因此你的函数如果抛出了异常,这个函数的调用者或者调用者的调用者,也就是在当前的call stack上,一定要有一个地方捕获这个异常。而对于setjmp()/longjmp()带来的栈上对象不被析构的问题对异常来说也是不存在的。那么它是否破坏了结构化(对于OO paradigms,也许应该说是破坏了流程?)呢?显然不是,有了异常之后你可以放心的只书写正确的逻辑,而将所有的错误处理归结到一个地方,这不是更好么?

综上所述,在C++中大概异常可以全面替代其它的错误处理方式了,可是如果代码中到处充斥着try/throw/catch也不是件好事,欲知异常的使用技巧,请保持耐心继续阅读icon_smile.gif

发表回复】【查看CU论坛原帖】【关闭

 apollolegend 回复于:2003-08-19 13:29:04
2. 异常的语法
在这里我们只讨论一些语法相关的问题。

2.1 try
try总是与catch一同出现,伴随一个try语句,至少应该有一个catch()语句。try随后的block是可能抛出异常的地方。

2.2 catch
catch带有一个参数,参数类型以及参数名字都由程序指定,名字可以忽略,如果在catch随后的block中并不打算引用这个异常对象的话。参数类型可以是build-in type,例如int, long, char等,也可以是一个对象,一个对象指针或者引用。如果希望捕获任意类型的异常,可以使用“...”作为catch的参数。

catch不一定要全部捕获try block中抛出的异常,剩下没有捕获的可以交给上一级函数处理。

2.3 throw
throw后面带一个类型的实例,它和catch的关系就象是函数调用,catch指定形参,throw给出实参。编译器按照catch出现的顺序以及catch指定的参数类型确定一个异常应该由哪个catch来处理。

throw不一定非要出现在try随后的block中,它可以出现在任何需要的地方,只要最终有catch可以捕获它即可。即使在catch随后的block中,仍然可以继续throw。这时候有两种情况,一是throw一个新类型的异常,这与普通的throw一样。二是要rethrow当前这个异常,在这种情况下,throw不带参数即可表达。例如:

try{
    ...
}
catch(int){
    throw MyException("hello exception");    // 抛出一个新的异常
}
catch(float){
    throw;                   // 重新抛出当前的浮点数异常
}

2.4 函数声明
还有一个地方与throw关键字有关,就是函数声明。例如:

void foo() throw (int);      // 只能抛出int型异常
void bar() throw ();         // 不抛出任何异常
void baz();                  // 可以抛出任意类型的异常或者不抛出异常

如果一个函数的声明中带有throw限定符,则在函数体中也必须同样出现:

void foo() throw (int)
{
    ...
}

这里有一个问题,非常隐蔽,就是即使你象上面一样编写了foo()函数,指定它只能抛出int异常,而实际上它还是可能抛出其他类型的异常而不被编译器发现:

void foo() throw (int)
{
    throw float;     // 错误!异常类型错误!会被编译器指出
    ...
    baz();           // 正确!baz()可能抛出非int异常而编译器又不能发现!
}

void baz()
{
    throw float;
}

这种情况的直接后果就是如果baz()抛出了异常,而调用foo()的代码又严格遵守foo()的声明来编写,那么程序将abort()。这曾经让我很恼火,认为这种机制形同虚设,但是还是有些解决的办法,请参照“使用技巧”中相关的问题。


 apollolegend 回复于:2003-08-19 13:32:05
3. 异常使用技巧
 
3.1 异常是如何工作的
为了可以有把握的使用异常,我们先来看看异常处理是如何工作的。

3.1.1 unwinding stack
我们知道,每次函数调用发生的时候,都会执行保护现场寄存器、参数压栈、为被调用的函数创建堆栈这几个对堆栈的操作,它们都使堆栈增长。每次函数返回则是恢复现场,使堆栈减小。我们把函数返回过程中恢复现场的过程称为unwinding stack。

异常处理中的throw语句产生的效果与函数返回相同,它也引发unwinding stack。如果catch不是在throw的直接上层函数中,那么这个unwinding的过程会一直持续,直到找到合适的catch。如果没有合适的catch,则最后std::unexpected()函数被调用,说明发现了一个没想到的异常,这个函数会调用std::terminate(),这个terminate()调用abort(),程序终止(core dump)。

在“简介”中提到的longjmp()也同样会unwinding stack,但是这是一个C函数,它就象free()不会调用对象的析构函数一样,它也不知道在unwinding stack的过程中调用栈上对象的析构函数。这是它与异常的主要区别。

3.1.2 RTTI
在unwinding stack的过程中,程序会一直试图找到一个“合适”的catch来处理这个异常。前面我们提到throw和catch的关系很象是函数调用和函数原型的关系,多个catch就好象一个函数被重载为可以接受不同的类型。根据这样的猜测,好象找到合适的catch来处理异常与函数重载的过程中找到合适的函数原型是一样的,没有什么大不了的。但实际情况却很困难,因为重载的调用在编译时刻就可以确定,而异常的抛出却不能,考虑下面的代码:

void foo() throw (int)
{
    throw int;
}

void bar()
{
    try{
        foo();
    }
    catch(int){
        ...
    }
    catch(float){
        ...
    }
}

void baz()
{
    try{
        foo();
    }
    catch(int){
        ...
    }
    catch(float){
        ...
    }
}

foo()在两个地方被调用,这两次异常被不同的catch捕获,所以在为throw产生代码的时候,无法明确的指出要由哪个catch捕获,也就是说,无法在编译时刻确定。

仍然考虑这个例子,让我们来看看既然不能在编译时刻确定throw的去向,那么在运行时刻如何确定。在bar()中,一列catch就象switch语句中的case一样排列,实际上是一系列的判断过程,依次检查当前异常的类型是否满足catch指定的类型,这种动态的,在运行时刻确定类型的技术就是RTTI(Runtime Type Identification/Information)。深度探索C++对象模型[1]中提到,RTTI就是异常处理的副产品。关于RTTI又是一个话题,在这里就不详细讨论了。

3.2 是否继承std::exception?
是的。而且std::exception已经有了一些派生类,如果需要可以直接使用它们,不需要再重复定义了。

3.3 每个函数后面都要写throw()?
尽管前面已经分析了这样做也有漏洞,但是它仍然是一个好习惯,可以让调用者从头文件得到非常明确的信息,而不用翻那些可能与代码不同步的文档。如果你提供一个库,那么在库的入口函数中应该使用catch(...)来捕获所有异常,在catch(...)中捕获的异常应该被转换(rethrow)为throw列表中的某一个异常,这样就可以保证不会产生意外的异常。

3.4 guard模式
异常处理在unwinding stack的时候,会析构所有栈上的对象,但是却不会自动删除堆上的对象,甚至你的代码中虽然写了delete语句,但是却被throw跳过,导致内存泄露,或者其它资源的泄露。例如:

void foo()
{
    ...
    MyClass * p = new MyClass();
    bar(p);
    ...
    delete p;       // 如果bar()中抛出异常,则不会运行到这里!
}

void bar(MyClass * p)
{
    throw MyException();
}

对于这种情况,C++提供了std::auto_ptr这个模板来解决问题。这个常被称为“智能指针”的模板原理就是,将原来代码中的指针用一个栈上的模板实例保护起来,当发生异常unwinding stack的时候,这个模板实例会被析构,而在它的析构函数中,指针将被delete,例如:

void foo()
{
    ...
    std::auto_ptr<MyClass> p(new MyClass());
    bar(p.get());
    ...
    // delete p;       // 这句不再需要了
}

void bar(MyClass * p)
{
    throw MyException();
}

不论bar()是否抛出异常,只要p被析构,内存就会被释放。

不光对于内存,对于其他资源的管理也可以参照这个方法来完成。在ACE[2]中,这种方式被称为Guard,用来对锁进行保护。

3.5 构造函数和析构函数
构造函数没有返回值,很多地方都推荐通过抛出异常来通知调用者构造失败。这是肯定是个好的办法,但是也不很完美。主要是因为在构造函数中抛出异常并不会引发析构函数的调用,例如:

class foo
{
public:
    ~foo() {} // 这个函数将被调用
};

class bar
{
public:
    bar() { c_ = new char[10]; throw -1;}
    ~bar() { delete c_;}  // 这个函数不会被调用!
private:
    char * c_;
    foo f_;
};

void baz()
{
    try{
        bar b;
    }
    catch(int){
    }
}

在这个例子中,bar的析构函数不会被调用,但是尽管如此,foo的析构函数还是可以被调用。危险的是在构造函数中分配空间的c_,因为析构函数没有被调用而变成了leak。最好的解决办法还是auto_ptr,使用auto_ptr后,bar类的声明变成:

class bar
{
public:
    bar() { c_.reset(new char[10]); throw -1;}
    ~bar() { }  // 不需要再delete c_了!
private:
    auto_ptr<char> c_;
    foo f_;
};

析构函数中则不要抛出异常,这一点在Thinking In C++ Volume 2[3]中有明确表述。如果析构函数中调用了可能抛出异常的函数,则应该在析构函数内部catch它。

3.6 什么时候使用异常
到现在为止,我们已经讨论完了异常的大部分问题,可以实际操作操作了。实际应用中遇到的最让我头疼的问题就是什么时候应该使用异常,是否应该用异常全面代替“简介”中提到的其它错误处理方式呢?

首先,不能用异常完全代替返回值,因为返回值的含义不一定只是成功或失败,有时候是一个可选择的状态,例如:

if(customer->status() == active){
    ...
}
else{
    ...
}

在这种情况下,不论返回值是什么,都是程序可以接受的正常的结果。而异常只能用来表达“异常”-- 也就是错误的状态。这好象是显而易见的事情,但是实际编程的过程中有很多更加模棱两可的时候,遇到这样的情况,首先要考虑的就是这个原则。

第二,看看在特定的情况下异常是否会发挥它的优点,而这个优点正好又不能使用其他技术达到(或者简单的达到)。比如,如果你正在为电信公司写一个复杂计费逻辑,那么你当然希望在整个计算费用的过程中集中精力去考虑业务逻辑方面的问题,而不是到处需要根据当前返回值判断是否释放前面步骤中申请的资源。这时候使用异常可以让你的代码非常清晰,即使你有100处申请资源的地方,只要一个地方集中释放他们就好了。例如:

bool bar1();
bool bar2();
bool bar3();

bool foo()
{
    ...
    char * p1 = new char[10];
    ...
    if(!bar1()){
        delete p1;
        return false;
    }
    ...
    char * p2 = new char[10];
    ...
    if(!bar2()){
        delete p1;             // 要释放前面申请的所有资源
        delete p2;
        return false;
    }
    ...
    char * p3 = new char[10];
    ...
    if(!bar2()){
        delete p1;             // 要释放前面申请的所有资源
        delete p2;
        delete p3;
        return false;
    }
}

这种流程显然不如:

void bar1() throw(int);
void bar2() throw(int);
void bar3() throw(int);

void foo() throw (int)
{
    char * p1 = NULL;
    char * p2 = NULL;
    char * p3 = NULL;
    try{
        char * p1 = new char[10];
        bar1();
        char * p2 = new char[10];
        bar2();
        char * p3 = new char[10];
        bar3();
    }
    catch(int){
        delete p1;        // 集中释放资源
        delete p2;
        delete p3;
        throw;
    }
}

第三,在Thinking In C++ Volume 2[3]中列了一个什么时候不应该用,什么时候应该用的表,大家可以参考一下。

最后,说一个与异常无关的东西,但也跟程序错误有关的,就是断言(assert),我在开发中使用了异常后,很快发现有的人将应该使用assert处理的错误定义成了异常。这里稍微提醒一下assert的用法,非常简单的原则:只有对于那些可以通过改进程序纠正的错误,才可以用assert。返回值、异常显然与其不在一个层面上,这是C的入门知识。


 linzi_tj 回复于:2003-08-20 17:56:28
很好的文章。
我也迷惑异常与返回值的使用区分。
异常:程序中可能出现的错误。有不确定性,如分配内存失败,磁盘空间不足。异常很大程度上依赖于运行时的环境。
返回值:可以预计发生的情形。


 threehair 回复于:2003-08-20 18:26:52
好!


 蓝色键盘 回复于:2003-08-23 19:00:53
程序的重要的一个部分是处理可能各种错误和异常

bline.gif

Copyright © ChinaUnix.net
 *  请尊重我们的劳动,转载请注明出自ChinaUnix.net及作者名  * 

没有更多推荐了,返回首页