ANSI/ISO C++ Professional Programmer's Handbook(6)

来源:互联网 发布:心事谁人知 陈小春 编辑:程序博客网 时间:2024/05/19 04:28
  摘自:http://sttony.blogspot.com/search/label/C%2B%2B

6


异常处理


by Danny Kalev





简介


大的软件工程一般有层次体系。在最底层,一般是程序库、API函数和所有的底层函数。在最高层是用户接口组件,例如使用户能在电子表格中填充数据的组件。考虑一个普通的飞机定票程序:它的最顶层由在用户屏幕上显示内容的GUI组件组成。这些组件与数据存取对象相结合,后者一般封装在数据库API中。而数据库API与数据库引擎相结合。数据库引擎则调用系统服务来处理底层硬件资源,如物理内存、文件系统、安全机制。一般来说,严重的运行期错误在更底层的代码中被检查出来,底层代码不可能也一定不能试图在自己的层次处理错误。处理错误是上一层组件的责任。但是为了处理错误,上一层必须知道错误是怎么产生的。本质上说,错误处理由发现错误和通知负责处理错误的软件组件组成。这些组件依次处理错误并试图消除错误的影响。


传统的错误处理方法


在早期,C++没有内建的处理运行期错误的工具。传统的C方法用来处理运行期错误。这些方法归结为三种方针:




  • 返回一个状态代码来表示成功或失败。代码的含义是先前约定的。





  • 将错误码赋值给全局变量,其他函数去检查这个全局变量。





  • 完全的停止程序运行。





在面向对象环境中每一种方法都有明显得缺点和局限。有些可能是完全不能接受的,特别是在大型应用中。下面的章节仔细的检查了每一个方法来评价他们固有的局限和危险性。


返回错误码


在某种程度上,这种方法在小程序中是很有用的,尤其是在已经有一套完整错误码的程序并且严格执行报告错误和检查错误的程序。然而,这种方法也有显而易见的局限;例如,错误类型和表示错误的值都不标准时。因而,在实现一个库时可能选择0来表示(可能代表false)来表示错误,而另一个库中可能使用0来表示成功,用非零值表示一个错误环境。通常,返回值通过一个公用头文件中的系列符号常量共享,这样应用或开发小组还能够在一定程度上维护它。但是这些代码是不标准的。


更不必说,使用了来自不同软件商和程序员提供的不兼容的软件库,当使用了相冲突的错误码时,开发程序将十分困难和混乱。另一个缺点是每一个返回值都必须查表和解释——单调、费时的操作。这种方法要求调用者必须检查每一个函数的返回值;不这样做将导致运行期崩溃。当检查到错误码时,返回语句中断了正常的执行流程然后将错误码传递给调用者。围绕每一个函数调用附加的代码(检查返回状态和决定是否继续正常的代码)能够容易的使程序扩大一倍,而且在维护和可读性方面产生很大困难。然而更糟的是,有时返回错误码是不可能的。例如,构造器没有返回值,所以不能用这种方法报告构造对象失败。


改变一个全局标志


另一个可选择的报告错误的方案是使用全局标志表示最后的操作是否成功。与上一种方法不同,这种方法是标准的。C <errno.h>头文件定义了检查和赋值全局整数标志errno的一套机制。注意,这种方法的固有缺点并不是可以忽略的。在多线程环境里,一个线程赋值到errno的错误码可能在调用者还没有检查之前就被其他线程不经意的改变。另外,使用一个错误码来代替更可读的消息是不便的,因为错误码可能在不同的环境中不兼容。最后这种方法需要严格规范的编程风格,程序员必须经常检查errno现在得值。


使用全局标志的方法类似于使用函数返回值:两者都提供了报告错误的机制,但两者都不能保证错误是否真的被处理了。例如,函数中打开文件失败能通过赋给errno适当的值来报告这个错误。但是它不能阻止其他函数向文件写数据以及关闭文件的企图。此外,如果errno指出了一个错误并且程序员也检查到并处理了,errno仍然必须要显式的重新赋值。程序员可能忘记这么做,结果导致别的函数假设错误没有处理,并且试图矫正这个错误——意想不到的结果。


