第28章 异常处理

    本章标题说明了一切:处理打断程序流程的特殊情形。本书前面一直采取最乐观的态度,假定内存分配将成功、文件能找到等,但现实往往并非如此。

    在本章中,您将学习:

    • 什么是异常;

    • 如何处理异常;

    • 异常处理对提供稳定的C++应用程序有何帮助。

    28.1 什么是异常

    假设您的程序分配内存、读写数据、保存到文件,一切都在开发环境中完美地执行;您的应用程序使用了数 GB 内存,却没有泄露一字节,对此您很是自豪!您发布该应用程序,用户将其部署到各种工作站。有些工作站已购买10年,还有些工作站的硬盘都不转了。不久后您就收到了抱怨邮件,有些用户抱怨说“访问违规”,有些说出现“未处理的异常”。

    “未处理”和“异常”,就是这样。显然,程序在开发环境中表现不错,为何麻烦不断呢?

    现实世界千差万别,没有两台计算机是相同的,即便硬件配置一样。这是因为在特定时间,可用的资源量取决于计算机运行的软件及其状态,因此即便在开发环境中内存分配完美无缺,在其他环境中也可能出问题。

    这样的情形很少见,但确实会发生。这些问题导致“异常”。

    异常会打断应用程序的正常流程。毕竟,如果没有内存可用,应用程序就无法完成分配给它的任务。然而,应用程序可处理这种异常:向用户显示一条友好的错误消息、采取必要的挽救措施并妥善地退出。

    通过对异常进行处理,有助于避免出现“访问违规”和“未处理的异常”等屏幕,还可避免收到相关的抱怨邮件。下面来看看C++都向您提供了哪些应对意外的工具。

    28.2 导致异常的原因

    异常可能是外部因素导致的,如系统没有足够的内存;也可能是应用程序内部因素导致的,如使用的指针包含无效值或除数为零。为向调用者指出错误,有些模块引发异常。

    第28章 异常处理 - 图1为防止代码引发异常,可对异常进行处理,让它们“不会出现异常”。

    28.3 使用try和catch捕获异常

    在捕获异常方面,try和catch是最重要的C++关键字。要捕获语句可能引发的异常,可将它们放在try块中,并使用catch块对try块可能引发的异常进行处理:

    第28章 异常处理 - 图2

    28.3.1 使用catch(…)处理所有异常

    第8章说过,成功分配内存时,默认形式的new返回一个指向该内存单元的有效指针,但失败时引发异常。程序清单28.1演示了如何捕获使用new分配内存时可能引发的异常,并在计算机不能分配请求的内存时进行处理。

    程序清单28.1 使用try和catch捕获并处理内存分配异常

    第28章 异常处理 - 图3

    输出:

    第28章 异常处理 - 图4

    分析:

    在这个示例中,我请求为-1 个整数预留内存。这很荒谬,但用户经常做荒谬的事。如果没有异常处理程序,该程序将以讨厌的方式终止。但由于有异常处理程序,程序显示了一条得体的消息:Got to end, sorry!。

    第28章 异常处理 - 图5如果在Visual Studio中运行该程序,可能出现一条调试模式消息,如图28.1所示。

    第28章 异常处理 - 图6

    图28.1 请求分配的内存量无效导致的异常

    单击“忽略”将执行异常处理程序。这是一条调试模式消息,但即便是在发行模式下,异常处理也将让程序妥善地退出。

    程序清单28.1演示了try块和catch块的用法。catch()像函数一样接受参数,参数…意味着catch块将捕获所有的异常。然而,在这个示例中,您可能想指定特定的异常类型 std::bad_alloc,因为这是new 失败时引发的异常。通过捕获特定类型的异常,有助于处理这种类型的异常,如显示一条消息,准确地指出出了什么问题。

    28.3.2 捕获特定类型的异常

    程序清单28.1所示的异常是由C++标准库引发的。这种异常的类型是已知的,在这种情况下,更好的选择是只捕获这种类型的异常,因为您能查明导致异常的原因,执行更有针对性的清理工作,或至少是向用户显示一条准确的消息,如程序清单28.2所示。

    程序清单28.2 捕获std::bad_alloc类型的异常

    第28章 异常处理 - 图7

    输出:

    第28章 异常处理 - 图8

    分析:

    如果将程序清单28.2的输出与程序清单28.1的输出进行比较,您将发现现在能够提供应用程序中断的准确原因,即 bad allocation。这是因为新增了一个 catch(是的,有两个 catch块),其中一个捕获类型为bad_alloc&的异常,如第16~20行所示,这种异常是由new引发的。

    第28章 异常处理 - 图9一般而言,可根据可能出现的异常添加多个catch()块,这将很有帮助。

    如程序清单28.2所示,catch(…)捕获未被其他catch块显式捕获的所有异常。

    28.3.3 使用throw引发特定类型的异常

    程序清单28.2捕获std::bad_alloc时,实际上是捕获new引发的std::bad_alloc类对象。您可以引发自己选择的异常,为此只需使用关键字throw:

    第28章 异常处理 - 图10

    程序清单28.3将两个数相除,演示了如何使用throw引发自定义异常。

    程序清单28.3 在试图除以零时引发一种自定义异常

    第28章 异常处理 - 图11

    输出:

    第28章 异常处理 - 图12

    分析:

    上述代码表明,通过捕获类型为char*的异常(第24行),可捕获调用函数Divide()可能引发的异常(第 6行)。另外,这里没有将整个main()都放在 try{ };中,而只在其中包含可能引发异常的代码。这通常是一种不错的做法,因为异常处理也可能降低代码的执行性能。

    28.4 异常处理的工作原理

    在程序清单28.3中,您在函数Divide()中引发了一个类型为char的异常,并在函数main()中使用处理程序catch(char)捕获它。

    每当您使用throw引发异常时,编译器都将查找能够处理该异常的catch(Type)。异常处理逻辑首先检查引发异常的代码是否包含在try块中,如果是,则查找可处理这种异常的catch(Type)。如果throw语句不在try块内,或者没有与引发的异常兼容的 catch(),异常处理逻辑将继续在调用函数中寻找。因此,异常处理逻辑沿调用栈向上逐个地在调用函数中寻找,直到找到可处理异常的 catch(Type)。在退栈过程的每一步中,都将销毁当前函数的局部变量,因此这些局部变量的销毁顺序与创建顺序相反。程序清单28.4演示了这一点。

    程序清单28.4 出现异常时销毁局部对象的顺序

    第28章 异常处理 - 图13

    输出:

    第28章 异常处理 - 图14

    分析:

    在程序清单28.4中,main()调用了FuncA(),FuncA()调用了FuncB(),而FuncB()引发异常,如第21行所示。函数FuncA()和main()都能处理这种异常,因为它们都包含 catch(const char*)。引发异常的FuncB()没有catch()块,因此FuncB()引发的异常将首先由FuncA()中的catch块(第34~39行)处理,因为是 FuncA()调用了 FuncB()。注意到 FuncA()认为这种异常不严重,没有继续将其传播给 main()。因此,在main()看来,就像没有问题发生一样。如果解除对第38行的注释,异常将传播给FuncB的调用者,即main()也将收到这种异常。

    输出指出了对象的创建顺序(与实例化它们的代码的排列顺序相同),还指出了引发异常后对象被销毁的顺序(与实例化顺序相反)。不仅在引发异常的 FuncB()中创建的对象被销毁,在调用 FuncB()并处理异常的FuncA()中创建的对象也被销毁。

    第28章 异常处理 - 图15程序清单28.4表明,引发异常时将对局部对象调用析构函数。

    如果因出现异常而被调用的析构函数也引发异常,将导致应用程序异常终止。

    28.4.1 std::exception类

    程序清单28.2捕获std::bad_alloc时,实际上是捕获new引发的std::bad_alloc对象。std::bad_alloc继承了C++标准类std::exception,而std::exception是在头文件<exception>中声明的。

    下述重要异常类都是从std::exception派生而来的。

    • bad_alloc:使用 new请求内存失败时引发。

    • bad_cast:试图使用 dynamic_cast转换错误类型(没有继承关系的类型)时引发。

    • ios_base::failure:由 iostream库中的函数和方法引发。

    std::exception类是异常基类,它定义了虚方法what();这个方法很有用且非常重要,详细地描述了导致异常的原因。在程序清单 28.2中,第 18行的 exp.what()提供了信息 bad allocation,让用户知道什么地方出了问题。由于 std::exception是众多异常类型的基类,因此可使用 catch(const exception&)所有将std::exception作为基类的异常:

    第28章 异常处理 - 图16

    28.4.2 从std::exception派生出自定义异常类

    可以引发所需的任何异常。然而,让自定义异常继承std::exception的好处在于,现有的异常处理程序 catch(const std::exception&)不但能捕获 bad_alloc、bad_cast等异常,还能捕获自定义异常,因为它们的基类都是exception。程序清单28.5演示了这一点。

    程序清单28.5 继承std::exception的CustomException类

    第28章 异常处理 - 图17

    输出:

    第28章 异常处理 - 图18

    分析:

    程序清单28.3在除以零时引发简单的char*异常,这里对其进行了修改,实例化了CustomException类的一个对象,这个类是在第 5~17 行定义的,它继承了 std::exception。注意到这个自定义异常类实现了虚函数 what(),如第 13~16 所示;该函数返回引发异常的原因。在 main()中,第 39~43 行的catch(exception&)不但处理异常CustomException,还处理bad_alloc等其他将exception作为基类的异常。

    第28章 异常处理 - 图19请注意程序清单28.5中虚方法CustomException::what()的声明(如第13行所示):

    第28章 异常处理 - 图20

    它以throw()结尾,这意味着这个函数本身不会引发异常。这是对异常类的一个重要约束,如果您在该函数中包含一条throw语句,编译器将发出警告。如果函数以throw(int)结尾,意味着该函数可能引发类型为int的异常。

    第28章 异常处理 - 图21

    28.5 总结

    本章介绍了 C++编程的一个重要部分。确保应用程序离开开发环境后依然稳定很重要,这有助于提高用户满意度,并提供直观的用户体验,而这正是异常的用武之地。您发现,分配资源或内存的代码可能失败,因此需要处理它们可能引发的异常。您学习了 C++异常类 std::exception,如果需要编写自定义异常类,最好继承std::exception。

    28.6 问与答

    问:为何引发异常,而不是返回错误?

    答:不是什么时候都可以返回错误。如果调用new失败,需要处理new引发的异常,以免应用程序崩溃。另外,如果错误非常严重,导致应用程序无法正常运行,应考虑引发异常。

    问:为何自定义异常类应继承std::exception?

    答:当然,并非必须这样做,但这让您能够重用捕获std::exception异常的所有catch()块。编写自己的异常类时,可以不继承任何类,但必须在所有相关的地方插入新的 catch(MyNewExceptionType&)语句。

    问:我编写的函数引发异常,必须在该函数中捕获它吗?

    答:完全不必,只需确保调用栈中有一个函数捕获这类异常即可。

    问:构造函数可引发异常吗?

    答:构造函数实际上没有选择余地!它们没有返回值,指出问题的唯一途径是引发异常。

    问:析构函数可引发异常吗?

    答:从技术上说可以,但这是一种糟糕的做法,因为异常导致退栈时也将调用析构函数。如果因异常而调用的析构函数引发异常,将给原本就稳定并试图妥善退出的应用程序雪上加霜。

    28.7 作业

    作业包括测验和练习,前者帮助读者加深对所学知识的理解,后者提供了使用新学知识的机会。请尽量先完成测验和练习题,然后再对照附录 D 的答案。在继续学习下一章前,请务必弄懂这些答案。

    28.7.1 测验

    1.std::exception是什么?

    2.使用new分配内存失败时,将引发哪种异常?

    3.在异常处理程序(catch块)中,为大量int变量分配内存以便备份数据合适吗?

    4.假设有一个异常类MyException,它继承了std::exceptiion,您将如何捕获这种异常对象?

    28.7.2 练习

    1.查错:下述代码有何错误?

    第28章 异常处理 - 图22

    2.查错:下述代码有何错误?

    第28章 异常处理 - 图23

    3.查错:下述代码有何错误?

    第28章 异常处理 - 图24