4 错误处理

4.1 引言

错误处理是一个庞大而复杂的主题,其所关注的问题和影响远远超出了编程语言本身,更多涉及到程序设计技术和工具的范畴。不过,C++确实提供了一些有益的机制,其中最主要的一个就是类型系统本身。在构建应用程序时,通常的做法不是仅仅依靠内置类型(如int、double、bool等)和语句(如if-else、switch-case、for等),而是建立更多提供高级功能的新类型(如string、map、thread等)和算法(如sort、find_if、draw_all等)。这些高级类型降低了程序设计的复杂性,减少了产生错误的机会,同时也增加了编译器捕获错误的概率。大多数C++结构都致力于设计并实现一套优雅而高效的抽象模型——用户自定义类型和基于这些类型的算法。这种抽象所带来的好处之一是,将错误检测与错误处理相分离。随着程序的发展,特别是当库被广泛使用后,错误处理的标准变得愈加重要。在程序开发的早期即明确错误处理的策略是一个好主意。

4.2 异常

回顾之前编写的Vector类:

当用户试图越界访问数组元素时,会发生什么?

这里面存在以下两个问题:

Vector类的作者唯一能做的,是替他的用户执行下标检测,并在发生越界时向用户报告,即抛出异常。例如:

throw指令创建了一个out_of_range类型的异常对象,并将该对象的控制权移交给直接或间接调用Vector::operator[]函数的代码。流程控制将回溯函数的调用栈,直至对此异常感兴趣的调用者代码,其间任何局部对象都将被正确地析构。例如:

将可能抛出异常的代码放在一个try块中,一旦有异常抛出,流程立即跳转到与该异常类型匹配的catch块中,执行针对此异常的处理代码。如果一个函数并非处理某类异常的恰当地方,比如这里的foo函数,就不必捕获该类型的异常,而是将其隐式传递给函数的调用者,比如这里的bar函数。

out_of_range异常类在标准库的<stdexcept>头文件中定义。标准库中的很多涉及元素访问的库函数都使用该异常。

上述代码中的catch语句,通过引用捕获异常对象,这样可以避免异常对象的复制,同时调用其what成员函数,获得关于异常的描述信息。

异常处理机制可以使对错误的处理更加简单和系统化,同时提高代码的可读性。想做到这一点,就不要过多地使用try-catch语句。从抛出异常的代码到处理异常的代码,之间可能跨越了几十个函数调用,大多数函数应当允许异常沿着调用栈向上传播,直至被位于顶层的catch块捕获。让异常处理变得简单和系统化的主流技术名为资源获取即初始化(RAII)。RAII的核心思想是由构造函数获取类对象所需要的一切资源,并在析构函数中予以释放。异常处理的设计初衷,就是要确保调用链中的所有析构函数,都能被正确地调用。做到这一点,就能使所有动态资源的释放操作,被可靠地自动执行。

4.3 约束条件

针对越界访问抛出异常,是函数检查参数并拒绝执行某种操作的典型案例。某个操作是否能被执行,取决于它所依赖的条件是否能被满足。Vector对象下标操作所依赖的条件,就是下标必须位于[0:m_size)区间内。记号[a:b)表示一个左闭右开区间,即所有大于等于a且小于b的整数,注意等于b的整数不在此区间内。每当定义一个函数时,都应考虑函数的约束条件,并决定是否要检测这些条件。对于大多数应用来说,检测简单的约束条件是一个好主意。

除了函数以外,类同样有它的约束条件。Vector类的约束条件就是它的m_elem成员必须指向一个包含m_size个double型元素的数组。为类建立约束条件是构造函数的任务,在该类的其它成员函数被执行前,这个约束条件必须满足。不幸的是,Vector类的构造函数只完成了一部分工作,它正确地初始化了类的成员变量,却没有检查所传递的参数是否合理。考虑这种情形:

这可能导致混乱的结果。下面是一个更好的定义:

这里使用了标准库异常length_error报告元素数量为负的情况。事实上,很多标准库操作都通过这个异常报告类似的错误。如果操作符new无法获得欲分配的内存,它就会抛出std::bad_alloc异常。Vector类的用户代码如下:

内存分配失败通常发生在,系统所能提供的连续自由内存空间,无法达到所申请的字节数的情况下。现代操作系统的内存管理机制,允许用户分配比实际物理内存多得多的虚拟内存空间。因此在bad_alloc异常触发之前,系统的运行速度可能已经变得很慢。

除了使用标准库提供的异常类以外,程序员也可以自己定义表示异常的类。这样当错误发生时,就可以根据需要在异常对象中携带更少或者更多的信息。使用标准库定义的异常类层次结构并不是必须的。

在函数中,引发异常的语句后面的代码是不会被执行的,其中可能包含某些资源清理工作。例如:

这种情况下,不妨捕获该异常,在完成必要的清理工作后,再重新抛出该异常。例如:

在良好设计的代码中,很少使用try-catch语句。可以通过系统性地使用RAII技术来避免过多地使用try-catch语句。

在类和函数的设计过程中,约束条件占据着相当重要的地位,必须予以足够的重视:

约束条件的概念涉及很多资源管理的细节,而C++的资源管理与类的构造和析构函数密切相关。

4.4 错误处理的其它替代方式

错误处理在现实世界的软件开发中是一个重大议题,自然有各种不同的实现方式。如果检测到一个错误,却不能在函数内部自我化解,就必须以某种方式通知函数的调用者,即将问题传递出去。在C++中,抛出异常是最通用的处理方式。