终止程序运行


处理运行期错误最激烈的方法就是,在检查到运行期错误时立即简单的终止程序。这种解决方法避免了前两种方法的缺点;例如,即不需要反复的检查每一个函数调用的返回值,程序员也不需要反复的赋值全局标志、检查它的值、清除它——这很容易产生错误。标准C库有两个函数来终止程序:exit()abort()。调用exit()能表示程序成功的结束(作为main()的最后一句),或在运行期错误时被调用。在返回控制环境以前,exit()首先刷新打开的流和关闭打开的文件。另一方面 abort(),指出不正常的程序终止。它立即终止程序,不进行刷新流或关闭打开文件的工作。


在运行期错误发生时重要的应用程序不能简单的被终止。如果生命维持机器只是因为一个除零错误就停止了工作,将到来惨重的结果;同样的,控制载人宇宙飞船自动功能的嵌入式电脑遇到临时与地面控制中心失去联系的情况也不因该终止运行。类似的,象电话公司或银行的自动记帐系统无论遇到什么运行期错误都不能完全停止工作。强壮的现实世界的应用程序因该——也必须比——这做的更好。


对于有些期望在严重运行期错误时终止应用程序来说程序终止也是有问题,比如操作系统。一个检查到运行期错误的函数通常没有判断错误重要性的必需的信息。例如,内存分配函数不能告诉你分配请求是否失败,因为用户现在正在使用调试器、浏览器、电子表格或字处理器等等,或者因为系统已经由于严重的硬件故障而不不稳定了。在第一种设想里,系统还能简单的显示一条消息来要求用户关闭不需要的应用程序。在第二种设想里,可能需要更强烈的措施。但是,按照这种方法,分配函数简单的终止程序(在这个例子中是操作系统的内核),而不管错误的重要性。在稍微重要一点的程序中都不能这么办。好的系统设计必须保证运行期错误被发现并且被报告,但是也必须保证错误被限制在能够容忍的范围之内。


在极端情况下或在调试阶段,终止程序是可以接受的。但是,在面向对象环境中abort()exit()从来不会被使用,即使在调试阶段,因为他们不符合C++对象模式。


exit()和abort()不会销毁对象


对象可能通过构造器或成员函数得到了它需要的资源:从堆分配的内存、文件句柄、通讯端口、数据库事务锁、I/O设备等等。这些资源必须在使用他们的对象使用完之后再适当地释放。一般由销毁器来释放资源。这个设计习惯叫resource initialization is acquisition(在第五章“面相对象的编程和设计”中详细讨论)。在堆中创建的局域对象在所属的块或函数结束时自动的销毁。但是abort()和exit()都不会调用局域对象的销毁器。因此,调用这两个函数突然的结束程序可能导致不可挽回的后果:数据库损坏、文件丢失或重要数据丢失等等。所以在面向对象的环境中不能使用abort()exit()


进入异常处理


就像你见到的,传统C的错误处理方法都不适合C++;C++的一个目标是对大型软件开发提供比C更好更安全的机制。


C++的设计人员注意到了缺乏适当的错误处理机制带来的困难。他们找到了一种避免C传统错误处理方式所有缺点的全新方法。这种建议的机制基于异常触发时系统控制的自动传递。这种机制必须简单,而且将程序员从反复的检查全局标志活函数返回值的繁重劳动中解脱出来。另外,它必须保证处理异常的代码在异常触发时能自动的得到通知。最后,它必须保证当异常不是在局域处理时,局域对象被恰当地释放并且在异常传递到高一级处理者之前对象的资源被释放。


1989年,在多年的研究和一个多余的投票提议之后,异常处理加入了C++。C++不是第一个提供结构化异常处理语言。在60十年代后期,PL/1提供了内建的异常处理机制;80十年代早期,Ada提供了它的异常处理版本,其他许多语言也提供了异常处理方法。但是这些异常处理模式都不适合C++的对象模式和程序结构。因此,提议的C++地异常处理是独一无二的,它能作为新语言的一个模式。


