- 内容提要
- 前言
- 第 1 章 预备知识
- 第 2 章 开始学习 C++
- 第 3 章 处理数据
- 第 4 章 复合类型
- 第 5 章 循环和关系表达式
- 第 6 章 分支语句和逻辑运算符
- 第 7 章 函数——C++的编程模块
- 第 8 章 函数探幽
- 第 9 章 内存模型和名称空间
- 第 10 章 对象和类
- 第 11 章 使用类
- 第 12 章 类和动态内存分配
- 第 13 章 类继承
- 第 14 章 C++中的代码重用
- 第 15 章 友元、异常和其他
- 第 16 章 string 类和标准模板库
- 第 17 章 输入、输出和文件
- 第 18 章 探讨 C++新标准
- 附录 A 计数系统
- 附录 B C++保留字
- 附录 C ASCII 字符集
- 附录 D 运算符优先级
- 附录 E 其他运算符
- 附录 F 模板类 string
- 附录 G 标准模板库方法和函数
- 附录 H 精选读物和网上资源
- 附录 I 转换为 ISO 标准 C++
- 附录 J 复习题答案
15.3 异常
程序有时会遇到运行阶段错误,导致程序无法正常地运行下去。例如,程序可能试图打开一个不可用的文件,请求过多的内存,或者遭遇不能容忍的值。通常,程序员都会试图预防这种意外情况。C++异常为处理这种情况提供了一种功能强大而灵活的工具。异常是相对较新的 C++功能,有些老式编译器可能没有实现。另外,有些编译器默认关闭这种特性,您可能需要使用编译器选项来启用它。
讨论异常之前,先来看看程序员可使用的一些基本方法。作为试验,以一个计算两个数的调和平均数的函数为例。两个数的调和平均数的定义是:这两个数字倒数的平均值的倒数,因此表达式为:
如果 y 是 x 的负值,则上述公式将导致被零除——一种不允许的运算。对于被零除的情况,很多新式编译器通过生成一个表示无穷大的特殊浮点值来处理,cout 将这种值显示为 Inf、inf、INF 或类似的东西;而其他的编译器可能生成在发生被零除时崩溃的程序。最好编写在所有系统上都以相同的受控方式运行的代码。
15.3.1 调用 abort( )
对于这种问题,处理方式之一是,如果其中一个参数是另一个参数的负值,则调用 abort( ) 函数。Abort( ) 函数的原型位于头文件 cstdlib(或 stdlib.h)中,其典型实现是向标准错误流(即 cerr 使用的错误流)发送消息 abnormal program termination(程序异常终止),然后终止程序。它还返回一个随实现而异的值,告诉操作系统(如果程序是由另一个程序调用的,则告诉父进程),处理失败。abort( ) 是否刷新文件缓冲区(用于存储读写到文件中的数据的内存区域)取决于实现。如果愿意,也可以使用 exit( ),该函数刷新文件缓冲区,但不显示消息。程序清单 15.7 是一个使用 abort( ) 的小程序。
程序清单 15.7 error1.cpp
程序清单 15.7 中程序的运行情况如下:
注意,在 hmean( ) 中调用 abort( ) 函数将直接终止程序,而不是先返回到 main( )。一般而言,显示的程序异常中断消息随编译器而异,下面是另一种编译器显示的消息:
为了避免异常终止,程序应在调用 hmean( ) 函数之前检查 x 和 y 的值。然而,依靠程序员来执行这种检查是不安全的。
15.3.2 返回错误码
一种比异常终止更灵活的方法是,使用函数的返回值来指出问题。例如,ostream 类的 get(void)成员通常返回下一个输入字符的 ASCII 码,但到达文件尾时,将返回特殊值 EOF。对 hmean( ) 来说,这种方法不管用。任何数值都是有效的返回值,因此不存在可用于指出问题的特殊值。在这种情况下,可使用指针参数或引用参数来将值返回给调用程序,并使用函数的返回值来指出成功还是失败。istream 族重载>>运算符使用了这种技术的变体。通过告知调用程序是成功了还是失败了,使得程序可以采取除异常终止程序之外的其他措施。程序清单 15.8 是一个采用这种方式的示例,它将 hmean( ) 的返回值重新定义为 bool,让返回值指出成功了还是失败了,另外还给该函数增加了第三个参数,用于提供答案。
程序清单 15.8 error2.cpp
程序清单 15.8 中程序的运行情况如下:
程序说明
在程序清单 15.8 中,程序设计避免了错误输入导致的恶果,让用户能够继续输入。当然,设计确实依靠用户检查函数的返回值,这项工作是程序员所不经常做的。例如,为使程序短小精悍,本书的程序清单都没有检查 cout 是否成功地处理了输出。
第三参数可以是指针或引用。对内置类型的参数,很多程序员都倾向于使用指针,因为这样可以明显看出是哪个参数用于提供答案。
另一种在某个地方存储返回条件的方法是使用一个全局变量。可能问题的函数可以在出现问题时将该全局变量设置为特定的值,而调用程序可以检查该变量。传统的 C 语言数学库使用的就是这种方法,它使用的全局变量名为 errno。当然,必须确保其他函数没有将该全局变量用于其他目的。
15.3.3 异常机制
下面介绍如何使用异常机制来处理错误。C++异常是对程序运行过程中发生的异常情况(例如被 0 除)的一种响应。异常提供了将控制权从程序的一个部分传递到另一部分的途径。对异常的处理有 3 个组成部分:
- 引发异常;
- 使用处理程序捕获异常;
- 使用 try 块。
程序在出现问题时将引发异常。例如,可以修改程序清单 15.7 中的 hmean( ),使之引发异常,而不是调用 abort( ) 函数。throw 语句实际上是跳转,即命令程序跳到另一条语句。throw 关键字表示引发异常,紧随其后的值(例如字符串或对象)指出了异常的特征。
程序使用异常处理程序(exception handler)来捕获异常,异常处理程序位于要处理问题的程序中。catch 关键字表示捕获异常。处理程序以关键字 catch 开头,随后是位于括号中的类型声明,它指出了异常处理程序要响应的异常类型;然后是一个用花括号括起的代码块,指出要采取的措施。catch 关键字和异常类型用作标签,指出当异常被引发时,程序应跳到这个位置执行。异常处理程序也被称为 catch 块。
try 块标识其中特定的异常可能被激活的代码块,它后面跟一个或多个 catch 块。try 块是由关键字 try 指示的,关键字 try 的后面是一个由花括号括起的代码块,表明需要注意这些代码引发的异常。
要了解这 3 个元素是如何协同工作的,最简单的方法是看一个简短的例子,如程序清单 15.9 所示。
程序清单 15.9 error3.cpp
程序清单 15.9 中程序的运行情况如下:
程序说明
在程序清单 15.9 中,try 块与下面类似:
如果其中的某条语句导致异常被引发,则后面的 catch 块将对异常进行处理。如果程序在 try 块的外面调用 hmean( ),将无法处理异常。
引发异常的代码与下面类似:
其中被引发的异常是字符串“bad hmean( )arguments: a = -b not allowed”。异常类型可以是字符串(就像这个例子中那样)或其他 C++类型;通常为类类型,本章后面的示例将说明这一点。
执行 throw 语句类似于执行返回语句,因为它也将终止函数的执行;但 throw 不是将控制权返回给调用程序,而是导致程序沿函数调用序列后退,直到找到包含 try 块的函数。在程序清单 15.9 中,该函数是调用函数。稍后将有一个沿函数调用序列后退多步的例子。另外,在这个例子中,throw 将程序控制权返回给 main( )。程序将在 main( ) 中寻找与引发的异常类型匹配的异常处理程序(位于 try 块的后面)。
处理程序(或 catch 块)与下面类似:
catch 块点类似于函数定义,但并不是函数定义。关键字 catch 表明这是一个处理程序,而 char*s 则表明该处理程序与字符串异常匹配。s 与函数参数定义极其类似,因为匹配的引发将被赋给 s。另外,当异常与该处理程序匹配时,程序将执行括号中的代码。
执行完 try 块中的语句后,如果没有引发任何异常,则程序跳过 try 块后面的 catch 块,直接执行处理程序后面的第一条语句。因此处理值 3 和 6 时,程序清单 15.9 中程序执行报告结果的输出语句。
接下来看将 10 和−10 传递给 hmean( ) 函数后发生的情况。If 语句导致 hmean( ) 引发异常。这将终止 hmean( ) 的执行。程序向后搜索时发现,hmean( ) 函数是从 main( ) 中的 try 块中调用的,因此程序查找与异常类型匹配的 catch 块。程序中唯一的一个 catch 块的参数为 char*,因此它与引发异常匹配。程序将字符串“bad hmean( )arguments: a = -b not allowed”赋给变量 s,然后执行处理程序中的代码。处理程序首先打印 s——捕获的异常,然后打印要求用户输入新数据的指示,最后执行 continue 语句,命令程序跳过 while 循环的剩余部分,跳到起始位置。continue 使程序跳到循环的起始处,这表明处理程序语句是循环的一部分,而 catch 行是指引程序流程的标签(参见图 15.2)。
图 15.2 出现异常时的程序流程
您可能会问,如果函数引发了异常,而没有 try 块或没有匹配的处理程序时,将会发生什么情况。在默认情况下下,程序最终将调用 abort( ) 函数,但可以修改这种行为。稍后将讨论这个问题。
15.3.4 将对象用作异常类型
通常,引发异常的函数将传递一个对象。这样做的重要优点之一是,可以使用不同的异常类型来区分不同的函数在不同情况下引发的异常。另外,对象可以携带信息,程序员可以根据这些信息来确定引发异常的原因。同时,catch 块可以根据这些信息来决定采取什么样的措施。例如,下面是针对函数 hmean( ) 引发的异常而提供的一种设计:
可以将一个 bad_hmean 对象初始化为传递给函数 hmean( ) 的值,而方法 mesg( ) 可用于报告问题(包括传递给函数 hmena( ) 的值)。函数 hmean( ) 可以使用下面这样的代码:
上述代码调用构造函数 bad_hmean( ),以初始化对象,使其存储参数值。
程序清单 15.10 和 15.11 添加了另一个异常类 bad_gmean 以及另一个名为 gmean( ) 的函数,该函数引发 bad_gmean 异常。函数 gmean( ) 计算两个数的几何平均值,即乘积的平方根。这个函数要求两个参数都不为负,如果参数为负,它将引发异常。程序清单 15.10 是一个头文件,其中包含异常类的定义;而程序清单 15.11 是一个示例程序,它使用了该头文件。注意,try 块的后面跟着两个 catch 块:
如果函数 hmean( ) 引发 bad_hmean 异常,第一个 catch 块将捕获该异常;如果 gmean( ) 引发 bad_gmean 异常,异常将逃过第一个 catch 块,被第二个 catch 块捕获。
程序清单 15.10 exc_mean.h
程序清单 15.11 error4.cpp
下面是程序清单 15.10 和 15.11 组成的程序的运行情况,错误的 gmean( ) 函数输入导致程序终止:
首先,bad_hmean 异常处理程序使用了一条 continue 语句,而 bad_gmean 异常处理程序使用了一条 break 语句。因此,如果用户给函数 hmean( ) 提供的参数不正确,将导致程序跳过循环中余下的代码,进入下一次循环;而用户给函数 gmean( ) 提供的参数不正确时将结束循环。这演示了程序如何确定引发的异常(根据异常类型)并据此采取相应的措施。
其次,异常类 bad_gmean 和 bad_hmean 使用的技术不同,具体地说,bad_gmean 使用的是公有数据和一个公有方法,该方法返回一个 C-风格字符串。
15.3.5 异常规范和 C++11
有时候,一种理念看似有前途,但实际的使用效果并不好。一个这样的例子是异常规范(exception specification),这是 C++98 新增的一项功能,但 C++11 却将其摒弃了。这意味着 C++11 仍然处于标准之中,但以后可能会从标准中剔除,因此不建议您使用它。
然而,忽视异常规范前,您至少应该知道它是什么样的,如下所示:
其中的 throw( ) 部分就是异常规范,它可能出现在函数原型和函数定义中,可包含类型列表,也可不包含。
异常规范的作用之一是,告诉用户可能需要使用 try 块。然而,这项工作也可使用注释轻松地完成。异常规范的另一个作用是,让编译器添加执行运行阶段检查的代码,检查是否违反了异常规范。这很难检查。例如,marm( ) 可能不会引发异常,但它可能调用一个函数,而这个函数调用的另一个函数引发了异常。另外,您给函数编写代码时它不会引发异常,但库更新后它却会引发异常。总之,编程社区(尤其是尽力编写安全代码的开发人员)达成的一致意见是,最好不要使用这项功能。而 C++11 也建议您忽略异常规范。
然而,C++11 确实支持一种特殊的异常规范:您可使用新增的关键字 noexcept 指出函数不会引发异常:
有关这种异常规范是否必要和有用存在一些争议,有些人认为最好不要使用它(至少在大多数情况下如此);而有些人认为引入这个新关键字很有必要,理由是知道函数不会引发异常有助于编译器优化代码。通过使用这个关键字,编写函数的程序员相当于做出了承诺。
还有运算符 noexcept( ),它判断其操作数是否会引发异常,详情请参阅附录 E。
15.3.6 栈解退
假设 try 块没有直接调用引发异常的函数,而是调用了对引发异常的函数进行调用的函数,则程序流程将从引发异常的函数跳到包含 try 块和处理程序的函数。这涉及到栈解退(unwinding the stack),下面进行介绍。
首先来看一看 C++通常是如何处理函数调用和返回的。C++通常通过将信息放在栈(参见第 9 章)中来处理函数调用。具体地说,程序将调用函数的指令的地址(返回地址)放到栈中。当被调用的函数执行完毕后,程序将使用该地址来确定从哪里开始继续执行。另外,函数调用将函数参数放到栈中。在栈中,这些函数参数被视为自动变量。如果被调用的函数创建了新的自动变量,则这些变量也将被添加到栈中。如果被调用的函数调用了另一个函数,则后者的信息将被添加到栈中,依此类推。当函数结束时,程序流程将跳到该函数被调用时存储的地址处,同时栈顶的元素被释放。因此,函数通常都返回到调用它的函数,依此类推,同时每个函数都在结束时释放其自动变量。如果自动变量是类对象,则类的析构函数(如果有的话)将被调用。
现在假设函数由于出现异常(而不是由于返回)而终止,则程序也将释放栈中的内存,但不会在释放栈的第一个返回地址后停止,而是继续释放栈,直到找到一个位于 try 块(参见图 15.3)中的返回地址。随后,控制权将转到块尾的异常处理程序,而不是函数调用后面的第一条语句。这个过程被称为栈解退。引发机制的一个非常重要的特性是,和函数返回一样,对于栈中的自动类对象,类的析构函数将被调用。然而,函数返回仅仅处理该函数放在栈中的对象,而 throw 语句则处理 try 块和 throw 之间整个函数调用序列放在栈中的对象。如果没有栈解退这种特性,则引发异常后,对于中间函数调用放在栈中的自动类对象,其析构函数将不会被调用。
图 15.3 throw 与 return
程序清单 15.12 是一个栈解退的示例。其中,main( ) 调用了 means( ),而 means( ) 又调用了 hmean( ) 和 gmean( )。函数 means( ) 计算算术平均数、调和平均数和几何平均数。main( ) 和 means( ) 都创建 demo 类型的对象(demo 是一个喋喋不休的类,指出什么时候构造函数和析构函数被调用),以便您知道发生异常时这些对象将被如何处理。函数 main( ) 中的 try 块能够捕获 bad_hmean 和 badgmean 异常,而函数 means( ) 中的 try 块只能捕获 bad_hmean 异常。catch 块的代码如下:
上述代码显示消息后,重新引发异常,这将向上把异常发送给 main( ) 函数。一般而言,重新引发的异常将由下一个捕获这种异常的 try-catch 块组合进行处理,如果没有找到这样的处理程序,默认情况下程序将异常终止。程序清单 15.12 使用的头文件与程序清单 15.11 使用的相同(程序清单 15.10 所示的 exc_mean.h)。
程序清单 15.12 error5.cpp
下面是程序清单 15.10 和程序清单 15.12 组成的程序的运行情况:
程序说明
来看看该程序的运行过程。首先,正如 demo 类的构造函数指出的,在 main( ) 函数中创建了一个 demo 对象。接下来,调用了函数 means( ),它创建了另一个 demo 对象。函数 means( ) 使用 6 和 2 来调用函数 hmean( ) 和 gmean( ),它们将结果返回给 means( ),后者计算一个结果并将其返回。返回结果前,means( ) 调用了 d2.show( );返回结果后,函数 means( ) 执行完毕,因此自动为 d2 调用析构函数:
接下来的输入循环将值 6 和−6 发送给函数 means( ),然后 means( ) 创建一个新的 demo 对象,并将值传递给 hmean( )。函数 hmean( ) 引发 bad_hmean 异常,该异常被 means( ) 中的 catch 块捕获,下面的输出指出了这一点:
该 catch 块中的 throw 语句导致函数 means( ) 终止执行,并将异常传递给 main( ) 函数。语句 d2.show( ) 没有被执行表明 means( ) 函数被提前终止。但需要指出的是,还是为 d2 调用了析构函数:
这演示了异常极其重要的一点:程序进行栈解退以回到能够捕获异常的地方时,将释放栈中的自动存储型变量。如果变量是类对象,将为该对象调用析构函数。
与此同时,重新引发的异常被传递给 main( ),在该函数中,合适的 catch 块将捕获它并对其进行处理:
接下来开始了第三次输入循环:6 和−8 被发送给函数 means( )。同样,means( ) 创建一个新的 demo 对象,然后将 6 和−8 传递给 hmean( ),后者在处理它们时没有出现问题。然而,means( ) 将 6 和−8 传递给 gmean( ),后者引发了 bad_gmean 异常。由于 means( ) 不能捕获 bad_gmean 异常,因此异常被传递给 main( ),同时不再执行 means( ) 中的其他代码。同样,当程序进行栈解退时,将释放局部的动态变量,因此为 d2 调用了析构函数:
最后,main( ) 中的 bad_gmean 异常处理程序捕获了该异常,循环结束:
然后程序正常终止:显示一些消息并自动为 d1 调用析构函数。如果 catch 块使用的是 exit(EXIT_FAIL URE) 而不是 break,则程序将立刻终止,用户将看不到下述消息:
但仍能够看到如下消息:
同样,异常机制将负责释放栈中的自动变量。
15.3.7 其他异常特性
虽然 throw-catch 机制类似于函数参数和函数返回机制,但还是有些不同之处。其中之一是函数 fun( ) 中的返回语句将控制权返回到调用 fun( ) 的函数,但 throw 语句将控制权向上返回到第一个这样的函数:包含能够捕获相应异常的 try-catch 组合。例如,在程序清单 15.12 中,当函数 hmeans( ) 引发异常时,控制权将传递给函数 means( );然而,当 gmean( ) 引发异常时,控制权将向上传递到 main( )。
另一个不同之处是,引发异常时编译器总是创建一个临时拷贝,即使异常规范和 catch 块中指定的是引用。例如,请看下面的代码:
p 将指向 oops 的副本而不是 oops 本身。这是件好事,因为函数 super( ) 执行完毕后,oops 将不复存在。顺便说一句,将引发异常和创建对象组合在一起将更简单:
您可能会问,既然 throw 语句将生成副本,为何代码中使用引用呢?毕竟,将引用作为返回值的通常原因是避免创建副本以提高效率。答案是,引用还有另一个重要特征:基类引用可以执行派生类对象。假设有一组通过继承关联起来的异常类型,则在异常规范中只需列出一个基类引用,它将与任何派生类对象匹配。
假设有一个异常类层次结构,并要分别处理不同的异常类型,则使用基类引用将能够捕获任何异常对象;而使用派生类对象只能捕获它所属类及从这个类派生而来的类的对象。引发的异常对象将被第一个与之匹配的 catch 块捕获。这意味着 catch 块的排列顺序应该与派生顺序相反:
如果将 bad_1 &处理程序放在最前面,它将捕获异常 bad_1、bad_2 和 bad_3;通过按相反的顺序排列,bad_3 异常将被 bad_3 &处理程序所捕获。
提示:
如果有一个异常类继承层次结构,应这样排列 catch 块:将捕获位于层次结构最下面的异常类的 catch 语句放在最前面,将捕获基类异常的 catch 语句放在最后面。
通过正确地排列 catch 块的顺序,让您能够在如何处理异常方面有选择的余地。然而,有时候可能不知道会发生哪些异常。例如,假设您编写了一个调用另一个函数的函数,而您并不知道被调用的函数可能引发哪些异常。在这种情况下,仍能够捕获异常,即使不知道异常的类型。方法是使用省略号来表示异常类型,从而捕获任何异常:
如果知道一些可能会引发的异常,可以将上述捕获所有异常的 catch 块放在最后面,这有点类似于 switch 语句中的 default:
可以创建捕获对象而不是引用的处理程序。在 catch 语句中使用基类对象时,将捕获所有的派生类对象,但派生特性将被剥去,因此将使用虚方法的基类版本。
15.3.8 exception 类
C++异常的主要目的是为设计容错程序提供语言级支持,即异常使得在程序设计中包含错误处理功能更容易,以免事后采取一些严格的错误处理方式。异常的灵活性和相对方便性激励着程序员在条件允许的情况下在程序设计中加入错误处理功能。总之,异常是这样一种特性:类似于类,可以改变您的编程方式。
较新的 C++编译器将异常合并到语言中。例如,为支持该语言,exception 头文件(以前为 exception.h 或 except.h)定义了 exception 类,C++可以把它用作其他异常类的基类。代码可以引发 exception 异常,也可以将 exception 类用作基类。有一个名为 what( ) 的虚拟成员函数,它返回一个字符串,该字符串的特征随实现而异。然而,由于这是一个虚方法,因此可以在从 exception 派生而来的类中重新定义它:
如果不想以不同的方式处理这些派生而来的异常,可以在同一个基类处理程序中捕获它们:
否则,可以分别捕获它们。
C++库定义了很多基于 exception 的异常类型。
1.stdexcept 异常类
头文件 stdexcept 定义了其他几个异常类。首先,该文件定义了 logic_error 和 runtime_error 类,它们都是以公有方式从 exception 派生而来的:
注意,这些类的构造函数接受一个 string 对象作为参数,该参数提供了方法 what( ) 以 C-风格字符串方式返回的字符数据。
这两个新类被用作两个派生类系列的基类。异常类系列 logic_error 描述了典型的逻辑错误。总体而言,通过合理的编程可以避免这种错误,但实际上这些错误还是可能发生的。每个类的名称指出了它用于报告的错误类型:
- domain_error;
- invalid_argument;
- length_error;
- out_of_bounds。
每个类独有一个类似于 logic_error 的构造函数,让您能够提供一个供方法 what( ) 返回的字符串。
数学函数有定义域(domain)和值域(range)。定义域由参数的可能取值组成,值域由函数可能的返回值组成。例如,正弦函数的定义域为负无穷大到正无穷大,因为任何实数都有正弦值;但正弦函数的值域为−1 到+1,因为它们分别是最大和最小正弦值。另一方面,反正弦函数的定义域为−1 到+1,值域为−π到+ π。如果您编写一个函数,该函数将一个参数传递给函数 std::sin( ),则可以让该函数在参数不在定义域−1 到+1 之间时引发 domain_error 异常。
异常 invalid_argument 指出给函数传递了一个意料外的值。例如,如果函数希望接受一个这样的字符串:其中每个字符要么是‘0’要么是‘1’,则当传递的字符串中包含其他字符时,该函数将引发 invalid_argument 异常。
异常 length_error 用于指出没有足够的空间来执行所需的操作。例如,string 类的 append( ) 方法在合并得到的字符串长度超过最大允许长度时,将引发 length_error 异常。
异常 out_of_bounds 通常用于指示索引错误。例如,您可以定义一个类似于数组的类,其 operator( ) [ ]在使用的索引无效时引发 out_of_bounds 异常。
接下来,runtime_error 异常系列描述了可能在运行期间发生但难以预计和防范的错误。每个类的名称指出了它用于报告的错误类型:
- range_error;
- overflow_error;
- underflow_error。
每个类独有一个类似于 runtime_error 的构造函数,让您能够提供一个供方法 what( ) 返回的字符串。
下溢(underflow)错误在浮点数计算中。一般而言,存在浮点类型可以表示的最小非零值,计算结果比这个值还小时将导致下溢错误。整型和浮点型都可能发生上溢错误,当计算结果超过了某种类型能够表示的最大数量级时,将发生上溢错误。计算结果可能不再函数允许的范围之内,但没有发生上溢或下溢错误,在这种情况下,可以使用 range_error 异常。
一般而言,logic_error 系列异常表明存在可以通过编程修复的问题,而 runtime_error 系列异常表明存在无法避免的问题。所有这些错误类有相同的常规特征,它们之间的主要区别在于:不同的类名让您能够分别处理每种异常。另一方面,继承关系让您能够一起处理它们(如果您愿意的话)。例如,下面的代码首先单独捕获 out_of_bounds 异常,然后统一捕获其他 logic_error 系列异常,最后统一捕获 exception 异常、runtime_error 系列异常以及其他从 exception 派生而来的异常:
如果上述库类不能满足您的需求,应该从 logic_error 或 runtime_error 派生一个异常类,以确保您异常类可归入同一个继承层次结构中。
2.bad_alloc 异常和 new
对于使用 new 导致的内存分配问题,C++的最新处理方式是让 new 引发 bad_alloc 异常。头文件 new 包含 bad_alloc 类的声明,它是从 exception 类公有派生而来的。但在以前,当无法分配请求的内存量时,new 返回一个空指针。
程序清单 15.13 演示了最新的方法。捕获到异常后,程序将显示继承的 what( ) 方法返回的消息(该消息随实现而异),然后终止。
程序清单 15.13 newexcp.cpp
下面该程序在某个系统中的输出:
在这里,方法 what( ) 返回字符串“std::bad_alloc”。
如果程序在您的系统上运行时没有出现内存分配问题,可尝试提高请求分配的内存量。
3.空指针和 new
很多代码都是在 new 在失败时返回空指针时编写的。为处理 new 的变化,有些编译器提供了一个标记(开关),让用户选择所需的行为。当前,C++标准提供了一种在失败时返回空指针的 new,其用法如下:
使用这种 new,可将程序清单 15.13 的核心代码改为如下所示:
15.3.9 异常、类和继承
异常、类和继承以三种方式相互关联。首先,可以像标准 C++库所做的那样,从一个异常类派生出另一个;其次,可以在类定义中嵌套异常类声明来组合异常;第三,这种嵌套声明本身可被继承,还可用作基类。
程序清单 15.14 带领我们开始了上述一些可能性的探索之旅。这个头文件声明了一个 Sales 类,它用于存储一个年份以及一个包含 12 个月的销售数据的数组。LabeledSales 类是从 Sales 派生而来的,新增了一个用于存储数据标签的成员。
程序清单 15.14 sales.h
来看一下程序清单 15.14 的几个细节。首先,符号常量 MONTHS 位于 Sales 类的保护部分,这使得派生类(如 LabeledSales)能够使用这个值。
接下来,bad_index 被嵌套在 Sales 类的公有部分中,这使得客户类的 catch 块可以使用这个类作为类型。注意,在外部使用这个类型时,需要使用 Sales::bad_index 来标识。这个类是从 logic_error 类派生而来的,能够存储和报告数组索引的超界值(out-of-bounds value)。
nbad_index 类被嵌套到 LabeledSales 的公有部分,这使得客户类可以通过 LabeledSales::nbad_index 来使用它。它是从 bad_index 类派生而来的,新增了存储和报告 LabeledSales 对象的标签的功能。由于 bad_index 是从 logic_error 派生而来的,因此 nbad_index 归根结底也是从 logic_error 派生而来的。
这两个类都有重载的 operator[ ] ( ) 方法,这些方法设计用于访问存储在对象中的数组元素,并在索引超界时引发异常。
bad_index 和 nbad_index 类都使用了异常规范 throw(),这是因为它们都归根结底是从基类 exception 派生而来的,而 exception 的虚构造函数使用了异常规范 throw()。这是 C++98 的一项功能,在 C++11 中,exception 的构造函数没有使用异常规范。
程序清单 15.15 是程序清单中没有声明为内联的方法的实现。注意,对于被嵌套类的方法,需要使用多个作用域解析运算符。另外,如果数组索引超界,函数 operator[ ] ( ) 将引发异常。
程序清单 15.15 sales.cpp
程序清单 15.16 在一个程序中使用了这些类:首先试图超越 LabeledSales 对象 sales2 中数组的末尾,然后试图超越 Sales 对象 sales1 中数组的末尾。这些尝试是在两个 try 块中进行的,让您能够检测每种异常。
程序清单 15.16 use_sales.cpp
下面是程序清单 15.14~程序清单 15.16 组成的程序的输出:
15.3.10 异常何时会迷失方向
异常被引发后,在两种情况下,会导致问题。首先,如果它是在带异常规范的函数中引发的,则必须与规范列表中的某种异常匹配(在继承层次结构中,类类型与这个类及其派生类的对象匹配),否则称为意外异常(unexpected exception)。在默认情况下,这将导致程序异常终止(虽然 C++11 摒弃了异常规范,但仍支持它,且有些现有的代码使用了它)。如果异常不是在函数中引发的(或者函数没有异常规范),则必须捕获它。如果没被捕获(在没有 try 块或没有匹配的 catch 块时,将出现这种情况),则异常被称为未捕获异常(uncaught exception)。在默认情况下,这将导致程序异常终止。然而,可以修改程序对意外异常和未捕获异常的反应。下面来看如何修改,先从未捕获异常开始。
未捕获异常不会导致程序立刻异常终止。相反,程序将首先调用函数 terminate( )。在默认情况下,terminate( ) 调用 abort( ) 函数。可以指定 terminate( ) 应调用的函数(而不是 abort( ))来修改 terminate( ) 的这种行为。为此,可调用 set_terminate( ) 函数。set_terminate( ) 和 terminate( ) 都是在头文件 exception 中声明的:
其中的 typedef 使 terminate_handler 成为这样一种类型的名称:指向没有参数和返回值的函数的指针。set_terminate( ) 函数将不带任何参数且返回类型为 void 的函数的名称(地址)作为参数,并返回该函数的地址。如果调用了 set_terminate( ) 函数多次,则 terminate( ) 将调用最后一次 set_terminate( ) 调用设置的函数。
来看一个例子。假设希望未捕获的异常导致程序打印一条消息,然后调用 exit( ) 函数,将退出状态值设置为 5。首先,请包含头文件 exception。可以使用 using 编译指令、适当的 using 声明或 std ::限定符,来使其声明可用。
然后,设计一个完成上述两种操作所需的函数,其原型如下:
最后,在程序的开头,将终止操作指定为调用该函数。
现在,如果引发了一个异常且没有被捕获,程序将调用 terminate( ),而后者将调用 MyQuit( )。
接下来看意外异常。通过给函数指定异常规范,可以让函数的用户知道要捕获哪些异常。假设函数的原型如下:
则可以这样使用该函数:
知道应捕获哪些异常很有帮助,因为默认情况下,未捕获的异常将导致程序异常终止。
原则上,异常规范应包含函数调用的其他函数引发的异常。例如,如果 Argh( ) 调用了 Duh( ) 函数,而后者可能引发 retort 对象异常,则 Argh( ) 和 Duh( ) 的异常规范中都应包含 retort。除非自己编写所有的函数,并且特别仔细,否则无法保证上述工作都已正确完成。例如,可能使用的是老式商业库,而其中的函数没有异常规范。这表明应进一步探讨这样一点,即如果函数引发了其异常规范中没有的异常,情况将如何?这也表明异常规范机制处理起来比较麻烦,这也是 C++11 将其摒弃的原因之一。
在这种情况下,行为与未捕获的异常极其类似。如果发生意外异常,程序将调用 unexpected( ) 函数(您没有想到是 unexpected( ) 函数吧?谁也想不到!)。这个函数将调用 terminate( ),后者在默认情况下将调用 abort( )。正如有一个可用于修改 terminate( ) 的行为的 set_terminate( ) 函数一样,也有一个可用于修改 unexpected( ) 的行为的 set_unexpected( ) 函数。这些新函数也是在头文件 exception 中声明的:
然而,与提供给 set_terminate( ) 的函数的行为相比,提供给 set_unexpected( ) 的函数的行为受到更严格的限制。具体地说,unexpected_handler 函数可以:
- 通过调用 terminate( )(默认行为)、abort( ) 或 exit( ) 来终止程序;
- 引发异常。
引发异常(第二种选择)的结果取决于 unexpected_handler 函数所引发的异常以及引发意外异常的函数的异常规范:
- 如果新引发的异常与原来的异常规范匹配,则程序将从那里开始进行正常处理,即寻找与新引发的异常匹配的 catch 块。基本上,这种方法将用预期的异常取代意外异常;
- 如果新引发的异常与原来的异常规范不匹配,且异常规范中没有包括 std ::bad_exception 类型,则程序将调用 terminate( )。bad_exception 是从 exception 派生而来的,其声明位于头文件 exce ption 中;
- 如果新引发的异常与原来的异常规范不匹配,且原来的异常规范中包含了 std ::bad_exception 类型,则不匹配的异常将被 std ::bad_exception 异常所取代。
总之,如果要捕获所有的异常(不管是预期的异常还是意外异常),则可以这样做:
首先确保异常头文件的声明可用:
然后,设计一个替代函数,将意外异常转换为 bad_exception 异常,该函数的原型如下:
仅使用 throw,而不指定异常将导致重新引发原来的异常。然而,如果异常规范中包含了这种类型,则该异常将被 bad_exception 对象所取代。
接下来在程序的开始位置,将意外异常操作指定为调用该函数:
最后,将 bad_exception 类型包括在异常规范中,并添加如下 catch 块序列:
15.3.11 有关异常的注意事项
从前面关于如何使用异常的讨论可知,应在设计程序时就加入异常处理功能,而不是以后再添加。这样做有些缺点。例如,使用异常会增加程序代码,降低程序的运行速度。异常规范不适用于模板,因为模板函数引发的异常可能随特定的具体化而异。异常和动态内存分配并非总能协同工作。
下面进一步讨论动态内存分配和异常。首先,请看下面的函数:
string 类采用动态内存分配。通常,当函数结束时,将为 mesg 调用 string 的析构函数。虽然 throw 语句过早地终止了函数,但它仍然使得析构函数被调用,这要归功于栈解退。因此在这里,内存被正确地管理。
接下来看下面这个函数:
这里有个问题。解退栈时,将删除栈中的变量 ar。但函数过早的终止意味着函数末尾的 delete[ ]语句被忽略。指针消失了,但它指向的内存块未被释放,并且不可访问。总之,这些内存被泄漏了。
这种泄漏是可以避免的。例如,可以在引发异常的函数中捕获该异常,在 catch 块中包含一些清理代码,然后重新引发异常:
然而,这将增加疏忽和产生其他错误的机会。另一种解决方法是使用第 16 章将讨论的智能指针模板之一。
总之,虽然异常处理对于某些项目极为重要,但它也会增加编程的工作量、增大程序、降低程序的速度。另一方面,不进行错误检查的代价可能非常高。
异常处理
在现代库中,异常处理的复杂程度可能再创新高——主要原因在于文档没有对异常处理例程进行解释或解释得很蹩脚。任何熟练使用现代操作系统的人都遇到过未处理的异常导致的错误和问题。这些错误背后的程序员通常面临一场艰难的战役,需要不断了解库的复杂性:什么异常将被引发,它们发生的原因和时间,如何处理它们,等等。
程序员新手很快将发现,理解库中异常处理像学习语言本身一样困难,现代库中包含的例程和模式可能像 C++语法细节一样陌生而困难。要开发出优秀的软件,必须花时间了解库和类中的复杂内容,就像必须花时间学习 C++本身一样。通过库文档和源代码了解到的异常和错误处理细节将使程序员和他的软件受益。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论