第4章 错误处理
第4章 错误处理
简介
异常
不变性
错误处理的替代方案
断言
- assert()
- static_assert
- noexcept
建议
4.1 简介
错误处理是一个庞大且复杂的话题,其关注点和影响远远超出了语言设施本身,触及编程技术和工具等多个层面。然而,C++ 提供了一些特性来辅助这一过程。最重要的工具就是类型系统本身。我们不再繁琐地仅从基本类型(如 char 、 int 和 double )及语句(如 if 、 while 和 for )构建应用程序,而是构建适用于我们应用的类型(如 string 、 map 和 thread )和算法(如 sort() 、 find_if() 和 draw_all() )。
这些更高级别的构造简化了我们的编程,限制了出错的可能性(例如,你不太可能尝试对一个对话框应用树遍历),并增加了编译器捕获错误的机会。C++ 语言中的大多数结构都是致力于设计和实现优雅且高效的抽象(如用户自定义类型及其使用的算法)。使用这些抽象的一个效果是,运行时错误能够被检测到的点与能够处理该错误的点被分离开来。随着程序规模的增长,尤其是在大量使用库时,错误处理的标准变得尤为重要。在程序开发的早期明确一套错误处理策略是个好主意。
4.2 异常处理
让我们再次回顾 Vector 类的例子。当我们试图访问一个超出§ 2.3中 Vector 容量范围的元素时,应该如何处理?
- Vector 的编写者不知道用户希望在这种情况下怎么做(实际上, Vector 的编写者通常甚至不知道 Vector 将在哪一个程序中运行)。
- Vector 的使用者无法一致地检测到这个问题(如果使用者能够检测到,就不会发生越界访问了)。
假设我们希望从越界访问这种错误中恢复,解决方案是让 Vector 的实现者检测到尝试越界访问的行为,并告知用户这一情况。然后,用户可以采取适当的行动。例如, Vector::operator[]() 可以检测到尝试的越界访问,并抛出一个 out_of_range 异常:
double& Vector::operator[](int i) {
if (!(0 < i && i < size())) {
throw out_of_range{"Vector::operator[]"};
}
return elem[i];
}
这里的 throw 会将控制权转移给直接或间接调用了 Vector::operator[]() 的某个函数中的 out_of_range 类型异常处理器。为此,实现会按需 回溯 函数调用栈,直到回到对该异常类型感兴趣的调用者的上下文中。也就是说,异常处理机制会在必要时退出作用域和函数,直至到达一个表达了处理该类型异常意愿的调用者,并沿途按需调用析构函数(§ 5.2.2)。
例如:
void f(Vector& v) {
// ...
try { // 在这个块内抛出的 out_of_range 异常由下面定义的处理器处理
compute1(v); // 可能尝试访问 v 的末端之外
Vector v2 = compute2(v); // 可能尝试访问 v 的末端之外
compute3(v2); // 可能尝试访问 v2 的末端之外
}
catch (const out_of_range& err) { // 哎呀:发生了 out_of_range 错误
// ... 处理范围错误 ...
cerr << err.what() << '\n';
}
// ...
}
我们将感兴趣的异常处理代码放入 try 块中。 compute1() 、 compute2() 和 compute3() 的调用代表了一组代码,我们无法简单地预先判断是否会发生范围错误。通过 catch 子句,我们提供了处理 out_of_range 类型异常的机制。如果 f() 不是处理这类异常的好地方,我们就不会使用 try 块,而是让异常隐式传递给 f() 的调用者。
out_of_range 类型是在标准库中定义的(位于 <stdexcept> 中),并且实际上被一些标准库容器的访问函数所使用。
我通过引用捕获异常以避免复制,并使用了 what() 函数来打印在 抛出 点放入的错误消息。
使用异常处理机制可以使错误处理变得更加简单、系统化和可读性强。为了实现这一点,不要过度使用 try 语句。在许多程序中,从 抛出异常 到可以合理处理该异常的函数之间,通常会有数十个函数调用。因此,大多数函数应该简单地允许异常沿着调用堆栈传播。
使错误处理变得简单和系统化的主要技术(称为 资源获取即初始化 ; RAII )在 §5.2.2 中进行了解释。RAII背后的基本思想是让构造函数获取类操作所需的资源,并让析构函数释放所有资源,从而确保并隐式地完成资源释放。
4.3 约束条件
使用异常来指示越界访问是一种函数检查其参数并在基本假设(先决条件)未满足时拒绝执行的例子。如果我们正式地指定 Vector 的下标运算符,我们会说“索引必须在0:size() )范围内”,这实际上也是我们在 **operator[** 中进行测试的内容。[ a:b ) 符号指定一个半开区间,意味着 a 是区间的一部分,而 b 不是。每当我们定义一个函数时,我们都应考虑它的先决条件是什么,并考虑是否应该测试它们(§ 4.4)。对于大多数应用来说,测试简单的不变性是一个好主意;§ 4.5。
然而, operator[]() 操作 Vector 类型的对象,如果不满足“合理”值这一条件,它所做的任何事情都没有意义。特别是,我们确实说过“ elem 指向一个大小为 sz 的 double 数组”,但那只是在注释中提到的。对于类而言,关于其内部被认为始终为真的陈述被称为 类不变性 ,或简称为 不变性 。构造函数的任务是为其类建立不变性(以便成员函数可以依赖这一点),而成员函数则需要确保在它们退出时不变性仍然成立。不幸的是,我们的 Vector 构造函数只部分完成了这项工作。它正确地初始化了 Vector 的成员,但未能检查传递给它的参数是否有意义。考虑以下情况:
Vector v(-27);
这很可能会导致混乱。
以下是更合适的定义:
Vector::Vector(int s) {
if (s < 0)
throw std::length_error{"Vector constructor: negative size"};
elem = new double[s];
sz = s;
}
我使用标准库异常 length_error 来报告元素数量为负数的情况,因为某些标准库操作使用该异常来报告此类问题。如果 new 操作无法找到足够的内存来分配,则会抛出 std::bad_alloc 。现在我们可以这样写:
void test(int n) {
try {
Vector v(n);
} catch (std::length_error& err) {
// ... 处理负数 ...
} catch (std::bad_alloc& err) {
// ... 处理内存耗尽 ...
}
}
void run() {
test(-27); // 抛出 length_error (-27 太小)
test(1'000'000'000); // 可能抛出 bad_alloc
test(10); // 很可能正常
}
内存耗尽发生在请求的内存超过机器提供的总量,或者程序已经几乎消耗了那么多,而你的请求使其超过了极限时。请注意,现代操作系统通常会给你比物理内存一次可容纳更多的空间,因此请求过多的内存可能会在触发 bad_alloc 之前很久就导致严重的性能下降。
你可以自定义类作为异常,并让它们携带从检测到错误的点到可以处理该错误的点所需的信息,无论多少(§ 4.2)。没有必要使用标准库异常层次结构。
通常,一旦抛出异常,函数就没有办法完成其指定任务。这时,“处理”异常意味着做一些最小限度的本地清理然后重新抛出异常。例如:
void test(int n) {
try {
Vector v(n);
} catch (std::length_error&) { // 做些处理然后重新抛出
std::cerr << "test failed: length error\n";
throw; // 重新抛出
} catch (std::bad_alloc&) {// 哎呀!此程序未设计处理内存耗尽的情况
std::terminate(); // 终止程序
}
}
在设计良好的代码中, try 块是罕见的。通过系统地使用 RAII 技术(§ 5.2.2、§ 6.3)来避免过度使用。
不变性的概念对于类的设计至关重要,而在函数设计中,前置条件扮演着类似的角色:
- 制定不变性有助于我们精确理解所需的目标。
- 不变性迫使我们具体化设计;这使我们的代码更有可能正确无误。
不变性的概念支撑着 C++ 中由构造函数(第五章)和析构函数(§ 5.2.2、§ 15.2.1)支持的资源管理理念。
4.4 错误处理的其他选择
错误处理是所有现实世界软件中的重大问题,因此自然有多种方法可供选择。如果在一个函数中检测到错误,且该错误不能在该函数内部得到本地处理,那么该函数必须以某种方式将问题传达给调用者。抛出异常是 C++ 中用于此目的最通用的机制。
有些语言设计异常仅仅是为了提供一种返回值的替代机制,而C++并非如此设计:异常是用来报告特定任务失败的。异常与构造函数和析构函数相结合,为错误处理和资源管理提供了一个连贯的框架(§ 5.2.2、§ 6.3)。编译器优化使得返回一个值远比抛出相同值作为异常的成本要低。
抛出异常并不是报告无法本地处理错误的唯一方法。一个函数可以通过以下方式表明它无法执行分配的任务:
- 抛出异常
- 以某种方式返回表示失败的值
- 终止程序(通过调用类似 terminate() 、 exit() 或 abort() 的函数;§ 16.8)
我们返回错误指示符(即“错误码”)的情况包括:
- 失败是正常且预期的。例如,尝试打开文件失败是很常见的(可能没有这个名字的文件,或者可能无法按照请求的权限打开文件)。
- 直接调用者可以合理地预期并处理失败。
- 在一组并行任务中发生错误,我们需要知道哪个任务失败了。
- 系统内存非常有限,以至于异常的运行时支持会挤占重要功能的空间。
我们抛出异常的情况包括:
- 错误极为罕见,程序员很可能忘记检查它。例如,你上一次检查 printf() 的返回值是什么时候?
- 错误不能由直接调用者处理。相反,错误需要向上回溯调用链,直至“最终调用者”。例如,让应用程序中的每个函数可靠地处理每次分配失败和网络中断是不切实际的。反复检查错误码既繁琐又昂贵,且容易出错。错误检查和作为返回值传递错误码的测试很容易掩盖函数的主要逻辑。
- 应用程序的低层模块可以添加新的错误类型,使得高层模块不需要编写代码来应对这些错误。例如,当一个原本单线程的应用程序修改为使用多线程,或资源被放置在网络中远程访问时。
- 没有适合的返回路径来传递错误码。例如,构造函数没有“调用者”可以检查的返回值。特别地,构造函数可能被用于多个局部变量或在复杂对象的部分构造过程中被调用,因此基于错误码的清理会相当复杂。同样,操作符通常也没有明显的返回路径来传递错误码,例如 a*b+c/d 。
- 函数的返回路径因需要同时传递值和错误指示符而变得更加复杂或成本更高(例如,使用 pair ;§ 15.3.3),这可能导致使用输出参数、非局部错误状态指示器或其他变通方法。
- 从多个函数调用中恢复错误取决于它们的结果,导致需要在调用之间维护局部状态以及复杂的控制结构。
- 发现错误的函数是一个回调(函数参数),因此直接调用者甚至可能不知道调用了什么函数。
- 错误暗示需要进行某种“撤销操作”(§ 5.2.2)。
我们终止程序的情况包括:
- 遇到无法从中恢复的错误类型。例如,对于许多(但不是全部)系统而言,没有合理的途径从内存耗尽中恢复。
- 系统基于在检测到非平凡错误时重启线程、进程或计算机来处理错误。
确保终止的一种方法是在函数上添加 noexcept (§ 4.5.3),这样一来,函数实现中的任何地方抛出都会转化为 terminate() 。需要注意的是,有些应用程序不能接受无条件终止,因此必须使用其他替代方案。面向通用目的的库绝不应无条件终止。
不幸的是,这些条件并不总是逻辑上互斥且易于应用。程序的大小和复杂度很重要。有时,随着应用程序的发展,权衡也会发生变化。这需要经验。当有疑问时,倾向于使用异常,因为它们的使用更易于扩展,且不需要外部工具来检查所有错误是否都已处理。
不要认为所有的错误码或所有的异常都是不好的;两者都有明确的使用场景。此外,不要相信异常处理慢的误解;在正确处理复杂或罕见错误条件,以及重复测试错误码时,异常处理往往更快。
使用异常进行简单和高效错误处理的关键是 RAII(§ 5.2.2、§ 6.3)。遍布着 try 块的代码往往反映了为错误码设计的错误处理策略中最糟糕的方面。
4.5 断言
目前,没有通用且标准的方式来编写可选的运行时检查,比如不变性、前置条件等。然而,对于许多大型程序来说,存在这样的需求:支持用户在测试期间依赖广泛的运行时检查,但在部署代码时只进行最少的检查。
目前,我们必须依赖于特殊的机制。有许多这样的机制。它们需要具备灵活性、通用性,并且在不启用时不会产生任何成本。这意味着概念上的简洁性和实现上的复杂性。以下是我使用过的一个方案:
enum class Error_action { ignore, throwing, terminating, logging };// 错误处理选项
constexpr Error_action default_Error_action = Error_action::throwing;// 默认选项
enum class Error_code { range_error, length_error };// 单独的错误类型
string error_code_name[] { "range error", "length error" };// 单独错误的名称
template<Error_action action = default_Error_action, class C>
constexpr void expect(C cond, Error_code x) // 如果期望的条件 "cond" 不成立,则采取 "action"
{
if constexpr (action == Error_action::logging)
if (!cond()) std::cerr << "expect() failure: " << int(x) << ' ' << error_code_name[int(x)] << '\n';
if constexpr (action == Error_action::throwing)
if (!cond()) throw x;
if constexpr (action == Error_action::terminating)
if (!cond()) std::terminate();
// 或不采取任何行动
}
乍一看,这可能令人困惑,因为我们尚未介绍使用的许多语言特性。但是,正如所需,它非常灵活且易于使用。例如:
double& Vector::operator[](int i)
{
expect([i,this] { return 0<=i && i<size(); }, Error_code::range_error);
return elem[i];
}
这会检查下标是否在范围内,如果不满足则采取默认行动,即抛出异常。预期成立的条件 0<=i&&i<size() 作为 lambda 表达式 [i,this]{return 0<=i&&i<size();} 传递给 expect() (§ 7.3.3)。 if constexpr 测试在编译时完成(§ 7.4.3),因此对于 expect() 的每一次调用,最多只执行一次运行时测试。将 action 设置为 Error_action::ignore ,则不采取任何行动,也不为 expect() 生成代码。
通过设置 default_Error_action ,用户可以根据程序的具体部署选择合适的行动,比如终止或记录日志。为了支持记录日志,需要定义一个 error_code_names 表。记录信息可以通过使用 source_location (§ 16.5)进一步改进。
在许多系统中,断言机制(如 expect() )提供了一个控制断言失败含义的单一控制点是非常重要的。在大型代码库中搜索时,实际上是在检查假设的 if 语句,通常是不切实际的。
4.5.1 assert() 函数
标准库提供了调试宏 assert() ,用于断言某个条件在运行时必须成立。例如:
void f(const char* p)
{
assert(p != nullptr); // 确保 p 不是空指针
// ...
}
如果 assert() 中的条件在“调试模式”下失败,程序将终止。如果不是调试模式,则不会检查 assert() 。这种方法相当原始且不够灵活,但通常总比没有任何检查要好。
assert() 的主要限制在于它依赖于编译器的预处理器定义。通常,当编译器以非调试模式(如 -O2 优化级别)构建程序时,所有 assert() 调用都会被移除,从而不会在生产环境中执行任何检查。这意味着无法在部署后动态调整断言行为,也无法获取断言失败的详细信息,除非有专门的日志记录机制或使用更复杂的自定义断言函数,如前面提到的 expect() 示例所示。
此外, assert() 主要用于开发者发现逻辑错误,确保内部程序状态符合预期,而不应该作为处理用户输入错误或外部系统故障的主要机制。在发布的产品代码中,更倾向于采用更细致的错误处理策略,如异常处理、错误码返回或者日志记录等。
4.5.2 静态断言
异常用于报告运行时发现的错误。如果错误能在编译时发现,通常更倾向于这样做。这就是类型系统和为用户自定义类型指定接口的设施存在的目的。然而,我们也可以对大多数编译时已知的属性执行简单检查,并将不符合预期的情况作为编译器错误消息报告。例如:
static_assert(4 <= sizeof(int), "integers are too small"); // 检查整数大小
如果 4 <= sizeof(int) 不成立,即在这个系统中 int 类型的大小不足 4 字节,这将输出 "integers are too small"。我们称这样的期望声明为 静态断言 。
静态断言机制可用于任何可以用常量表达式(§ 1.6)表达的事物。例如:
constexpr double C = 299792458; // 光速,单位 m/s
void f(double speed)
{
constexpr double local_max = 160.0 * 1000 / (60 * 60); // 160 km/h 转换为 m/s
static_assert(speed > C, "can't go that fast"); // 如果速度大于光速则报错
static_assert(local_max > C, "can't go that fast"); // 同上,但此处是常量表达式,故合法
// ...
}
一般来说, static_assert(A, S) 如果 A 不为真,则将 S 打印为编译器错误消息。如果你不想打印特定的消息,可以省略 S ,编译器会提供一个默认消息:
static_assert(4 > sizeof(int)); // 使用默认消息
默认消息通常是静态断言的源位置加上断言谓词的字符表示。
静态断言的一个重要用途是在泛型编程(§ 8.2、§ 16.4)中对作为参数使用的类型进行断言。这允许在编译时验证模板参数是否满足特定条件,从而避免了运行时错误,并提高了代码的健壮性和类型安全。
4.5.3 noexcept
声明永不抛出异常的函数可以使用 noexcept 标识。例如:
void user(int sz) noexcept
{
Vector v(sz);
iota(v.begin(), v.end(), 1); // 从1开始递增填充 v(§ 17.3)
// ...
}
如果所有良好的意图和规划都失败了,以至于 user() 仍然抛出了异常,那么将调用 std::terminate() 立即终止程序。
不加思考地在函数上随意使用 noexcept 是危险的。如果一个标记了 noexcept 的函数调用了另一个抛出异常的函数,并期望该异常会被捕获并处理,那么 noexcept 会将这种情况转变为致命错误。此外, noexcept 迫使编写者通过某种形式的错误码来处理错误,这可能会很复杂、容易出错且成本较高(§ 4.4)。像其他强大的语言特性一样, noexcept 应当在充分理解并谨慎的情况下使用。
4.6 建议
- 当无法完成指定任务时,抛出异常; §4.4;[CG: E.2]。
- 仅在错误处理时使用异常; §4.4;[CG: E.3]。
- 未能打开文件或未到达迭代结束是预期事件,而非异常情况; §4.4。
- 当期望立即调用者处理错误时,使用错误码; §4.4。
- 对于预期会渗透多层函数调用的错误,抛出异常; §4.4。
- 如果对使用异常还是错误码有疑问,优先选择异常; §4.4。
- 在设计初期制定错误处理策略; §4.4;[CG: E.12]。
- 使用专门设计的用户自定义类型作为异常(而非内置类型); §4.2。
- 不要尝试在每个函数中捕获所有异常; §4.4;[CG: E.7]。
- 不必使用标准库的异常类层次结构; §4.3。
- 优先使用RAII而非显式的 try 块; §4.2, §4.3;[CG: E.6]。
- 让构造函数建立不变性,并在无法建立时抛出异常; §4.3;[CG: E.5]。
- 围绕不变性设计你的错误处理策略; §4.3;[CG: E.4]。
- 能在编译时检查的通常最好在编译时检查; §4.5.2 [CG: P.4] [CG: P.5]。
- 使用断言机制为失败的意义提供单一控制点; §4.5。
- 概念( §8.2)是编译时谓词,因此在断言中很有用; §4.5.2。
- 如果你的函数可能不抛出异常,声明它为 noexcept ; §4.4;[CG: E.12]。
- 不要不加思索地应用 noexcept ; §4.5.3。