实现异常处理机制的确是一个挑战。第一个C++编译器,cfront在UNIX运行。象许多UNIX编译器一样,它是一个翻译者,先将C++代码翻译成C代码再编译。cfront 4.0本来是要加入异常处理的。但是,太多的要求使得实现异常处理机制如此的复杂,以致cfront 4.0的开发小组在花费了一年时间之后完全放弃了整个项目。cfront 4.0在也没有发表;然而异常处理了成了C++标准的一部分。在标准之后上市的其他编译器都支持异常处理。下面的章节说明了为什么在cfront中实现异常处理如此地困难,而其他编译器普遍支持。


实现异常处理的挑战


有几个因素使得实现异常处理很困难。第一,实现必须保证一种异常正确的找到处理自己的程序。


第二,异常对象可以使多态的;在这种情况下,当不能为派生类匹配处理程序时就必须考虑基类的处理程序。这意味着某种运行期类型检查来恢复异常对象的动态类型。然而,在异常处理开发之前C++没有运行期类型检查的工具;为了这个目的,不得不建立动态类型检查。


还有额外的复杂性,在控制权传递到正确的处理程序之前,异常处理机制必须调用在try块中异常抛出以前构造的所有局域对象的销毁器。这个过程叫做堆栈释放(堆栈释放过程在本章后面详细讨论)。因为早期的C++编译器将C++源文件翻译成纯C代码再编译成机器码,异常处理就必须在C中自己实现运行期类型识别和堆栈释放。幸运的是这些困难都被克服了。


应用异常处理


异常处理是灵活的也是不复杂的工具。它克服了传统C的错误处理方法的缺点,它可以用来处理多样的运行期错误。尽管如此,异常处理象语言的其他特性一样,很容易被滥用。为了有效的使用这种特性,理解运行期机器的工作原理和相关联的性能损失是十分重要的。接下来的章节深入探讨异常处理的内部机制和怎样用异常处理来创建强壮的应用程序。





警告:下面地例子使用了新的异常处理特性,比如函数try块和异常说明。有些编译器还不支持这些特性;因此推荐你阅读你编译器的技术文档来验证是否完全支持这些新的特性。


异常处理的组成部分


异常处理是一种在异常发生点将控制权传递给异常处理程序的机制。异常是内建数据类型变量或类对象。异常处理由四个部分组成:一个try、一个与try块关联的处理程序序列、一个throw异常和异常。try块包含可能抛出异常地代码。例如



try
{
int * p = new int[1000000]; //可能抛出std::bad_alloc
}

一个catch语句(或叫处理程序)序列跟着try块,每一个catch语句处理一种类型的异常。例如



try
{
int * p = new int[1000000]; //可能抛出std::bad_alloc
//...
}
catch(std::bad_alloc& )
{
}
catch (std::bad_cast&)
{
}

处理程序只能被一个throw异常调用,这个throw异常只能是在处理程序的try 块中抛出的或是在异常处理程序的try块中调用的函数中抛出的。一个throw异常由关键字throw和一个赋值表达式组成。例如



try
{
throw 5; //在下面的catch语句中5赋值给n
}
catch(int n)
{
}

一个throw 异常与return语句相似。空throw是没有操作数的throw语句。例如



throw;

在处理程序里空throw表示一个rethrow,马上就要讨论它。否则,如果最近没有异常被处理,执行空throw调用terminate()


堆栈释放


当异常抛出时,运行期机制首先在当前范围内搜索匹配的处理程序。如果匹配的处理程序不存在,


退出当前范围并且在调用链中更高的会进入范围。这个过程是递归的:直到找到匹配的处理程序。此时,堆栈被释放,try中在throw异常之前构造的局域对象都被销毁了。缺乏匹配的处理程序时,程序终止。但是要注意,在抛出异常被处理时C++保证局域对象恰当的被销毁。堆栈释放的时候未捕捉到的异常是否能引起局域对象被销毁是依赖于具体编译器的。为了保证在未捕捉到的异常终止程序的时候也能销毁局域对象,你可以在main()中增加一个catch all语句。例如



