错误处理是一个庞大而复杂的主题,其所关注的问题和影响远远超出了编程语言本身,更多涉及到程序设计技术和工具的范畴。不过,C++确实提供了一些有益的机制,其中最主要的一个就是类型系统本身。在构建应用程序时,通常的做法不是仅仅依靠内置类型(如int、double、bool等)和语句(如if-else、switch-case、for等),而是建立更多提供高级功能的新类型(如string、map、thread等)和算法(如sort、find_if、draw_all等)。这些高级类型降低了程序设计的复杂性,减少了产生错误的机会,同时也增加了编译器捕获错误的概率。大多数C++结构都致力于设计并实现一套优雅而高效的抽象模型——用户自定义类型和基于这些类型的算法。这种抽象所带来的好处之一是,将错误检测与错误处理相分离。随着程序的发展,特别是当库被广泛使用后,错误处理的标准变得愈加重要。在程序开发的早期即明确错误处理的策略是一个好主意。
回顾之前编写的Vector类:
xxxxxxxxxx
241class Vector {
2public:
3 // 初始化
4 Vector(int size) : m_elem{ new double[size] }, m_size{ size } {}
5
6 // 通过下标访问特定的元素
7 double& operator[](int i) {
8 return m_elem[i];
9 }
10
11 // 通过下标访问特定的元素
12 const double& operator[](int i) const {
13 return const_cast<Vector&>(*this)[i];
14 }
15
16 // 获取元素的数量
17 int size() const {
18 return m_size;
19 }
20
21private:
22 double* m_elem; // 指向元素序列的指针
23 int m_size; // 元素的数量
24};
当用户试图越界访问数组元素时,会发生什么?
xxxxxxxxxx
61void foo() {
2 ...
3 Vector vec(5);
4 cin >> vec[5]; // 越界访问
5 ...
6}
这里面存在以下两个问题:
Vector类的作者并不知道用户会以这样的方式使用这个类,因为他很难预见到这个类会被用在什么样的程序中
Vector类的用户也不可能持之以恒地执行下标检测,如果真能做到这一点,世界上就不会有那么多下标越界了
Vector类的作者唯一能做的,是替他的用户执行下标检测,并在发生越界时向用户报告,即抛出异常。例如:
xxxxxxxxxx
121class Vector {
2public:
3 ...
4 // 通过下标访问特定的元素
5 double& operator[](int i) {
6 if (i < 0 || m_size <= i)
7 throw out_of_range("Vector::operator[]");
8
9 return m_elem[i];
10 }
11 ...
12};
throw指令创建了一个out_of_range类型的异常对象,并将该对象的控制权移交给直接或间接调用Vector::operator[]函数的代码。流程控制将回溯函数的调用栈,直至对此异常感兴趣的调用者代码,其间任何局部对象都将被正确地析构。例如:
xxxxxxxxxx
121void bar() {
2 ...
3 try {
4 ...
5 foo(); // 抛出out_of_range异常
6 ...
7 }
8 catch (const out_of_range& ex) { // 捕获out_of_range异常
9 cout << ex.what() << endl; // 处理out_of_range异常
10 }
11 ...
12}
将可能抛出异常的代码放在一个try块中,一旦有异常抛出,流程立即跳转到与该异常类型匹配的catch块中,执行针对此异常的处理代码。如果一个函数并非处理某类异常的恰当地方,比如这里的foo函数,就不必捕获该类型的异常,而是将其隐式传递给函数的调用者,比如这里的bar函数。
out_of_range异常类在标准库的<stdexcept>头文件中定义。标准库中的很多涉及元素访问的库函数都使用该异常。
上述代码中的catch语句,通过引用捕获异常对象,这样可以避免异常对象的复制,同时调用其what成员函数,获得关于异常的描述信息。
异常处理机制可以使对错误的处理更加简单和系统化,同时提高代码的可读性。想做到这一点,就不要过多地使用try-catch语句。从抛出异常的代码到处理异常的代码,之间可能跨越了几十个函数调用,大多数函数应当允许异常沿着调用栈向上传播,直至被位于顶层的catch块捕获。让异常处理变得简单和系统化的主流技术名为资源获取即初始化(RAII)。RAII的核心思想是由构造函数获取类对象所需要的一切资源,并在析构函数中予以释放。异常处理的设计初衷,就是要确保调用链中的所有析构函数,都能被正确地调用。做到这一点,就能使所有动态资源的释放操作,被可靠地自动执行。
针对越界访问抛出异常,是函数检查参数并拒绝执行某种操作的典型案例。某个操作是否能被执行,取决于它所依赖的条件是否能被满足。Vector对象下标操作所依赖的条件,就是下标必须位于[0:m_size)区间内。记号[a:b)表示一个左闭右开区间,即所有大于等于a且小于b的整数,注意等于b的整数不在此区间内。每当定义一个函数时,都应考虑函数的约束条件,并决定是否要检测这些条件。对于大多数应用来说,检测简单的约束条件是一个好主意。
除了函数以外,类同样有它的约束条件。Vector类的约束条件就是它的m_elem成员必须指向一个包含m_size个double型元素的数组。为类建立约束条件是构造函数的任务,在该类的其它成员函数被执行前,这个约束条件必须满足。不幸的是,Vector类的构造函数只完成了一部分工作,它正确地初始化了类的成员变量,却没有检查所传递的参数是否合理。考虑这种情形:
xxxxxxxxxx
11Vector vec(-5);
这可能导致混乱的结果。下面是一个更好的定义:
xxxxxxxxxx
121class Vector {
2public:
3 // 初始化
4 Vector(int size) {
5 if (size < 0)
6 throw length_error("Vector constructor: negative size");
7
8 m_elem = new double[size];
9 m_size = size;
10 }
11 ...
12};
这里使用了标准库异常length_error报告元素数量为负的情况。事实上,很多标准库操作都通过这个异常报告类似的错误。如果操作符new无法获得欲分配的内存,它就会抛出std::bad_alloc异常。Vector类的用户代码如下:
xxxxxxxxxx
121void test(int size) {
2 try {
3 Vector vec(size);
4 ...
5 }
6 catch (const length_error& ex) {
7 ... 处理元素数量为负异常 ...
8 }
9 catch (const bad_alloc& ex) {
10 ... 处理内存分配失败异常 ...
11 }
12}
xxxxxxxxxx
31test(-5); // 捕获到元素数量为负异常
2test(1'000'000'000); // 大概率捕获到内存分配失败异常
3test(5); // 没有异常
内存分配失败通常发生在,系统所能提供的连续自由内存空间,无法达到所申请的字节数的情况下。现代操作系统的内存管理机制,允许用户分配比实际物理内存多得多的虚拟内存空间。因此在bad_alloc异常触发之前,系统的运行速度可能已经变得很慢。
除了使用标准库提供的异常类以外,程序员也可以自己定义表示异常的类。这样当错误发生时,就可以根据需要在异常对象中携带更少或者更多的信息。使用标准库定义的异常类层次结构并不是必须的。
在函数中,引发异常的语句后面的代码是不会被执行的,其中可能包含某些资源清理工作。例如:
xxxxxxxxxx
61void foo() {
2 ... 分配资源 ...
3 Vector vec(5);
4 cin >> vec[5]; // 越界访问
5 ... 清理资源 ... // 异常发生时这些代码不会被执行
6}
这种情况下,不妨捕获该异常,在完成必要的清理工作后,再重新抛出该异常。例如:
xxxxxxxxxx
121void foo() {
2 ... 分配资源 ...
3 Vector vec(5);
4 try {
5 cin >> vec[5]; // 越界访问
6 }
7 catch (const out_of_range& ex) {
8 ... 清理资源 ... // 异常发生时在这里清理资源
9 throw; // 重新抛出异常
10 }
11 ... 清理资源 ... // 异常发生时这些代码不会被执行
12}
在良好设计的代码中,很少使用try-catch语句。可以通过系统性地使用RAII技术来避免过多地使用try-catch语句。
在类和函数的设计过程中,约束条件占据着相当重要的地位,必须予以足够的重视:
厘清约束条件有助于精确地理解需求
约束条件迫使程序设计者对问题的考虑更加具体,所编写代码的正确率也更高
约束条件的概念涉及很多资源管理的细节,而C++的资源管理与类的构造和析构函数密切相关。
错误处理在现实世界的软件开发中是一个重大议题,自然有各种不同的实现方式。如果检测到一个错误,却不能在函数内部自我化解,就必须以某种方式通知函数的调用者,即将问题传递出去。在C++中,抛出异常是最通用的处理方式。
在某些编程语言中,异常被设计为错误处理的唯一机制,C++并没有这样做。在C++中,异常仅仅是报告任务执行过程中遇到错误的方式之一,但不是唯一。C++的异常处理机制与构造和析构函数一起,提供了错误处理与资源管理的连贯框架。编译器会被优化,使得返回值的代价远远低于抛出相同值的异常。
函数可以通过下列方法,向调用者表示它无法完成当前任务:
抛出异常
返回一个特定的,表示失败的值
调用terminate、exit、abort之类的函数,终止程序运行
遇到下列情况,以返回错误代码的方式向调用者报告失败更为合适:
这种失败很常见且在调用者的预料范围之内。例如打开文件失败,可能这个文件并不存在,或没有打开权限
可以理性地期待函数的调用者能够立即处理这个错误
在并行处理的一系列任务中发生错误,调用者需要知道具体哪个任务失败了
在内存配置较低的系统中,异常机制的运行时开销可能会影响到系统的核心功能
遇到下列情况,以抛出异常的方式向调用者报告失败更为合适:
发生的错误极为罕见,以至调用者大概率会忽略对它的检测,比如printf函数出错
有些错误无法或不适合被直接调用者处理,最好能向上渗透至调用链的顶端,被统一处理。比如在函数调用的每一层级都对内存分配失败或网络通信中断等错误进行检查是不现实的,执行错误处理的代码不仅重复冗长而且代价高昂,甚至将完成主要任务的核心逻辑隐匿其中,难以辨识
在上层模块保持不变的前提下修改了底层实现,上层代码不可能对新增的错误提供处理分支。例如,将原来的单线程设计,修改为基于多线程的并发处理,或者将先前的本地资源访问,改为基于网络的远程服务器访问
无法以合适的途径返回错误代码。比如在构造函数中发生的错误,或在某些操作符函数中发生的错误,例如:a*b+c/d
函数本身有承载特定信息的返回值,如果再返回错误代码,会令函数的设计变得复杂。可能需要返回一个pair类型的对象,或者使用输出参数、非局部变量等其它奇技淫巧
在错误处理过程中,可能还需要调用其它函数,而这些函数同样可能出错,这将进一步加剧流程控制的复杂性
发生错误的函数可能是通过一个函数指针调用的,该指针可以指向不同的函数,不同函数返回的错误代码有不同的解释,调用者不可能提前预知
对某些错误的处理可能需要伴随某种形式的撤销动作
遇到下列情况,应当直接终止程序运行:
无法恢复的错误,比如内存耗尽等
通过重启线程、进程,甚至系统才能从错误中恢复
一种确保程序终止的办法是把函数声明为noexcept,这样的函数只要有异常抛出,都会立即导致terminate函数被调用,令进程终止。某些应用可能不允许无条件终止,这需要寻找其它替代方法。通用的库不应该使用无条件终止,毕竟终止与否应该由库的使用者决定,而非由库的创建者自作主张。
不同错误处理方式间的利弊权衡,有时很难做出理由充分的决定。现实的场景并不总能匹配上面列举的情况,程序的规模与复杂度也是千差万别。错误处理方式的抉择,很大程度上取决于代码编写者的经验、偏好和习惯。如果实在不好决定,那么就用异常,因为它总是能够相对中庸地适配不同规模的应用,而且不需要借助外部工具,就能确保所有错误都被无一遗漏地捕获并处理。
不要认为返回错误代码和抛出异常之间,一个就一定比另一个好,它们各有各的适用场景。也不要迷信异常机制的处理过程就一定很慢,它通常比想象的要快,尤其是在一些复杂、稀有的错误场景下,或者需要多次重复检测错误代码时。
基于异常机制的错误处理,关键在于RAII。简单地以try-catch语句平替每一个对函数返回错误代码的判断,是非常糟糕的设计。
C++11:通过noexcept说明符防止异常传播
截止目前,还没有一个通用的、标准的方法为诸如约束条件、前提条件等写出可行的运行时检查。然而,对许多大型应用而言,开发人员确实需要在测试阶段,采取更高强度的运行时检查,并在最终发布的正式版本中,只保留必要的检查,力争将检查开销降到最小。
有一些特殊的机制,它们灵活、通用,而且在不触发的情况下,几乎没有任何额外的开销。例如:
xxxxxxxxxx
21enum class ErrorAction { ignore, throwing, terminating, logging };
2constexpr ErrorAction defaultErrorAction = ErrorAction::throwing;
xxxxxxxxxx
21enum class ErrorCode { rangeError, lengthError };
2string errorDesc[]{ "range error", "length error" };
xxxxxxxxxx
151template<ErrorAction errorAction = defaultErrorAction, class Cond>
2constexpr void expect(Cond cond, ErrorCode errorCode) {
3 if constexpr (errorAction == ErrorAction::throwing)
4 if (!cond())
5 throw errorCode;
6
7 if constexpr (errorAction == ErrorAction::terminating)
8 if (!cond())
9 terminate();
10
11 if constexpr (errorAction == ErrorAction::logging)
12 if (!cond())
13 cerr << "expect() failure: " << int(errorCode) << '-' <<
14 errorDesc[int(errorCode)] << endl;
15}
xxxxxxxxxx
111class Vector {
2public:
3 ...
4 // 通过下标访问特定的元素
5 double& operator[](int i) {
6 expect([i, this] { return 0 <= i && i < m_size; }, ErrorCode::rangeError);
7
8 return m_elem[i];
9 }
10 ...
11};
模板函数expect的作用是,检查下标参数i是否在合法区间[0:m_size)内,如果不在则执行默认行为,即抛出ErrorCode::rangeError异常。其中关于合法区间的条件判断,以匿名函数“[i, this] { return 0 <= i && i < m_size; }”的形式给出。由于“if constexpr”的判断发生在编译时,因此编译后的expect函数只包含一条指令,等价于:
xxxxxxxxxx
41void expect(Cond cond, ErrorCode errorCode) {
2 if (!cond())
3 throw errorCode;
4}
如果在实例化该函数模板时,将其非类型模板参数errorAction指定为ErrorAction::ignore,则编译后的expect函数将是一个空函数,等价于:
xxxxxxxxxx
21void expect(Cond cond, ErrorCode errorCode) {
2}
通过设定全局变量defaultErrorAction的值,程序员可以为最终发布的正式版本,选择合适的错误处理行为,比如ErrorAction::terminating或者ErrorAction::logging等。为了支持日志,全局变量errorDesc定义了一张关于错误描述的数组,以错误代码为下标,相应元素的错误描述字符串。日志信息可以通过source_location加以改进。
类似expect这样的断言机制,还有一个好处,就是查找断言条件,如“0 <= i && i < m_size”,相对容易。在大型软件中,搜索所有的if语句,再逐个检查判断条件,是不切实际的。
标准库提供了一个调试宏——assert,它可以在运行时断言必须满足的条件。例如:
xxxxxxxxxx
41void foo(const char* p) {
2 assert(p != nullptr); // p不可以是空指针
3 ...
4}
在非调试模式下,assert宏什么也不做。在调试模式下,assert宏中的条件满足,继续执行后面的代码,否则程序终止。这个功能简单粗暴且不够灵活,但总比什么都没有强。
异常机制用于在运行时发现并处理错误,但有些时候,程序编写者可能希望在编译阶段执行一些简单的检查,以确保符合预期,并针对不符合的情况报告编译错误。例如:
xxxxxxxxxx
11static_assert(4 <= sizeof(int), "integers are too small"); // 整型不能小于4字节
如果当前环境中int类型数据的内存空间大小不足4字节,该断言将引发一个编译错误,同时输出“integers are too small”。这样的断言叫做静态断言。
在静态断言中只能使用常量表达式。例如:
xxxxxxxxxx
101constexpr double C = 299792.458; // km/s,光速
2
3void foo(double speed) {
4 constexpr double local = 160.0 / (60 * 60); // 160 km/h -> 160/(60x60) km/s
5
6 static_assert(speed < C, "can't go that fast"); // 错误,speed不是常量
7 static_assert(local < C, "can't go that fast");
8
9 ...
10}
对于“static_assert(A, S)”,当A为真时,继续编译后面的代码,否则报告编译错误并打印S。如果不提供S参数,则打印默认信息。例如:
xxxxxxxxxx
11static_assert(4 <= sizeof(int)); // 整型不能小于4字节
典型的默认信息通常由static_assert断言调用代码的位置,加上断言内容的字符表述谓词构成。
静态断言static_assert的一个重要用途,是在泛型编程中,检查模板的类型参数,是否符合某种预期的特征。
C++11:编译时断言static_assert
如果一个函数在任何时候都不应该抛出异常,那么可以将其声明为noexcept。例如:
xxxxxxxxxx
51void foo(int size) noexcept {
2 Vector vec(size);
3 iota(&vec[0], &vec[1], 1); // 将vec填充为1,2,3,4...
4 ...
5}
如果所有的意图与设计都失败,foo函数依然抛出了异常,则该异常无法被捕获,而是直接引发std::terminate函数被调用,程序终止运行。
不假思索地将函数声明为noexcept是有害的。如果在noexcept函数中抛出了某个期待被捕获并处理的异常,尽管该异常原本是无害的,noexcept仍会将其夸大为致命错误。同时,noexcept强制程序员使用错误代码的方式处理错误,这可能使处理过程变得复杂、易错,而且代价昂贵。与其它所有强有力的语法机制一样,noexcept应当在充分理解其特性的基础上,谨慎使用。
当无法完成既定任务时,请抛出异常
异常仅用于错误处理,而不能取代正常返回
打开文件失败,或到达迭代终点,是预期事件,而不是异常
当直接调用者期望处理错误时,直接返回错误代码,而不要抛出异常
当错误需要穿越多层函数调用,向上渗透时,请抛出异常
如果不确定是抛出异常好,还是返回错误代码好,优先选择异常
在设计阶段就要想好错误处理策略
最好以专门设计的用户自定义类型作为异常对象的类型,尽量不要抛出内置类型的异常
不要试图捕获每个函数中的每个错误
不一定非要使用标准库预定义的异常类层次
优先使用RAII,而不是在try块和catch块中完成资源的分配与释放
在构造函数中检查约束条件,不满足则抛出异常
围绕约束条件,设计错误处理策略
能在编译时检查的问题,尽量在编译时检查
使用断言机制,对故障进行单点控制
概念是编译时的断言,因此经常用在断言中
如果一个函数不允许抛出异常,那么就将其声明为noexcept
除非经过全面考虑,否则不要使用noexcept