2023年11月29日发(作者:)
C++程序设计
第15章 异常
程序中经常要检查处理各种错误情形,如果用传统的流程控制语句来处理,很容易使程
序逻辑混乱。异常(exception)就是一种专门用于检测错误并处理的一种机制,使程序保持逻
辑清晰,并改进程序的可靠性。C++语言提供了基本的异常处理机制。本章主要介绍异常的
概念、语句、异常类型架构及应用。
可靠的编程应尽可能地、及时地检测到各种异常情形,尽可能在本地处理。尽管有时自
己不能处理,也应该向调用方提供详细的出错信息,使调用方能得到充分信息,从而采取合
适方式来处理异常。
15.1 异常的概念
异常是什么概念?异常就是在程序运行中发生的难以预料的、不正常的事件而导致偏离
正常流程的现象。例如:
访问数组元素的下标越界,在越界时又写入了数据;
用new动态申请内存而返回空指针(可能是因内存不足);
算术运算上溢出或下溢出;
整数除法中除数为0;
调用函数时提供了无效实参,如指针实参为空指针(如用空指针来调用strlen函数);
通过挂空指针或挂空引用来访问对象;
输入整数或浮点数失败;
I/O错误,等等。
上面列出的情形之一如果发生,就可能导致运行错误而终止程序。
发生异常将导致正常流程不能进行,就需要对异常进行处理。那么异常处理是什么概
念?异常处理(exception handling)就是在运行时刻对异常进行检测、捕获、提示、传递等过
程。如果采用传统的if-else语句来检测处理所有可能发生的异常,很容易导致程序流程混乱,
分不清正常流程与异常处理,而且在处理一个异常时往往又引入了新的异常。
假设要设计一个函数,从一个文本文件中读取数据得到一个float矩阵。该文件应存放
一个m*n的float矩阵,头两个整数说明其行数m和列数n。你要把它读入并创建一个矩阵
对象,以备下一步计算。如果你认为文本文件不会有错,完全按正常编程,不超过10条语
句就能完成。如果这个文本文件是别人提供的,而且你的函数将提供给其它人使用,那么你
在每一步都要考虑可能出现的错误,此时就可能需要30条语句来处理。例如,可能的出错
情形如下:
打开文件出错,文件名可能有误;
读取行数m或者列数n可能出错;
读取每个元素时都可能出错;
矩阵数据可能不完整,也会出错。
如果你用传统方式来判断处理以上这些问题,就会发现正常的流程被淹没在多种异常判
断处理之中。此时就需要有一种统一的机制能将正常流程与异常处理分开描述,而保持程序
逻辑清晰可读,同时各种异常情形能被集中处理。
C++提供了引发异常语句throw和捕获处理异常语句try-catch。它们构成了一种特殊的
流程控制。
用throw引发的每个异常都可以描述为一个对象或一个值。在程序中,每一种异常都可
以描述为一种类型,可能是自定义的类,也可能是简单的整数或字符串。在比较完善的编程
中,经常用不同的类来描述不同的异常,建立一个异常类型的继承结构,以方便对异常类型
的管理和重用。
一个函数中当检测到某种异常发生,但自己往往不知道应该如何处理,此时就应该通知
调用方知道发生了什么异常。处理异常的一般方式是:在一个函数中发现一个错误但不能处
理,就用throw语句引发一个异常,希望它的(直接或间接)调用方能够捕获并处理这个异
常。函数的调用方如果能解决该异常,就可使用try-catch语句来捕获并处理这种异常。如
果调用方不能捕获处理该异常,异常就被传递到它自己的调用方,最后到达main函数。
异常的发生、传递与处理的过程与函数调用堆栈相关。如图15.1所示。main函数中调
g(){...return;}g(){...throw x;}
callreturncallthrow
f(){...g();...return;}f(){...g();...}
callreturncallthrow
main(){...f();...}main(){...f();...}
(a)正正正正正正正(b)正正正正正正正正正
图15.1 函数调用堆栈与异常传递
用f函数,f函数再调用g函数。如果g函数执行return就正常返回到f。如果f执行到return
就正常返回到main。这是正常流程。
如果g函数在运行时因检测到某种错误而用throw语句引发一个异常,而自己也没有捕
获处理,此时该异常就被传递到f的调用方g函数,而且g函数执行终止(注意,不是返回)。
对于f来说就是g函数调用发生异常。此时如果f函数没有捕获该异常,那么异常又被传递
到它的调用方main函数,此时f函数执行终止。同理,此时如果main也没有捕获该异常,
那么程序就必须终止。此时系统可能会跳出一个对话框告知你发生了运行错误。
在发生异常、传递异常的过程中,如果有一个函数用try-catch捕获了该异常,就不会
导致程序终止。在运行时刻,一个异常只能被捕获一次。假设f函数捕获了这个异常,那么
对于它的调用方main函数来说,就等于没有发生异常。
异常编程的目的是改善程序的可靠性。在大型复杂程序中,完全不发生异常几乎不可能,
用传统的if-else语句来检查所有可能的异常情形,也有很大困难。编程正确性总是依赖某些
假设成立为前提,异常编程就是要分析识别这些假设不成立的情形,采用面向对象编程技术,
建立各种异常类型并形成继承性架构,以处理程序中可能发生的各类异常。
15.2 异常类型的架构
C++的异常类型可以是任何类型,既可以是基本类型,如int整数、char*字符串,也可
以是自定义类型。在比较规范的编程中,往往不能将基本类型作为异常类型。这是因为基本
类型所能表示的异常种类有限。例如在一个程序中int类型只能表示一种异常情形,如果在
不同函数中多处引发不同语义的int异常,就很难区别不同int值的含义。可能表示访问数
组的下标越界,也可能表示打开文件不成功。
在比较规范的编程中往往根据各种错误情形,利用类的继承性建立一个异常类型的架
构,作用如下:
对所处理的各种错误情形进行准确描述、抽象和归类。
方便扩展新的异常类型。
在编程中方便选取引发正确的异常类型,也方便按类型来捕获处理异常。
图15.2是定义在
exception
logic_errorruntime_error. . .
domain_error
invalid_argument
out_of_range
range_errorunderflow_error
overflow_error
length_error
图15.2 异常类型架构
中定义了基类exception和一组函数,在
异常。标准模板库STL中的部分函数就利用了这个架构。下面简单介绍各种异常类型。
类exception是所有异常类的基类,其公共成员如下:
class exception{
...
public:
exception() throw(); //缺省构造函数
exception(const exception& rhs) throw(); //拷贝构造函数
exception& operator=(const exception& rhs) throw();//赋值操作函数
virtual ~exception() throw(); //虚析构函数
virtual const char *what() const throw(); //虚函数
};
注意到每个函数原型末尾都有“throw()”,称为函数的异常规范(exception-specification),
括号中为空说明该函数中不会引发任何异常出来。如果一个函数在执行时可能引发某种异常
类型,就应该在“thow(异常类型表)”中说明,以告知调用方。
每个异常对象都至少包含一个字符串,来说明异常发生的原因或出错性质,称为出错信
息。因此大多派生异常类都提供含字符串形参的构造函数。例如:
class invalid_argument : public logic_error{
public:
invalid_argument(const string& what_arg); //构造函数
};
一般地,派生类继承基类的虚函数what(),返回出错信息。如果需要的话,派生类可以
改写这个虚函数,以提供更多信息。
类logic_error表示逻辑错误的异常类型,此类错误是在特定代码执行之前就违背了某些
前置条件,例如,数据越界out_of_range,函数调用实参无效invalid_argument等,也包括
特定领域相关的错误domain_error。读者可自行扩展新类型。例如,访问数据的下标越界可
作为out_of_range的派生类。一些逻辑错误意味着编程有误,一般通过改进编程能避免。
类runtime_error表示运行期错误,在程序执行期间才能检测的错误。例如算术运算可
能导致上溢出overflow_error、下溢出underflow_error、数值越界range_error等。读者可自
行扩展新的类型,如空指针错误可作为runtime_error的派生类。一些运行期错误有一定偶
然性,与执行环境有关,如内存不足、打开文件失败等,此类错误并不能通过改进自身编程
来消除。
一个异常类所包含的信息越多,对于此类错误的检测和处理就越有利。例如,要说明下
标越界错误,就应该说明该下标的当前值是多少,可能的话,还应说明合理的下标范围。这
需要添加新的数据成员以及相应的成员函数。例如:
class Index_out_of_range : public out_of_range{
const int index;
public:
Index_out_of_range(int index1, const string& what_arg)
:index(index1), out_of_range(what_arg){}
int getIndex()const {return index;}
};
再如,要说明读取文件到特定位置时发生数据错误,就应该说明文件名、出错位置、所
读到的数据等信息。后面将详细介绍这些派生类的设计。
大多数异常派生类都很简短,关键是对异常的识别和命名。
C++异常分为两类:有命名的和未命名的。有命名的异常是有类型的,基本类型(如int)
或字符串(char *)或自定义类型。未命名的异常是在运行时刻某种底层错误引起的,例如,
整数相除时除数为0,通过挂空指针或挂空引用来访问对象,破坏当前函数的堆栈使函数返
回到错误地址等。未命名异常虽然也能被捕获,但不能提供确切的出错信息。建立异常类型
架构本质上就是对各种异常情形的识别与命名,对于异常处理具有重要作用。
15.3 异常处理语句
C++语言的异常处理语句包括引发异常语句throw和捕获处理语句try-catch。
15.3.1 throw语句
引发异常语句的语法格式为:
throw <表达式>;
其中,关键字throw表示要引发一个异常到当前作用域之外。表达式值的类型作为异常
事件的类型,并将表达式的值传给捕获处理该类型异常的程序。表达式的值可能是一个基本
类型的值,也可能是一个对象。如果要引发一个对象,对象类应该事先设计好。一个类表示
了一种异常事件,应描述该类异常发生的原因、语境以及可能的处理方法等。
如果在一个函数编程中发现了自己不能处理的错误情形,就可使用throw语句引发一个
异常,将它引发到当前作用域之外。如果当前作用域是一个函数,就将异常传递给函数的调
用方,让调用方来处理。
throw与return相似,表达式也相似,都会中止后面代码的执行。throw语句执行将控
制流转到异常捕获语句处理。这将导致throw语句下面相邻语句不能执行,而且会自动回收
当前作用域中的局部变量。例如:
throw index; //引发一个int异常,index是一个int变量
//引发一个const char *异常 throw "index out of range";
throw invalid_argument("denominator is zero");//引发invalid_argument异常
最后一个throw语句执行过程是,先创建一个invalid_argument对象,然后再将该对象
引发到当前作用域之外。
throw与return的含义不同。一个函数的返回值表示正常执行的结果,要作为显式说明
的函数规范。throw语句虽然也能终止当前函数的执行,但表示不正常的执行结果。一个函
数只有一种返回类型,但可能引发多种类型的异常。
为了说明一个函数可能引发哪些类型的异常,可用异常规范(exception specification)来说
明,就是“throw(异常类型表)”。例如下面是一个求商函数:
double quotient(int numrator, int denominator) throw(invalid_argument){
if (denominator == 0)
throw invalid_argument("denominator is zero");
return double(numrator) / denominator;
}
该函数的第一个形参除以第二个形参,返回商作为结果。该函数的原型中包含了异常规
范:,说明该函数的调用可能引发invalid_argument异常。函数
throw(invalid_argument)
体中检查第二个形参(即除数),如果除数为0,就引发该异常。
尽管异常规范目前还起不到语法检验的作用,但起码能告知函数的调用方注意捕获哪些
类型的异常,而不是仅仅等待函数的返回值。
异常对函数设计具有重要作用。传统的C函数设计不用异常。如果函数有多种结果,返
回类型只能有一个,就要添加形参来表示其它结果。添加的形参往往是指针类型,在调用时
要提供变量地址作为实参,在调用返回之后再判断得到什么结果。例如,一个求商函数可能
设计如下:
double quotient(int numrator, int denominator, int * isValid){
if (denominator == 0){
*isValid = 0; //用0表示无效除数
return 0;
}
*isValid = 1; //用1表示有效除数
return double(numrator) / denominator;
}
上面函数中商作为返回值,添加了一个引用形参来表示除数是否有效。另一种可行的C
函数设计是返回一个int值,0表示无效除数,1表示有效除数,而将商作为形参,如下所
示:
int quotient(int numrator, int denominator, double* result){
if (denominator == 0)
return 0;
*result = double(numrator) / denominator;
return 1;
}
可以看出,传统的C函数设计把除数为0作为一种特殊情形,用if语句加以判断处理,
需要更多的函数形参,导致函数定义复杂化。另一方面,调用方在调用函数返回之后,就要
立即用一个if语句来判断返回值是否为1,如为1,商才有效,如不为1,商无效。这对调
用方有一种强迫性,必须立即做出判断,这将导致程序逻辑复杂化,而且难以清晰表达正常
流程。
异常是C++提供的一种新概念,表示了偏离正常流程的小概率事件。异常不应该使正常
流程的描述复杂化,也不应该让调用方忽视可能发生的异常。调用方可以选择在适当的地方
集中捕获处理多种异常,就要用到try-catch语句。
使用throw语句,应注意以下要点:
(1)根据当前异常情形,应选择更准确、更具体的异常类型来引发,而避免引发抽象的类
型。例如,如果在new申请内存之后,如果发现返回空指针,此时应引发OutOfMemory类
型的异常,而不是NullPointer异常,也不是更抽象的runtime_error或者exception。准确具
体的异常信息对于调用方的处理非常重要,否则就可能导致误解。
(2)如果一个函数中使用throw语句引发异常到函数之外,应该在函数原型中用异常规范
准确描述,即“throw(异常类型表)”,使调用方知道可能引发的异常类型,提醒调用方不要
忽视。
(3)虽然throw语句可以在函数中任何地方执行,但应尽可能避免在构造函数、析构函数
中使用throw语句,因为这将导致对象的构建和撤销过程中出现底层内存错误,可能会导致
程序在捕获到异常之前就被终止。后面15.8节将分析其原因。
(4)一般来说,异常发生总是有条件的,往往在一条if语句检测到某个假设条件不成立时,
才用throw语句引发异常,以阻止下面代码执行。在一个函数中无条件引发异常,只有一个
理由,就是不想让其它函数调用,例如,一些实体类的拷贝构造函数和赋值操作函数如果不
想被调用,就将这些函数设为私有,同时用一条throw语句避免本类其它函数执行。
(5)千万不要认为,只要我的编程中没有throw语句就不会引发异常,没有异常就是可靠
的。你可以暂时忽略异常,但当假设条件不满足,异常总会发生。当异常发生时你就不知道
在何处出现异常,也不知道什么原因导致异常,更不知道如何处理能使程序继续执行。
15.3.2 try-catch语句
捕获处理异常的语句是try-catch语句,一条try-catch语句由一个try子句(一条复合语句)
和多个catch子句组成。一个catch子句包括一个异常类型及变量和一个异常处理器(一条复
合语句)。语法格式如下:
try{
可能引发异常的语句序列; //受保护代码
}catch(异常类型1 异常变量1){
处理代码1; //异常处理器1
}catch(异常类型2 异常变量2){
处理代码2; //异常处理器2
}...
}catch(...){
处理代码; //异常处理器
}
其中,关键字try之后的一个复合语句称为try子句。这个复合语句中的代码被称为受
保护代码,包含多条语句。受保护代码描述正常的执行流程,但这些语句的执行却可能引发
异常。如果执行没有发生异常,try-catch语句就正常结束,开始执行其下面语句。如果引发
了某种类型的异常,就按catch子句顺序逐个匹配异常类型,捕获并处理该异常。如果异常
被捕获,而且处理过程中未引发新的异常,try-catch语句就正常结束。如果异常未被捕获,
该异常就被引发到外层作用域。图15.3表示了try-catch语句的组成结构。
try正正
正正正正正
正正1
catch正
正
正正正正1
正正正正正1
正正2
...
正正n
catch正
正
正正正正2
正正正正正2
...
正正
正正
图15.3 try-catch语句的组成结构
一条语句执行引发异常,有以下3种可能的原因:
1、 该语句是throw语句。
2、 调用函数引发了异常。
3、 表达式执行引发了未命名的异常,如整数除数为0、挂空访问等。
例如下面try-catch语句,调用了前面介绍的求商函数quotient。
try{
result = quotient(n1, n2); //A
cout<< "The quotient is "< //... }catch(invalid_argument ex){ //C cout<<"invalid_argument:"<<(); //D } A行调用quotient函数,如果没有引发异常,就执行B行,然后try-catch语句就执行完 毕。如果A行引发了某种异常,B行就不执行,从C行开始匹配异常类型,因为A行函数 调用可能引发的异常类型正式catch子句要捕获的异常类型invalid_argument,故此该异常对 象就替代了ex形参,之后再执行后面的一个复合语句,D行调用异常对象ex的成员函数得 到错误信息,然后打印出来。try-catch语句执行完毕。无论是否发生异常,这个try-catch语 句都能执行完毕,下面语句都能执行。 异常是按其类型进行捕获处理的。一个catch子句仅捕获一类异常。一个catch子句由 一个异常类型及变量和一个异常处理器(一条复合语句)构成。异常类型及变量指明要捕获的 异常的类型,以及接受异常对象的变量。例如catch(invalid_argument ex),要捕获的异常类 型为invalid_argument,如果真的捕获到该类异常,那么变量ex就持有这个异常对象,这个 对象就是前面用throw语句引发出来的。 有一种特殊的catch子句,就是catch(...),该子句能匹配任何类型的异常,包括未 命名的异常,不过异常对象或值不能被变量捕获,故此不能提供确切的错误信息。在多个 catch子句中,这种catch子句应该排在最后。 在执行try子句中的受保护代码时,如果引发一个异常,系统就到catch子句中寻找处 理该异常类型的入口。这种寻找过程称为异常类型匹配。按如下步骤进行: (1)由throw语句引发异常事件之后,系统依次检查catch子句以寻找相匹配的处理异 常事件入口。如果某个catch子句的异常类型说明与被引发出来的异常事件类型相一致,该 异常就被捕获,然后执行该子句的异常处理器代码。如果有多个catch子句的异常类型相匹 配,按照前后次序只执行第一个匹配的异常处理代码。因此较具体的派生类异常应该在匹配 在前,以提供最具体详细的信息,而较抽象的基类异常应该排在后面。 (2)若没有找到任何相匹配的catch子句,该异常就被传递到外层作用域。如果外层 作用域是函数,就传递到函数的调用方。 一个异常的生命期从创建、初始化之后,被throw引发出来,然后被某个catch子句捕 获,其生命期就结束了。一个异常从引发出来到被捕获,可能穿越多层作用域或函数调用。 如果到main函数都未被捕获,将导致程序被迫终止。 从图15.3中可以看出,try-catch语句的执行结果有两个:正常和异常。表15.1分析了 try-catch语句的4种具体情形。 表15.1 try-catch语句执行结果 序号 结果 具体情形 1 正常完毕 受保护代码未引发异常 2 正常完毕 受保护代码引发了异常,但异常被某个catch子句捕获 3 异常退出 受保护代码引发了异常,但未被catch子句捕获 4 异常退出 受保护代码引发了异常,而且被某个catch子句捕获,但在异常处理器中又 引发了新的异常,或者用“throw;”语句把刚捕获的异常又重新引发出来 分析下面try-catch语句的可能结果: try{ result = quotient(n1, n2); cout<< "The quotient is "< //... }catch(invalid_argument ex){ cout<<"invalid_argument:"<<(); }catch(logic_error ex){ cout<<"logic_error:"<<(); }catch(exception ex){ cout<<"exception:"<<(); }catch(...){ cout<<"some unexpected exception"; } 上面try子句中调用了可能引发异常的函数quotient。这个try语句包含了4个catch子 句,这4个catch子句的次序是较具体的派生类放在前面,较抽象的基类放在后面。最后一 个catch子句可匹配捕获任意类型的异常,但因得不到异常对象,故此不能提供更多信息。 在一次执行时,如果引发异常,只能有一个catch子句捕获处理该异常。由于最后一个catch 子句能捕获所有类型的异常,而且所有的异常处理器代码中都不会引发异常,因此该 try-catch语句的执行结果是表中第1种或者第2种情形。 对于try-catch语句的理解和应用,应注意以下几点。 (1)try子句中的代码,称为受保护代码,实际上是受到下面若干catch子句的保护,使 得try子句代码可以放心去描述正常处理流程,而无需每执行一步都要用if语句来判断是否 发生异常情形。 (2)并非try子句都可能引发异常,也并非catch子句要捕获try子句所引发的所有异常, 当前函数只需捕获自己能处理的异常。 (3)多个catch子句之间,不允许基类异常在前、派生类在后,否则将出现语法警告,这 使得列在后面的派生类捕获不到异常,而排在前面的基类先捕获到了。 (4)try-catch语句仅适合处理异常,并不能将其作为正常流程控制。 15.3.3 例子 例15-1 控制流程测试。 #include void testExcept(int i){ try{ if (i == 1) throw "catch me when i == 1"; //A if (i == 2) throw i; //B if (i == 0){ int d = (i+1) / i; //C cout< } cout<<"i="< }catch(int i){ //E cout<<"catch an int: "< }catch(char * ex){ //F cout<<"catch a string:"< }catch(...){ //G cout<<"catch an exception unknown"; } cout<<"tfunction returnn"; //H } void main(){ testExcept(0); //C行引发异常,G行捕获 testExcept(1); //A行引发异常,F行捕获 testExcept(2); //B行引发异常,E行捕获 testExcept(3); //未引发异常 } 上面try-catch语句中的受保护代码中可能引发3种类型的异常。A行引发const char * 类型的异常,B行引发int异常,C行执行整数除法时,除数为0,将引发底层的未命名的 异常,故此D行不会执行。 上面try-catch语句包含3个异常处理器。E行捕获int异常,F行捕获char*异常,G行 捕获底层未命名异常。注意,第1个和第2个异常处理器的次序无所谓,但第3个异常处理 器必须排在最后,因为排在它后面将捕获不到任何异常。H行是try-catch语句后的一条语句, 如果try-catch语句正常退出,那么H语句将执行。如果受保护代码中引发的异常未被捕获, 或者异常处理代码中引发异常,H语句都不能执行。 执行程序,输出如下: catch an exception unknown function return catch a string:catch me when i == 1 function return catch an int: 2 function return i=3 function return 在主函数中测试了4种情形,前3次调用都引发异常而且被捕获,最后一次调用未引发 异常。上面例子中的异常是直接用throw语句或者除数为0的表达式来引发的,异常也可能 是因调用函数而引发的。 例15-2 测试向量vector 是支持元素随机访问的一种常用容器,它有两种随机访问形式:operator[]和at(),后者可引 发out_of_range异常。 #include #include using namespace std; void main(){ try{ vector int i = 0; for(i = 0; i < 4; i++) //B vec[i] = i + 1; for(i = 0; i <= 4; i++) cout< cout< for(i = 0; i <= 4; i++) cout<<(i)<<" "; //D throw exception when i==4 cout< }catch(out_of_range ex){ cout<<"out of range:"<<()< }catch(...){ cout<<"unexpectedn"; } } 执行程序,输出如下: 1 2 3 4 -33686019 1 2 3 4 out of range:invalid vector 上面程序测试两种按下标随机访问元素的成员函数。A行先创建了一个向量,包含4 个int元素。B行对这4个元素初始化。C行调用operator[]来访问元素,输出第1行,当下 标越界时,并没有引发任何异常,只是读取的vec[4]元素的值是随机值。D行调用at(int index) 来访问元素,输出第2行。当下标越界时,引发了out_of_range异常,而不会按非法下标读 取值。 例15-3 除数为0的异常。在整数除法中,如果除数为0就引发底层未命名异常,因此 有必要在除法执行之前判断除数是否为0,如果除数为0就引发一个命名的异常来通知调用 方。编程如下: #include #include using namespace std; double quotient(int numrator, int denominator) throw(invalid_argument){ if (denominator == 0) throw invalid_argument("denominator is zero"); //A return double(numrator) / denominator; } void main(){ int n1, n2; double result; cout<<"Enter two ints(end-of-file ^Z to end):"; while (cin>>n1>>n2){ try{ result = quotient(n1, n2); cout<< "The quotient is "< }catch(invalid_argument ex){ //B cout<<"invalid_argument:"<<(); }catch(logic_error ex){ //C cout<<"logic_error:"<<(); }catch(exception ex){ //D cout<<"exception:"<<(); }catch(...){ //E cout<<"some unexpected exception"; } cout<<"nEnter two ints(end-of-file ^Z to end):"; } } 执行程序,输出如下: The quotient is 0.607143 Enter two ints(end-of-file ^Z to end):^Z 执行了3次求商函数,其中第2次除数为0,此时A行引发了invalid_argument类型的 异常,那么B行的catch子句就捕获了该类异常。做如下修改、再测试验证: 1、 如果修改A行,将invalid_argument改变为logic_error,那么C行的catch子句就 捕获了该类异常。 2、 如果再修改A行,改变为exception,那么D行的catch子句就捕获了该类异常。 3、 如果删除B、C、D行的catch子句,那么E行的catch子句就捕获了该类异常。 4、 如果删除所有的catch子句,那么异常将引发到main函数之外,由系统给出一个 提示如图15.4。这个对话框中不能给出有效错误信息,只能终止程序。 这个例子说明了因调用函数而引发异常的捕获。这个例子也说明了catch(...)可以捕获任 何类型的异常,但编程中往往采用另一种方法来处理未捕获的异常。 图15.4 main函数未捕获的异常提示 15.4 终止处理器 如果引发的异常未能被捕获,程序将被迫终止。在程序终止之前,系统提供了终止处理 器(terminate handler),提供一个机会来清理系统资源,然后再终止程序。在 定义在std命名空间之中。 在发生下面情形之一时将自动执行terminate()函数: 1、 引发异常最终未能捕获。 2、 析构函数在系统堆栈释放时引发了异常。 3、 在引发某个异常之后系统堆栈遭破坏。 缺省的terminate函数将调用abort函数,但abort函数不执行清理而简单终止程序,因 此常常需要自行定义一个函数,作为terminate函数调用的函数,这要先准备一个无参且无 返回的函数f,然后调用set_terminate(f),将函数f作为终止处理器。 例15-4 terminate函数的例子。 #include #include using namespace std; void term_func(){ //A cout << "term_func() was called by terminate().n"; // ... cleanup tasks performed here // If this function does not exit, abort is called. exit(-1); } void main(){ int i = 10, j = 0, result; set_terminate( term_func ); //B try{ if( j == 0 ) throw "Divide by zero!"; //C else result = i/j; }catch(int){ cout << "Caught an integer exception.n"; } cout << "This should never print.n"; } 执行程序,输出如下: term_func() was called by terminate(). A行定义了一个函数要作为终止处理器,B行调用set_terminate将此函数作为终止处理 器。在头文件 typedef void (*terminate_handler)();//函数指针类型,终止处理器 terminate_handler set_terminate(terminate_handler ph) throw(); void terminate(); 头一行说明了一种函数指针的类型名,第二行说明了一个函数set_terminate,将一个函 数ph说明为新的终止处理器。最后一行是异常处理器函数,缺省将调用abort函数。 C行引发的异常类型为const char*,显然不能被下面的catch子句捕获,该异常将 导致程序终止,将执行terminate函数,因为B行设置了新的终止处理函数term_func,那么 新的函数得到执行。通常情况下,设置终止函数的目的是释放资源,然后调用exit函数来终 止程序。 标准C++还支持意外处理器unexpected handler,但VC++6版本并不支持。 15.5 扩展新的异常类型 虽然前面图15.2给出了一个异常类型架构,但经常需要扩展自己的异常类型。例如, 虽然out_of_range类能用于说明下标越界,但未说明发生异常的下标究竟值是什么。再如, 前面例子中除数为0的异常使用了invalid_argument类,而实际上除数为0可能有多种情形, 而不一定都作为函数实参,因此有必要自行定义除数为0的异常类。 图15.4给出了一组扩展的异常类型。扩展异常类主要是以logic_error和runtime_error 为基类来定义派生类。下面构造了一个头文件exceptions.h,包含了一组常用的异常类。 #ifndef EXCEPTIONS #define EXCEPTIONS #include exception logic_errorruntime_error out_of_range OutOfMemory NullPointerIOException DevideByZero Index_out_of_range OpenFileExceptionReadFileFail 图15.4 扩展异常类型 #include using namespace std; //下标越界,记录下标 class Index_out_of_range : public out_of_range{ const int index; public: Index_out_of_range(int index1, const string& what_arg) :index(index1), out_of_range(what_arg){} int getIndex()const {return index;} }; //除数为0 class DivideByZero : public runtime_error{ public: DivideByZero(const string& what_arg) :runtime_error(what_arg){} }; //空指针 class NullPointer : public runtime_error{ public: NullPointer(const string& what_arg) :runtime_error(what_arg){} }; //无可用内存 class OutOfMemory : public runtime_error{ public: OutOfMemory(const string& what_arg) :runtime_error(what_arg){} ReadFileFail(long pos, const string& what_arg) :errPos(pos),IOException(what_arg) {} const long getErrPos()const{return errPos;} }; #endif 读者可自行扩展合适的派生类,以适合软件开发的具体需要。下面部分例子要使用这些 异常类型。 15.6 异常类型的应用 利用扩展的异常类型,就可对许多已有程序进行改进。例如前面矩阵类模板TMatrix 中,有一个公有成员函数elemAt如下: template T & TMatrix if (r < 0 || r >= row) throw r; //行下标越界,引发int异常 if (c < 0 || c >= col) throw c; //列下标越界,引发int异常 return dp[r][c]; } 原先是引发int类型异常,这容易与其它异常混淆。现在就可以使用更明确的 Index_out_of_range类型的异常。上面2条throw语句就可以分别改为: throw Index_out_of_range(r, "row index in elemAt(int, int)"); throw Index_out_of_range(c, "col index in elemAt(int, int)"); TMatrix operator()(int r, int c)函数也会引发下标越界异常。这些函数都没有显式说明异 常规范: throw(Index_out_of_range) 这是由于VC++6没有对异常规范进行语法检查(Java语言要求明确的异常规范,否则语 法编译出错)。不过规范的设计应该显式说明每个函数的异常规范,以提示调用方可能引发 哪些异常,避免遗忘捕获处理。 例15-5设计一个函数从一个文本文件中读取多个浮点数,放入一个向量vector 中,显示各元素,给出元素的个数,并按升序排序。要读取的文本文件包含任意多的浮点数, 用分隔符分开,例如: 8.9 9.1 10.1 11.2 4.5 5.6 6.7 7.8 0.1 1.2 2.3 3.4 这个例子将演示多种异常类型的引发和处理。编程如下: #include #include #include #include "exceptions.h" using namespace std; void getVectorFromFile(char * filename, vector throw(NullPointer, OpenFileException, ReadFileFail){ if (filename == NULL) throw NullPointer("filename is null"); ::ifstream ifs(filename, ios::in|ios::nocreate); if (!ifs){ string msg = "open file:"; msg += filename; msg += " fail for read"; throw OpenFileException(msg); } float f; while(!()){ ifs >> f; if (()){ string msg = "read file fail:"; msg += filename; throw ReadFileFail((), msg); } _back(f); } (); return; } void main(){ try{ char filename[200]; cout<<"input file name to read:"; cin>>filename; vector getVectorFromFile(filename, vf); cout<<"元素个数:"<<()<<":"< for(int i = 0; i < (); i++) cout<<(i)<< " "; cout< sort((), ()); cout<<"after sortedn"; for(i = 0; i < (); i++) cout<<(i)<< " "; cout< }catch(NullPointer ex){ cout<<()< }catch(OpenFileException ex){ cout<<()< }catch(ReadFileFail ex){ cout<<()<<" at "< }catch(out_of_range ex){ cout<<"index out "<<()< }catch (exception ex){ cout<<()< }catch (...){ cout<<"exception unknown"< } } 函数getVectorFromFile的第一个形参是一个文件名,第二个形参是vector 为结果。该函数可能引发3种命名异常,用异常规范throw说明,以提示调用方。该函数直 接引发3种类型的异常: 1、 检查形参指针是否为空,可能引发NullPointer异常; 2、 打开文件,可能引发OpenFileException异常,保存了出错的文件名; 3、 读浮点数,可能引发ReadFileFail异常,保存了文件读错的位置。 在函数getVectorFromFile执行过程中只要引发任何一种异常,就不能得到结果。 主函数中使用try-catch语句来完成计算并捕获处理各种异常。先输入一个文件名,并 说明一个vector 序、再显示。其中在调用at(i)函数时可能引发out_of_range异常。 执行程序,在第1行输入一个文件名,输出如下: 异常。例如: 用NULL值来调用函数,看是否导致NullPointerException。 输入错误的文件名是否会导致OpenFileException。 把某个浮点数的字符该为字符,看是否导致ReadFileFail。 try子句中的代码描述了正常执行的逻辑,而各种异常的捕获处理都用catch子句描述。 这样就能将正常流程与异常处理分割开,不仅提高了程序的可读性和可维护性,而且增强了 应对多种错误的能力,提高了编程可靠性。 15.7 函数设计中的异常处理 在函数设计中何时要用到异常?有以下3个原则: 1、遇到小概率事件,应考虑使用异常。一种小概率事件往往就是一种异常情形,例如, 函数的指针形参在调用时却得到了空指针实参。再例如,要输入一个浮点数,但实际输入错 误。小概率事件也意味着在可靠性要求不高的前提下可以推迟处理、甚至忽略。前面很多例 子都有这样一个前提,即小概率事件不会发生。反之,如果不是小概率事件,就不适合用异 常。例如,读文件到文件尾eof判断,就不适合将读到文件尾作为一种异常来处理,它不是 小概率事件,因为每一次读取都应判断是否到达文件尾。 2、遇到某种情形,根据当前信息不能确定应该如何处理,应考虑用异常来通知调用方 处理。例如,一个函数从文本文件中读浮点数序列,文件名由形参提供,假如按调用方提供 的文件名打开文件失败,应如何处理?此时合理的办法就是告诉调用方,这个文件名打开失 败了,由调用方来决定是换一个文件名,还是放弃。反之,对于某种情形,如果函数可以处 理而且不违背约定,那么这种情形就不适合作为异常。例如对于堆栈stack操作pop,只有 先判断堆栈不为空,才能弹出pop元素。堆栈为空这种情形不适合作为异常。 3、向调用方报告的某种结果的描述比较复杂,就应考虑使用异常。传统的C语言编程 常用不同的int值来表示各种错误。例如一个函数从文本文件中读浮点数序列,可以让该函 数返回一个int值,而且约定返回0表示正常,-1表示实参空指针,-2表示打开文件失败, -3表示读取数据失败等。但返回-2时,还应告知打开失败的文件名。当返回-3时,不仅要 告知文件名,还应告知导致读取失败的具体位置,即第几个元素读取失败,这样才方便调用 方有效解决问题,此时就需要用异常来详细描述。 在一个函数设计中,要调用一个可能引发某种异常的函数时,有哪些处理方式?当前函 数有下面4种选择: 1、 捕获该异常并进行处理,使自己的调用方不需要捕获处理该异常。 2、 捕获该异常,在处理代码中转换为另一种异常,再引发出去,让调用方来捕获处理 新的异常。 3、 捕获该异常,处理(可能是记录异常发生),再将捕获到的异常引发出去,让外层调 用方来捕获处理。在处理代码中用不带表达式的throw语句可以转发已捕获到的异 常。 4、 不捕获该异常,让外层调用方来捕获处理。可能是没有try-catch语句,也可能有 try-catch但没有catch子句能匹配所发生的异常类型。 应采取何种处理方式取决于当前函数所承担的异常处理的责任。第1种方式完全承担了 该种异常处理的责任,使外层调用方可以放心调用而无需关心会发生此类异常。第4种方式 则完全不承担责任,调用方必须考虑如何处理间接引发的异常。第2种和第3种方式介于两 者之间,承担了部分责任,能捕获处理异常,也能引发异常。 无论采用哪一种方式,函数的异常规范应告知调用方可能会引发哪些类型的异常。这应 该是函数约定的一个重要部分。 例15-6 异常处理流程的例子。 #include #include using namespace std; float getValue(int i){ //A try{ if (i < 0) throw "index is out of range"; //B throw char * if (i == 0) throw 3.14f; //C throw float if (i == 1) throw i; //D throw int if (i == 2) throw 2.718; //E throw double cout<<"in try block, i = "<< i < }catch(int index){ //F catch int cout<<"catch int exception:"< }catch(float f){ cout<<"catch float exception:"< throw; //G throw float }catch(char * msg){ cout<<"catch char* exception:"< throw exception(msg); //H throw exception } cout<<"below try-catch, i = "< return i+1; } void main(){ for(int i = -1; i <= 3; i++){ try{ float f = getValue(i); cout<<"f = "< }catch(float f){ cout<<"main:catch a float exception:"< }catch(double d){ cout<<"main:catch a double exception:"< }catch(exception ex){ cout<<"main:catch an exception:"<<()< }catch(...){ cout<<"catch an exception unknown"< } } } 执行程序,输出如下(行号是为了方便解释而加入的): 1 catch char* exception:index is out of range 2 main:catch an exception:index is out of range 3 catch float exception:3.14 4 main:catch a float exception:3.14 i == 1; 引发int异常,被捕获,没有再引发其它异常出来。 i == 2; 引发double异常,没有被捕获。 这样可以知道getValue函数的异常规范为throw(exception, float, double)。 在main函数中用i==-1到3来调用该函数,并用try-catch语句来捕获所有这些 异常。输出情况如下: i == -1,输出前2行。 i == 0,输出第3、4行。 i == 1,输出第5、6、7行。 i == 2,输出第8行。 i == 3,无异常,输出第9、10、11行。 能否在捕获异常之后再恢复重新执行?一般来说,在执行某条语句引发异常被捕获之 后,不能简单地再从该语句重新执行,这是由于出错的原因往往在于该语句之前所执行的语 句。因此仅仅对引发异常的语句重新执行,一般来说没有实际意义。如果真的要重新执行, 就需要正常的流程控制,而且仅适用于一些可以纠正的错误情形。例如,从键盘输入一个文 件名错误而导致打开文件失败,此时可以重新输入文件名再尝试打开。编程如下: do{ bool retry = false; //缺省为不重复执行 try{ char filename[200]; cout<<"input file name to read:"; cin>>filename; vector getVectorFromFile(filename, vf); //该函数可能引发异常 //... }catch(OpenFileException ex){ //捕获异常 cout<<()< retry = true; //设置重复执行标记 } }while (retry); //用do-while来重复执行 //... 上面代码将一个try-catch语句封装到一个do-while语句之中,再设置一个bool 标记值来控制何时需要重复执行try子句。 在一个函数中当检测到异常,或者捕获到异常,往往采用替代的办法来处理。例如,当 读取一个浮点数失败时,就约定一个缺省值作为结果。当读取一个浮点数矩阵失败时,就约 定一个缺省矩阵作为结果。这种替代法使后面的代码能正常执行。 这种替代法具有一定普遍性,符合事务处理的一般规律。例如,总经理将一项任务交付 给一个部门经理来完成,部门经理再将该任务的一部分交付给员工A来完成,期望该员工 能正常完成。但如果员工A遇到不能解决的问题而告知部门经理,此时部门经理就可以选 择另换一名员工B来完成,或者自己来完成。无论那一种替代,总经理的任务总能完成, 而无需关心该任务是如何完成的。 15.8 异常可能导致内存泄露 在一个函数执行过程中,因引发一个异常而导致控制流转移到外层作用域。此时当前作 用域中可能已创建有若干局部对象,那么这些局部对象将被自动撤销,这些对象的析构函数 会被自动调用执行,而且是在该异常被捕获之前。 当函数执行中用new创建一个对象之后,在用delete撤销该对象之前引发了异常,而 且控制流离开了当前函数,此时动态对象就不能自动回收,造成内存泄漏。这是因为指向动 态对象的指针作为该函数的局部变量在栈中被清除了。因此在一个函数中,在执行throw语 句之前,应该先用delete撤销先前创建的动态对象,以防止内存泄漏。但如果调用一个可能 引发异常的函数,要避免内存泄漏就比较困难了。 例15-7 虽能捕获异常,但导致内存泄漏。 #include #include double getSqrt(double * d){ if (*d < 0) throw *d; return sqrt(*d); } void main(){ try{ double * d = new double(-3.4); double d2 = getSqrt(d); //A 可能引发异常 delete d; //B 如果A引发异常,此行不执行 }catch(double d){ cout<<"d < 0:"< } } 如果A行引发了异常,B行delete语句就不能执行,造成内存泄漏,根本原因是控制流 离开了当前动态对象指针所在的作用域。 无论一个函数是正常返回,还是异常退出,其内部创建的动态对象都应清理干净,才能 避免内存泄漏。解决此问题的一种方法是,用一个函数来取代new运算符,将指向动态对 象的指针保存在一个特别设计的堆栈中(可以称之为清除栈clearup stack)。当前函数中所有 动态创建的对象的指针都保存在一块连续空间中。在函数执行中,如果发生异常而要退出当 前函数,就将此空间中所有未回收的动态对象都用delete回收,然后再将控制流转向异常捕 获。这样即便程序终止也能避免内存泄漏。在Symbian C++中(一种智能手机开发平台)就使 用这样的清除栈clearup stack方法来防止内存泄漏,这是因为手机内存极其有限。 15.9 构造与析构中的异常 构造函数和析构函数执行中也可能引发异常。 如果在构造函数执行中引发了一个异常,此时构造函数没有执行完成,对象还没有构造 完成,因此对此对象不执行析构函数。如果在引发异常之前已构造完成了一部分基类子对象 或者成员对象,那么这些基类子对象或成员对象将执行析构函数,而未构造完成的对象就不 会执行析构函数。例如下面一个类: class StudentScore{ int num; string name; float score; public: StudentScore(int num, string name, float score) :num(num), name(name), score(score){} //... } 该类的构造函数没有能保证学号/编号num和分数score都为正数值,因为这些属性持 有负数值在现实中是十分荒唐的错误。另外,学生的姓名name如果少于2个汉字也是不正 常的。因此这个构造函数需要改进。 例15-8 构造函数引发异常。 #include #include #include "exceptions.h" using namespace std; class StudentScore{ int num; string name; float score; public: StudentScore(int num, string name, float score) throw(invalid_argument){ if (num < 0) throw invalid_argument("num < 0"); else this->num = num; if (() < 4) throw invalid_argument("name length < 4"); else this->name = name; if (score < 0) throw invalid_argument("score < 0"); else this->score = score; } ~StudentScore(){ cout<<"destructor for num = "< } int getNum()const{return num;} string getName()const{return name;} float getScore()const{return score;} void setScore(float newscore){score = newscore;} }; ostream & operator<<(ostream &os, const StudentScore &ss){ os<<()<<"t"< <<"t"< return os; } void main(){ try{ StudentScore ss1(1, "张三",92.5); StudentScore *ss2 = new StudentScore(2, "李四", 87); if (ss2 == NULL) throw OutOfMemory("when create num = 2"); StudentScore ss3(3, "王五",-2); //引发异常 cout< delete ss2; }catch(invalid_argument ex){ cout<<"invalid argument:"<<()< 应设为私有,而且不再引发异常。 如果在析构函数执行中引发了一个异常,当前对象就不能再被安全地访问了,即使捕获 了异常。在析构函数中引发异常可能导致底层错误而被迫终止程序,即使能捕获异常,也可 能造成内存泄露,因此建议不要用引发异常来阻止撤销对象。 在撤销某些对象时往往有特定条件,比如要撤销一个客户,该客户还有若干账户对象要 依赖于它,那么该客户对象就不能被随便撤销。往往建立一个静态成员函数来撤销对象,先 检查是否满足特定条件,如果满足再调用析构函数。如果不满足条件,可以引发异常,也可 以不引发异常而返回约定的值,表示不同的结果。 在创建一个对象数组时如果引发异常,只对已构建完成的元素执行析构函数。 小 结 异常exception就是在程序运行过程中所发生的、难以预料的、不正常的事件,而 导致偏离正常流程。异常处理exception handling就是在运行时刻对异常进行检测、 捕获、提示、传递等过程。 C++提供了引发异常的语句throw和捕获处理异常的语句try-catch。 在运行时刻用throw引发的每个异常都可以描述为一个对象或一个值。在编译时 刻,每一种异常都可以描述为一种类型,可能是自定义的类,也可能是简单的整数 或字符串。 利用类的继承性建立一个异常类型的架构,对所处理的各种错误情形进行抽象和归 类;方便扩展新的异常类型;在编程中方便选取正确的异常类型,也方便按类型来 捕获处理异常。 误,类runtime_error表示运行期错误,各自定义了一组派生类表示具体的异常。 引发异常要使用throw语句,其作用是中止当前控制流,转向该异常的捕获程序。 可能到外层作用域,也可能到函数的调用方。如果该异常一直到main函数都没有 被捕获,就要强制终止程序。 一条语句执行引发异常,有以下3种可能的情形: 本身就是throw语句。 调用函数引发了异常。 表达式执行引发了底层异常,如整数除数为0、挂空访问等。 捕获异常的语句是try-catch,由一个try子句(一条复合语句,称为受保护代码)和 多个catch子句组成。一个catch子句包括一个异常类型及变量和一个异常处理器(一 条复合语句)。 如果受保护代码执行没有发生异常,try-catch语句就正常结束。 如果受保护代码引发了某种类型的异常,就按catch子句顺序逐个匹配异常类型, 捕获并处理该异常。 如果异常被捕获,而且处理过程中未引发新的异常,try-catch语句就正常结束。 如果异常未被捕获,该异常就被引发到try-catch语句的外层作用域。 异常是按其类型进行捕获处理的。一个catch子句仅捕获一类异常。注意,基类的 catch子句不能排在派生类之前。 有一种特殊的catch子句catch(...),该子句能匹配任何类型的异常,包括底层异常。 一个异常的生命期从创建、初始化开始,被throw引发出来,然后被某个catch子 如果异常未能被捕获,程序将被迫终止。在终止之前,系统将自动调用函数 在函数设计中需要使用异常的情形:(1)处理小概率事件;(2)当前不能确定如何处 在引发异常之前,应注意先将动态创建的局部对象先回收,否则将导致内存泄露。 避免在构造函数和析构函数中直接或间接地引发异常。 句捕获,其生命期就结束了,从引发到捕获可能穿越多层作用域或函数调用。 terminate(),来清理系统资源,然后终止程序。该函数缺省调用abort()函数,但用 户可定义一个函数来取代。 理而要通知调用方;(3)要告知的情形描述比较复杂,往往需要一个对象。 练 习 题 1. 对于关键词throw,下面哪一种说法是错误的? A 函数中throw语句用来引发异常到当前作用域之外。 B 函数原型中throw用来说明该函数可能引发哪些类型的异常。 C 用throw语句引发的异常,一定要用try-catch语句来捕获,否则语法错误。 D throw语句的语法形式与return语句一样。 2. 如果一条语句执行引发了异常,那么可能的原因不包括下面哪一种情形? A 该语句是throw语句。 B 该语句中调用的函数引发了异常。 C 该语句中的表达式计算引发了未命名的异常。 D 该语句外层没有try-catch语句。 3. 对于一条try-catch语句,下面哪一种说法是错误的? A try子句中的代码可以不引发任何异常。 B try子句中的代码所引发的异常,一定要被某个catch子句捕获,否则语法出错。 C 多个catch子句所捕获的异常,不能出现基类在前,而派生类在后的情形。 D catch(...)应该是最后一个catch子句。 4. 对于一条try-catch语句的执行结果,下面哪一种说法是错误的? A try子句执行没有引发任何异常,try-catch下面的语句继续执行。 B try子句引发了一个异常,该异常被某个catch子句捕获并处理,try-catch下面的语句继续执行。 C try子句引发了一个异常,但没有被任何一个catch子句捕获,此时try-catch语句引发异常,下面 语句不能继续执行。 D 在catch子句中不能用throw语句引发异常。 5. 假设Index_out_of_range是out_of_range的派生类,out_of_range是exception的派生类,给出下面 程序运行结果。 #include #include "exceptions.h" double getValue(int index){ if (index < 0 || index > 9) throw Index_out_of_range(index, "valid range is [0..9]"); return index; } void main(){ try{ double d = getValue(10); cout<<"d="< }catch(int index){ cout<<"index is out of range:"< }catch(out_of_range ex){ cout<<"catch a out_of_range:"<<()< }catch(exception ex){ cout<<"catch an exception:"<<()< }catch(...){ cout<<"catch an exception unknown"< } } 6. 给出下面程序的执行结果,分析程序中有哪些语句不可能执行,分析前3个catch子句改变次序是 否会影响执行结果。 #include void expTest(int i){ try{ if (i == 1) throw "catch me when i == 1"; if (i == 2) throw i; if (i == 3) throw 3.14; if (i == 0){ int d = (i+1) / i; cout< } cout<<"i="< }catch(double d){ cout<<"catch a double:"< }catch(int i){ cout<<"catch an int: "< }catch(char * ex){ cout<<"catch a string:"< }catch(...){ cout<<"catch an exception unknown"; } cout<<"tfunction returnn"; } void main(){ expTest(0); expTest(1); expTest(2); expTest(3); expTest(4); } 7. 以IOException类为基类,扩展一个派生类表示矩阵读写错误,记录出错的行和列。编程如 下:


发布评论