int main()
{
try
{
//...
}
catch(std::exception& stdexc) //处理期望的异常
{
//...
}
catch(...) //保证在未捕捉到的异常终止程序时也能正确的清除局域对象
{
}
return 0;
}

堆栈释放过程和return语句序列非常相似,都向调用者返回同样的对象。


传递异常对象给处理程序


异常能通过值或引用传递给它的处理者。抛出异常的内存分配方式是未指定的(但是不是在自由堆中分配的)。有些编译器使用专门的异常堆来创建异常对象。当通过引用传递异常时,处理程序接收到在异常堆中构造的异常对象的引用。通过引用来传递保证了它的多态行为。通过值传递的异常在调用者的堆框架(stack frame)中创建。例如



#include <cstdio>
class ExBase {/*...*/};
class FileEx: public ExBase {/*...*/};
void Write(FILE *pf)
{
if (pf == NULL) throw FileEx();
//... 正常的处理pf
}
int main ()
{
try
{
Write(NULL); //可能导致抛出一个FileEx异常
}
catch(ExBase& exception) //catch ExBase或它的派生类对象
{
//诊断和矫正 }
}

重复的拷贝通过值传递的对象是很耗时的,因为异常对象在传到匹配的处理程序之前可能被构造和销毁好几次。但是,在异常抛出的时候只发生一次,异常抛出只是在不正常的时候发生也不常见。在这种情况下,性能相对于保持应用程序的完整性来说是第二位的(异常处理的性能在本章最后讨论)。


异常类型匹配


异常的类型决定了那一个处理程序捕捉它。异常的匹配规则比重载函数的匹配有更多的限制。考虑下面的例子:



try
{
throw int();
}
catch (unsigned int) //不能捕捉到前面try块抛出的异常
{
}

抛出异常的类型是int,而处理程序接受unsigned int类型的异常。异常处理机制认为这不是匹配的类型;结果抛出的异常没有被捕捉到。异常处理的匹配规则仅仅允许十分有限的转换:对于异常 E和接受TT&的处理程序,在下面的条件下才是匹配的;




  • TE有同样的类型(忽略constvolatile限制)





  • TE的公有基类。





如果ET是指针,当ET是同类型指针实或E指向的对象是T指向对象的公有派生类时,匹配是有效的。另外,T数组返回T类型的函数分别被转换成指向T的指针指向返回T类型函数的指针


异常对象



你可能注意到,传统的返回一个整数值作为错误标志的约定不适合OOP。C++的异常处理机制提供了灵活、安全、强壮的方法。 异常可以是基本类型比如intchar *。有数据成员、成员函数的对象也可以作为异常。这样的对象可以向处理程序提供更多的信息。例如,一个聪明的异常对象可以有一个返回错误详细描述的成员函数,这比让处理程序去查表或文件要好的多。它也可以有一个在错误正确处理之后使程序能从运行期错误恢复的成员函数。考虑一个在现有日志文件中添加新记录的日志类:如果它不能打开日志文件,就抛出异常。当异常被正确的处理程序捕捉到时,异常对象可以有一个创建对话框的成员函数。操作者可以从对话框中选择恢复措施:创建新的日志文件,选择别的日志文件记录日志或简单的允许系统不记录日志。


异常说明


可能抛出异常的函数可以通过可能抛出异常的列表来警告用户。当函数的用户不能访问源文件而只能浏览函数原型的时候,异常说明是很有用的。下面是说明异常的例子:



class Zerodivide{/*..*/};
int divide (int, int) throw(Zerodivide); //函数只可能抛出
//Zerodivide类型的异常

如果你的函数不抛出任何异常,可以作如下申明:



bool equals (int, int) throw(); //这个函数布可能抛出任何异常

注意没有异常说明的函数申明比如



bool equals (int, int);

不对它的异常做任何保证:它可能抛出任何异常,也可能不抛出异常。


异常说明在运行期是强制的


在编译期不检查异常说明,


但是在运行期检查。当函数试图抛出它的异常说明不允许的异常时,异常处理机制检测到这个违例并且调用标准函数unexpected()unexpected()的默认行为是调用terminate()终止程序。违反异常说明非常象一个bug,不允许出现——因为它的默认行为是终止程序。通过函数set_unexpected()可以改变默认行为。


因为异常说明仅仅在运行期是强制的,编译器可能故意忽略明显违反异常说明的代码。考虑下面的代码:



int f(); //没有异常说明,f可以抛出任何异常
void g(int j) throw() //g许诺不抛出任何异常
{
int result = f(); //如果f抛出一个异常,g将违反它不抛出任何异常的
//保证。这段代码是非法的。
}

在这个例子中,不允许抛出任何异常的函数g(),调用了函数f()。但是, f()可能抛出任何异常,因为它没有异常说明。如果f()抛出异常,它传递给了g(),因而违反了g()不抛出任何异常的保证。异常说明仅在运行期强制可能令人惊讶,因为至少有些违反在编译期就能捕捉并标记。但是这是事实。运行期检查的方针又几个引人注目的原因...在前面的例子中,f()可能是一个C函数。要求每一个C函数都有异常说明是不可能的。强迫程序员在g() 中写一个不必要的trycatch(...)块“以防万一”也是不切实际的——程序员怎么知道f()到底抛不抛出异常,怎样的代码才是安全的?通过在运行期检查异常说明,C++应用了“相信程序员”原则,代替了迫使每一个程序员和编译器进行不必要检查。


和谐的异常说明


C++要求异常说明在派生类中也是和谐的。这意味着派生类覆盖的虚函数和基类中的虚函数至少必须有同样限制的异常说明。例如



//不同的异常类
class BaseEx{};
class DerivedEx: public BaseEx{};
class OtherEx {};
class A
{
public:
virtual void f() throw (BaseEx);
virtual void g() throw (BaseEx);
virtual void h() throw (DerivedEx);
virtual void i() throw (DerivedEx);
virtual void j() throw(BaseEx);
};
class D: public A
{
public:
void f() throw (DerivedEx); //OK,DerivedEx是BaseEx的派生类
class D: public A
{
public:
void f() throw (DerivedEx); //OK, DerivedEx是BaseEx的派生类
void g() throw (OtherEx); //错误;异常说明与A的不
//兼容
void h() throw (DerivedEx); //OK,与基类的异常说明
//一致
void i() throw (BaseEx); //错误,BaseEx不是DerivedEx也不是
//DerivedEx的派生类
void j() throw (BaseEx,OtherEx); //错误,比A::j的异常说明
//宽松
};
};

和谐限制同样适用于函数指针。指向有异常说明的函数的指针仅能赋值一个有相同或更严格异常说明的函数。这意味着指向没有异常说明函数的指针不能赋值一个没有异常说明的函数。但是注意,函数类型不考虑异常说明。因此,你不能靠函数的异常说明来区别两个函数。例如



void f(int) throw (Y);
void f(int) throw (Z); //错误;重定义'void f(int)'

同样的原因,申明包含异常说明的typedef也是错误的:



typedef void (*PF) (int) throw(Exception); //错误

对象构造和销毁期间的异常


构造器和销毁器是自动调用的;另外他们不能返回值来指示运行期错误。表面上,对象构造和销毁期间最似是而非的报告运行期错误的方法就是抛出异常。但是在你抛出异常之前必须考虑一些额外的因素。你必须特别谨慎地对待在销毁器中抛出异常。


从销毁器中抛出异常是危险的


不推荐从销毁器中抛出异常。问题在于其他异常进行堆栈释放的过程中要调用销毁器。如果由其他异常导致的销毁器调用也抛出自己的异常,异常处理机制将调用terminate()。如果你不得不在销毁器中抛出异常,建议你首先检查其他未捕捉到的异常是不是正在被处理。


检查未捕捉到的异常


当进入异常对应的处理程序时(或没有它的处理程序时函数unexpected()被调用了),认为异常被捕捉到了。为了检查抛出异常是否正在被处理,你可以使用标准函数uncaught_exception()(在标准头文件<stdexcept>)。例如



class FileException{};
File::~File() throw (FileException)
{
if ( close(file_handle) != success) //关闭当前文件是否失败?
{
if (uncaught_exception() == true ) //现在有未捕捉到的异常
//正在被处理码?
return; //如果这样,就不抛出异常
throw FileException(); //否则抛出异常来指示错误
//就是安全的
}
return; //成功
}

尽管如此,更好的设计选择在销毁器里处理异常而不是让他们传递到程序中,例如



void cleanup() throw (int);
class C
{
public:
~C();
};
C::~C()
{
try
{
cleanup();
}
catch(int)
{
//在销毁器里面处理异常
}
}

如果异常是由cleanup()抛出的,在销毁器里面处理。否则,抛出的异常将传递到销毁器外面。如果是其他异常释放堆栈过程中引起的销毁器调用,将调用terminate()


全局对象的构造和销毁


概念上讲,全局对象的构造在程序外面发生。因此,全局对象构造中抛出的异常永远不会被捕捉到。对于全局对象的销毁也是这样——全局对象的销毁器在程序终止之后才执行。因此,全局对象销毁器抛出的异常也不能被处理。


高级异常处理技术


简单的try-throw-catch模式能被进一步扩展以处理更复杂的运行期错误。这一章讨论了异常处理的一些更高级的用法,包括异常层次、嵌套异常、函数try块和auto_ptr类。


标准异常


C++定义了在运行期发生反常情况时抛出的标准异常层次。标准异常都派生自std::exception(在头文件<stdexcept>中定义)。这个层次使得应用程序只用一个catch语句就能捕捉到这些异常:



catch (std::exception& exc)
{
//处理类型std::exception的异常
//也可以是std::exception派生类的对象
}

语言的标准异常由内建运算符抛出标准异常



std::bad_alloc //由运算符new抛出
std::bad_cast //由运算符dynamic_cast < >抛出
std::bad_typeid //由运算符typeid抛出
std::bad_exception //当违反函数异常说明
//抛出

所有的标准异常提供成员函数what(),返回一个依赖具体编译器的const char *变量来描述异常。但是注意,标准库有另外一套异常。


异常处理层次


在底层的异常先被捕捉到:特定的(继承层次中最底层的)异常首先处理,然后是上一层的(基类),最后是catch all处理程序。例如



#include <stdexcept>
#include <iostream>
using namespace std;
int main()
{
try
{
char * buff = new char[100000000];
//...使用缓冲区
}
catch(bad_alloc& alloc_failure) //bad_alloc派生自
//exception
{
cout<<"memory allocation failure";
//... 处理运算符new抛出的异常
}
catch(exception& std_ex)
{
cout<< std_ex.what() <<endl;
}
catch(...) //这里处理别处没有捕捉到的异常
{
cout<<"unrecognized exception"<<endl;
}
return 0;
}

派生对象的处理程序必须比基类对象的处理程序先出现。这是因为按照出现的顺序依次尝试处理程序。因此写一个从不被执行的处理程序是可能的,例如通过将派生类的处理程序放在基类处理程序的前面。例如



catch(std::exception& std_ex) //bad_alloc总是在这里被处理
{
//...处理异常
}
catch(std::bad_alloc& alloc_failure) //不会到达这里
{
cout<<"memory allocation failure";
}

嵌套异常


抛出异常是为了指示不正常的状态。第一个捕捉到异常的处理程序尝试修复程序。如果失败了或它只是负责一个局部的恢复,它将再抛出异常,从而让更高层的try块来处理。为了这个目的,try块应该能以一定的层次顺序嵌套,使得低级catch语句抛出的嵌套异常也能被重新捕捉到。嵌套异常是通过一个没有操作数的throw指示的。例如



#include <iostream>
#include <string>
using namespace std;
enum {SUCCESS, FAILURE};
class File
{
public: File (const char *) {}
public: bool IsValid() const {return false; }
public: int OpenNew() const {return FAILURE; }
};
class Exception {/*..*/}; //一般异常基类
class FileException: public Exception
{
public: FileException(const char *p) : s(p) {}
public: const char * Error() const { return s.c_str(); }
private: string s;
};
void func(File& );
int main()
{
try //外部try
{
File f ("db.dat");
func(f); // 1
}
catch(...) // 7
//这个异常处理程序将捕捉到嵌套异常;
//注意:需要同样的异常类型
{
cout<<"re-thrown exception caught";
}
return 0;
}
void func(File & f)
{
try //内部try
{
if (f.IsValid() == false )
throw FileException("db.dat"); // 2
}
catch(FileException &fe) // 3
//处理异常的第一次机会
{
cout<<"invalid file specification" <<fe.Error()<<endl;
if (f.OpenNew() != SUCCESS)//5
//嵌套抛出原来的异常,让更高层的处理程序去处理它
throw; // 6
}
}

在前面的例子中,在main()中的try块调用函数func()是第一步。(2)func()函数中的第二个try块抛出FileException类型的异常。(3)这个异常被func()函数中的catch块捕捉到。catch块试图打开新的文件来修补错误。(5)尝试失败了,(6)重新抛出FileException。最后,嵌套抛出的异常被捕捉到了——(7)这一次是通过main()中的catch(...)捕捉到的。


函数try块


函数 try就是一个函数体由一个try块和与之相关联的处理程序组成的函数。函数try块使得处理程序能捕捉到在函数初始化表达式执行过程中抛出的异常,比如构造器的成员初始化列表或构造器本身抛出的。但是注意,与普通的处理程序不同函数try块的处理程序只能捕捉异常——它不能继续正常的对象构造。这是因为部分构造的对象在堆栈释放中被销毁了。另外,函数try块的处理程序不能执行return语句(最后,处理程序只能通过throw退出)。那函数try块有什么用呢?处理程序使你能抛出与捕捉到异常不一样的异常,因而避免违反异常说明。例如



class X{};
C::C(const std::string& s) throw (X) //仅允许抛出X
try
: str(s) //str的构造器可能抛出一个bad_alloc异常,
//可能违反C的异常说明
{
//构造器函数体
}
catch (...) //处理ctor初始化列表或ctor体抛出的任何异常
{
//...
throw X(); //将bad_alloc替换成X
}

在这个例子中,一个string对象首先作为类C的一个成员被构造。在其构造期间string 可能抛出bad_alloc异常。函数try块捕捉到bad_alloc异常然后抛出一个类型为X的异常作为替代,这样就符合C的异常说明了。


使用auto_ptr<>来避免内存泄漏


标准库提供类模板auto_ptr<>(在第十章“STL和泛型程序设计”中讨论),它能以一样的方式分配内存而避免了指针越界。当实例化auto_ptr<>时, 它能作为指向一个动态分配对象的指针来初始化。当当前的范围越界时,auto_ptr<>对象的销毁器自动的删除越界的对象。通过使用auto_ptr<>,你可以避免内存泄漏。此外,auto_ptr<>可以简化显式删除动态分配对象的麻烦。auto_ptr<>在标准头文件<memory>中定义。


例如



#include <memory>
#include <iostream>
using namespace std;
class Date{ public: const char * DateString(); };
void DisplayDate()
{
//创建一个类型为auto_ptr<Date>的局域对象
auto_ptr<Date> pd (new Date); //现在pd被模板对象所拥有
cout<< pd-> DateString();
//pd被auto_ptr的销毁器自动的删除;
}

在上面的例子中,auto_ptr<>的实例pd能象一个指向Date对象的普通指针一样使用。auto_ptr<>的重载运算符*->&提供类似指针的语法。当DisplayDate()退出时,pd的越界对象自动被销毁。


异常处理的性能开销


自然的,异常处理依赖于运行期类型检查。当抛出异常时,不得不检查异常是否是一个try块抛出的(在try块以外的代码也可能抛出异常——比如运算符new)。如果异常确实是由try块抛出的,就要比较异常类型并试图在当前范围找到一个匹配的处理程序。如果找到,控制权转到处理程序内。这是一个乐观的假设。如果没有为异常找到匹配的处理程序,或者异常不是try块抛出的将会怎么样呢?在这种情况下,当前的函数将从堆栈中释放,进入堆栈中前一个活动的函数。在找到匹配的处理程序之前,重复前面的工作(还有,所有在try块的throw表达式之前创建的局域对象都将被销毁)。当在程序中找不到匹配的处理程序时,调用terminate()终止程序。


额外的运行期类型识别


异常处理机制不得不存储关于每一个异常和每一个catch语句的数据,以便在异常和匹配它的处理程序之间执行运行期匹配。因为异常可以是任何类型,而且它也能多态,它的类型只用在运行期用RTTI识别。RTTI引起了执行速度和程序大小的额外开销(参见第七章“运行期类型能够识别”)。然而光有RTTI还是不够的。还必须有运行期代码信息,就是每个函数结构的信息。决定异常是否由try块抛出需要这个信息。这个信息由编译器产生。编译器将函数体划分成三个部分:没有活动对象的外部try块,有活动对象也在堆栈释放中销毁地外部try块,内部try块。


关闭对异常处理的支持


不同编译器、不同平台上实现异常处理的技术都不同。但是,即使没有异常抛出,只要实现异常处理就意味着额外的性能开销,不仅是执行速度程序也会变大。许多编译器允许你关闭对异常处理的支持。当关闭时,额外的数据结构、查找表和辅助代码都不会产生。然而关闭异常处理是罕见的选择。即使你不直接使用异常,你也会隐含的使用它:比如运算符new失败时抛出一个std::bad_alloc异常——其他一些内建运算符也可能抛出异常;STL容器可能抛出自己的异常,标准库函数也是如此。第三方厂商提供的代码库也可能使用异常。因此只有当你用C++编译器编译纯C代码时,你才能安全的关闭异常支持。使用纯C代码时异常处理的开销是不必要的,也是可以避免的。


滥用异常处理


异常处理不仅限于处理错误。有些程序员可能将它作为控制简单的forwhile循环的手段。例如,提示用户输入数据直到特定的条件满足时退出, 可以有如下实现(有点天真):



#include <iostream>
using namespace std;
class Exit{}; //作为一个异常对象
int main()
{
int num;
cout<< "enter a number; 99 to exit" <<endl;
try
{
while (true) //无限循环
{
cin>>num;
if (num == 99)
throw Exit(); //退出循环
cout<< "you entered: " << num << "enter another number " <<endl;
}
}
catch (Exit& )
{
cout<< "game over" <<endl;
}
return 0;
}

在上面的例子中,程序员将一段无限循环放在try块中。throw语句中断循环,并将控制权传递到接着的catch语句。不推荐这种程序设计风格。由于异常处理的开销,这是十分低效的。此外,这是冗长的,本来使用break语句会简单的多。在演示程序中,区别仅限于风格。在大型应用程序中,这种用法将导致巨大的性能开销。


不用繁重的异常处理机制也能安全有效处理的简单错误需要用传统方法来处理。例如,如果用户打错了密码时,密码确认对话框就不必抛出异常。简单的方法是重新显示一个带有错误信息提示的密码确认对话框。另一方面,如果用户输错密码多次,就意味着一次恶意的入侵。这时就要抛出异常了。处理程序要通知系统管理员和安全官员。


总结


C++的异常处理机制克服了传统方法的问题。它使程序员再也不用为每一个函数调用都写一段单调冗长的检查成功状态的代码。异常处理也消除了人为错误。另一个优点是自动的堆栈释放,这保证了局域对象正确的销毁以及局域对象所有的资源正确的被释放。


实现异常处理机制式一项繁重的工作。为了动态查询异常类型,在C++中引入了RTTI。异常处理的额外开销来源于RTTI数据结构,编译器产生的代码信息和其他依赖具体编译器的因素。异常可以有层次;标准异常就是一个好例子。最近几年,异常处理机制的几个小漏洞被修补了。第一个是增加了函数原型的异常说明。第二是引入了函数try块,它使程序能处理在初始化表达式中抛出的异常,比如构造器的成员初始化列表抛出的异常或执行构造器中抛出的异常。


异常处理是有效处理运行期错误的强大、灵活的工具。但是请明智的使用它。

原创粉丝点击