在某些编程语言中,异常被设计为错误处理的唯一机制,C++并没有这样做。在C++中,异常仅仅是报告任务执行过程中遇到错误的方式之一,但不是唯一。C++的异常处理机制与构造和析构函数一起,提供了错误处理与资源管理的连贯框架。编译器会被优化,使得返回值的代价远远低于抛出相同值的异常。

函数可以通过下列方法,向调用者表示它无法完成当前任务:

遇到下列情况,以返回错误代码的方式向调用者报告失败更为合适:

遇到下列情况,以抛出异常的方式向调用者报告失败更为合适:

遇到下列情况,应当直接终止程序运行:

一种确保程序终止的办法是把函数声明为noexcept,这样的函数只要有异常抛出,都会立即导致terminate函数被调用,令进程终止。某些应用可能不允许无条件终止,这需要寻找其它替代方法。通用的库不应该使用无条件终止,毕竟终止与否应该由库的使用者决定,而非由库的创建者自作主张。

不同错误处理方式间的利弊权衡,有时很难做出理由充分的决定。现实的场景并不总能匹配上面列举的情况,程序的规模与复杂度也是千差万别。错误处理方式的抉择,很大程度上取决于代码编写者的经验、偏好和习惯。如果实在不好决定,那么就用异常,因为它总是能够相对中庸地适配不同规模的应用,而且不需要借助外部工具,就能确保所有错误都被无一遗漏地捕获并处理。

不要认为返回错误代码和抛出异常之间,一个就一定比另一个好,它们各有各的适用场景。也不要迷信异常机制的处理过程就一定很慢,它通常比想象的要快,尤其是在一些复杂、稀有的错误场景下,或者需要多次重复检测错误代码时。

基于异常机制的错误处理,关键在于RAII。简单地以try-catch语句平替每一个对函数返回错误代码的判断,是非常糟糕的设计。

C++11:通过noexcept说明符防止异常传播

4.5 断言

截止目前,还没有一个通用的、标准的方法为诸如约束条件、前提条件等写出可行的运行时检查。然而,对许多大型应用而言,开发人员确实需要在测试阶段,采取更高强度的运行时检查,并在最终发布的正式版本中,只保留必要的检查,力争将检查开销降到最小。

有一些特殊的机制,它们灵活、通用,而且在不触发的情况下,几乎没有任何额外的开销。例如:

模板函数expect的作用是,检查下标参数i是否在合法区间[0:m_size)内,如果不在则执行默认行为,即抛出ErrorCode::rangeError异常。其中关于合法区间的条件判断,以匿名函数“[i, this] { return 0 <= i && i < m_size; }”的形式给出。由于“if constexpr”的判断发生在编译时,因此编译后的expect函数只包含一条指令,等价于:

如果在实例化该函数模板时,将其非类型模板参数errorAction指定为ErrorAction::ignore,则编译后的expect函数将是一个空函数,等价于:

通过设定全局变量defaultErrorAction的值,程序员可以为最终发布的正式版本,选择合适的错误处理行为,比如ErrorAction::terminating或者ErrorAction::logging等。为了支持日志,全局变量errorDesc定义了一张关于错误描述的数组,以错误代码为下标,相应元素的错误描述字符串。日志信息可以通过source_location加以改进。

类似expect这样的断言机制,还有一个好处,就是查找断言条件,如“0 <= i && i < m_size”,相对容易。在大型软件中,搜索所有的if语句,再逐个检查判断条件,是不切实际的。

4.5.1 assert

标准库提供了一个调试宏——assert,它可以在运行时断言必须满足的条件。例如:

在非调试模式下,assert宏什么也不做。在调试模式下,assert宏中的条件满足,继续执行后面的代码,否则程序终止。这个功能简单粗暴且不够灵活,但总比什么都没有强。

4.5.2 static_assert

异常机制用于在运行时发现并处理错误,但有些时候,程序编写者可能希望在编译阶段执行一些简单的检查,以确保符合预期,并针对不符合的情况报告编译错误。例如:

如果当前环境中int类型数据的内存空间大小不足4字节,该断言将引发一个编译错误,同时输出“integers are too small”。这样的断言叫做静态断言。

在静态断言中只能使用常量表达式。例如:

对于“static_assert(A, S)”,当A为真时,继续编译后面的代码,否则报告编译错误并打印S。如果不提供S参数,则打印默认信息。例如:

典型的默认信息通常由static_assert断言调用代码的位置,加上断言内容的字符表述谓词构成。

静态断言static_assert的一个重要用途,是在泛型编程中,检查模板的类型参数,是否符合某种预期的特征。

C++11:编译时断言static_assert

4.5.3 noexcept

如果一个函数在任何时候都不应该抛出异常,那么可以将其声明为noexcept。例如:

如果所有的意图与设计都失败,foo函数依然抛出了异常,则该异常无法被捕获,而是直接引发std::terminate函数被调用,程序终止运行。

不假思索地将函数声明为noexcept是有害的。如果在noexcept函数中抛出了某个期待被捕获并处理的异常,尽管该异常原本是无害的,noexcept仍会将其夸大为致命错误。同时,noexcept强制程序员使用错误代码的方式处理错误,这可能使处理过程变得复杂、易错,而且代价昂贵。与其它所有强有力的语法机制一样,noexcept应当在充分理解其特性的基础上,谨慎使用。

4.6 建议