异常
基本知识
程序做错误检查是必要的,通常我们可以通过返回值告诉客户有了错误,不过异常提供了更加方便的手段和丰富的信息。
当某处程序发现了错误,可以选择自己处理或者交给外部调用者处理,比如:
void Func(char* p)
{
if(p==NULL)
{
throw std::invalid_argument(“p is NULL”);
}
}
而调用者可以选择拦截该异常对象或者放过,交由更外层的逻辑处理。
try
{
Func(NULL);
}
catch(std::invalid_argument const& e)
{
cout<<e.what()<<endl;
throw;
}
这个例子中,调用者将异常对象拦截,显示出错误信息,然后使用throw继续抛出该异常对象,当要继续抛出该异常的时候,直接使用“throw”就可以,不用在throw后面增加异常对象了,因为这样会导致再一次复制异常对象,产生成本。像接力棒一样。如果不写throw语句,异常到此就终止了。
在一个情况复杂的大型系统中,异常对象的灵活处理方式提供了很大的方便,同时异常本身就是一个对象,对象能够提供的丰富信息是旧式的返回错误值不具备的。
异常的传播方式和调用链相反,这称之为栈展开。在异常发生的地方,编译器必须完成以下的事情:
如果throw发生在try区块中,寻找匹配的cath语句,如果找不到,则当前函数立刻返回,并往上一级调用函数中寻找匹配的catch语句,该动作将一直重复直到有合适的catch语句出现。
如果一直到最后都没有发现合适的catch语句,系统将调用terminate,而默认情况下,terminate将调用abort结束整个进程。
异常当然也可以是预定义类型,下面的代码仍然正确:
void f()
{
throw 3;
}
int _tmain(int argc, _TCHAR* argv[])
{
try
{
f();
}
catch(int e)
{
cout<<e<<endl;
}
return 0;
}
当我们在一个函数中throw 一个对象的时候,如果该对象是创建在该函数中的栈上,编译器会将该对象拷贝一份副本,放到某个特定的区域,保证当外部程序使用catch语句时可以访问到该副本,所以这种情况下异常对象的类型必须提供拷贝构造函数或者是预定义类型。下列代码故意禁止了A类的拷贝构造函数,因此程序无法正确链接到拷贝构造函数。
class E
{
public:
E(){}
void print() const
{
cout<<_i<<endl;
}
private:
int _i;
private:
E(E const& e);
};
void f()
{
E e;
throw e;
}
int _tmain(int argc, _TCHAR* argv[])
{
try
{
f();
}
catch(E const& e)
{
e.print();
}
return 0;
}
error LNK2001: unresolved external symbol “private: __thiscall E::E(class E const &)” (??0E@@AAE@ABV0@@Z)
我们必须意识到,通过throw语句抛出异常对象的时候,拷贝一次对象是必须付出的成本。除非我们抛出的是一个对象指针。但是这种方式通常并不推荐。请看下面的代码:
class E
{
public:
E(){}
void print() const
{
cout<<_i<<endl;
}
private:
int _i;
private:
E(E const& e);
};
void f()
{
throw new E();
}
int _tmain(int argc, _TCHAR* argv[])
{
try
{
f();
}
catch(E * p)
{
p->print();
//.....
delete p;
}
return 0;
}
如果delete p语句执行之前,出现了新的异常,该语句将没有机会被执行,内存泄露。作为弥补的办法,我们修改catch中的代码。
catch(E * p)
{
auto_ptr<E> sp(p);
sp->print();
//.....
}
但是或许我们的异常是被别的程序员拦截的,假如他们没有意识到这个问题的严重性,内存泄露就有可能发生。
在较安全使用和效率之间折衷的做法,参见下面的代码:
void f()
{
throw E();
}
int _tmain(int argc, _TCHAR* argv[])
{
try
{
f();
}
catch(E const& e)
{
e.print();
}
return 0;
}
我们使用了 throw E()代替 E e();throw e;两句话,编译器通常当看到没有具体名字的临时变量会采取一些优化措施。同时,我们在catch语句中参数类型使用的是E const& ,这样会避免在将异常对象传递给chtch块时的一次无谓的对象拷贝。
由于异常是非常严重的错误,所以我们假定它发生的频率不高,那么一次对象拷贝通产不会造成太大的影响。要避免的是滥用异常,如查找字典程序不恰当的用抛出异常来表达找不到单词,这将对性能造成毁灭性的影响。
异常的成本
我们前面提到过,如果你使用throw语句抛出一个异常对象,无论你抛出的是局部变量还是全局或者静态变量,都会造成编译器拷贝一次副本,然后使用该副本在异常传播链中传递。所以在catch语句中应用“const T&”参数是可以保证不进行静态的拷贝。除非你用异常对象的指针传递,通常这种做法不推荐。
如果你使用catch语句捕捉异常对象,请注意总是捕捉异常对象的引用,如果捕捉异常对象的话,会导致再一次的拷贝。
当你在catch块中试图继续传递异常对象的时候,请使用throw,而不是throw obj,后者回导致再一次的拷贝。
Scott Meyers在他的<
class E
{
public:
~E()
{
throw string("ok");
}
};
int _tmain(int argc, _TCHAR* argv[])
{
try
{
E e;
throw string("bad");
}
catch(...)
{
}
int x;
cin>>x;
return 0;
}
E类的析构函数中有一个抛出异常的语句,当_tmain函数的try块中执行到throw string(“bad”)时,try块所在的栈开始销毁,这时E的析构函数将调用,当执行到throw string(“ok”)语句时,C++编译器将直接调用terminate函数。真是非常的糟糕!
阻止析构函数中抛出异常
但是也许是析构函数的某个内在操作抛出了异常,怎么预防呢?比如:
class DBConn
{
public:
static DBConn create();//connect to db
~DBConn();
}
在DBConn析构的时候自动关闭与数据库的连接这种设计总是吸引着我们。但是,如果你使用的是某个数据库驱动程序的API Disconnect(),该函数的文档中说道,当执行错误时会抛出异常,怎么办?
使用try/catch语句在析构函数中处理可能发生的异常时必要的,但是如果抓到了异常如何处理呢?写日志,然后终止异常的传播?写日志,然后继续throw?写日志,然后终止程序?第二种肯定不行,我们就是要阻止异常从析构函数中传递。第三种有时候可以,因为可能没有其他办法继续下去。第一种做法看上去不错,但是从调用者的角度,除非他尝试着查看日志,否则他不知道发生了什么事情?用全局变量来告诉调用者错误信息?怎么看上去不够面向对象呢?
Scott Meyers提出了一个算比较好的解决方案:
增加一个close函数和bool closed变量。
class DBConn
{
public:
static DBConn create();//connect to db
~DBConn()
{
if(!closed)
{
try
{
close();
}
catch(...)
{
//记录错误日志,调用abort或者什么都不作(异常到此停止传播)
}
}
}
close();
private:
bool closed;
}
close函数负责关闭连接,析构函数中判断如果客户没有调用close函数,就调用close函数关闭连接。
析构函数中的catch块中的做法只是最后一道防线,应该总是鼓励客户显式调用close函数,这样当异常发生在close函数中时,客户可以自己决定如何处理。
构造函数中使用异常的注意事项
为什么有人想在构造函数中抛出异常?或许是因为他的构造函数认为某个输入参数不正确,不能构造对象,因此抛出异常强制客户处理错误信息。这样的人信奉错误应该在它发生的地方尽早被发现,而不是拖到当用户调用某个成员函数的时候。我就是。
但有时候并不是故意抛出异常,而是构造函数本身内部执行了复杂的初始化逻辑,突然某个函数(也许是别人提供的)抛出了异常,这个时候要注意前面已经被初始化的对象能够正确释放内存。
如果是在构造函数的初始化列表中发生了异常,怎么办呢?下面介绍一种用法:
class E
{
public:
E()
{
throw string("ok");
}
};
class B
{
public:
B();
private:
char* _p;
E _e;
};
inline B::B()
try:_p(new char[100]),_e()
{
}
catch(const string& e)
{
delete _p;
}
int _tmain(int argc, _TCHAR* argv[])
{
try
{
B b;
}
catch(string const& str)
{
cout<<str<<endl;
}
int x;
cin>>x;
return 0;
}
这是比较少见的用法,try在初始化列表的前面,并且在函数体后面加上catch块。本例目的是回收_p指向的自由存储的100字节空间。请回答,如果不用考虑演示try用法的需要,有没有更优雅的做法?
优先使用异常报告错误
这是C++标准委员会对于C++异常的基本态度。因为异常会强制用户捕捉,而不像返回值可以不检查;因为异常是自动传播的(COM中除外);有了异常处理,就不必在控制流的主线中加入错误处理和恢复代码,使得主线逻辑更易理解和维护,当然也美观;构造函数和操作符而言,异常是优先考虑的方案。特别是构造函数,它根本没有返回类型。
性能通常不是异常处理的缺点,在不发生错误的情况下,不会带来多大的开销。问题是你必须正确识别什么是错误情况,从而避免不必要的频繁抛出异常的情况,而不是杜绝使用异常。
这实际上牵涉到错误处理的设计问题。是的,在设计阶段考虑错误是必须的,遗憾的是很多系统设计在这方面都相当草率。
错误设计要考虑:
哪些情况属于错误—定义错误的范围
观察代码最基本的执行单元—函数,错误大体可分为三类:
a)违反或者无法满足前条件
比如参数正确性(参数越界,指针是否为空)或某个状态约束
b)无法满足后条件
比如函数想返回一个值,但是却不能生成一个有效的返回值
c)无法重新建立不变式
对象成员的值和由成员所引用的对象的值汇集在一起就称为这个对象的状态。使一个对象的状态定义良好的性质就被称为它的不定式。
举个例子:
比如MyString类里有个char* _p成员变量,当构造函数执行成功后,_p指向一个长度`为_len的数组,并且_p[_len]==0。MyString的每个成员函数都必须保证重建这个不变式,直到最后销毁该对象。
错误的严重级别和分类
模块边界错误如何传递
这里不是指C++异常传递,这是C++代码都应该使用的内部机制。问题是模块边界如何传递错误,比如网络通信程序,比如com+程序等等。
哪些代码负责处理错误
错误如何处理?写日志或通知用户还是短信通知等等
关于参数检查,我个人意见是,如果是指针参数,首先看看能不能改为引用,这样就不用检查指针是否为空。如果必须是指针作为参数,则面临两个选择,以断言来预防错误assert(p!=NULL)或者直接进行条件判断if(p!=NULL) throw invalid_argument(“”)。究竟该选哪一个呢?
assert用来防止程序员的错误,比如将一个空指针传入函数,而不是防止逻辑错误。问题是函数的设计者怎么知道调用者会不会犯错呢?其实,关于究竟用哪一个,目前也没有看到统一的方法。标准C++中的string采用了不管,即两个方法都不用,对构造函数和operator+接收到的char const*指针根本就不检查,boost::shared_ptr使用if语句,发现错误就抛出异常。
考虑到boost是目前c++世界里代码最优雅的模范,尽可能使用条件判断的方式。只有一种情况下例外,如果一个指针依次传递给多个函数,如果这个指针大多数情况下总是有效,但每个函数都作了条件判断的检查,性能开销是很大的,这时候,内部使用的函数就应该改用assert了。
标准异常
标准C++中为我们准备了异常的体系结构,当我们创建自己的异常对象时,应该总是先考虑一下标准异常类。
最顶层基类是exception,提供了what()虚函数用来描述错误:
virtual const* char* what() const throw()。
通常如果你想要编写自己的异常类,比如MyException,你应该从exception的子类中选择一个派生。
异常规格
异常规范是C++标准委员会那些大师们的失败的作品。我们现在可以看到标准c++库中采用了异常规范,但是最新的boost库已经使用//throw ()代替throw。
以下几点原因:
异常规格导致性能降低
有些编译器会自动拒绝将有异常规格的函数进行内联;有些编译器根本不能很好的基于异常相关的信息进行优化,对于这些编译器,即使函数体内的代码清清楚楚的表明不可能抛出任何异常,它们还会在里面加上try/catch块
当违反了异常规格,无计可施
如果一个函数void Func() throw (A,B,C)的异常规格告诉你它只能抛出A、B、C三种类型的异常,万一在Func内部抛出了一个D类型的异常,编译器将通过生成try/catch块来处理这种情况:
void Func()throw (A,B,C)
{
try
{
....
}
catch(A)
{
throw;
}
catch(B)
{
throw;
}
catch(C)
{
throw;
}
catch(...)
{
std::unexcepted();
}
}
你可以通过调用std::set_unexpected来设置自己的处理函数,unexcepted函数将会调用你的函数,问题是这些函数没有任何参数,一个全局无参的函数怎么处理各种情况,也许有好多违反了异常规格声明的函数在等着依赖这个函数解决问题呢?答案是无计可施,通常只能调用terminate了事。
浅陋的类型系统
异常规格可以加到函数上,并且成为函数的类型的一部分,但是如果你试图在typedef语句中使用异常规格,是不允许的。
void f() throw (A,B);//ok
typedef void (*PF)() throw (A,B);//语法错误
void (*PF)() throw(A,B);//去掉typedef就可以
这个行为和C++类型系统的其他部分不一致。
异常安全
异常安全现在来看是个新鲜的高级主题,但是今后将成为必备的基本技术。我们先看看异常安全的定义:
当异常抛出时,带有异常安全性的函数将不泄漏任何资源,不允许数据败坏(如果是成员函数,通常意味着对象的状态不能被破坏)。
异常安全程度由高到低有三种:
承诺不抛出任何异常
保证即使抛出异常,程序状态回到执行函数前(事务操作)
保证即使抛出异常,程序状态仍然处于有效状态
在前面我们介绍过,防止泄露资源的做法是使用auto_ptr等智能指针或者自己封装一些类。要实现第二种程度的异常安全有一种做法叫作copy and swap.就是在成员函数的实现中使用先将对象拷贝一份,然后在副本上操作,当操作完成后,和当前对象交换。这种做法需要结合pimpl技术。
异常安全是个重大的话题,以至于引发了1997年互联网上C++世界长达几个星期的讨论。