在课程 9.3 -- C++ 中常见的语义错误 中,我们介绍了许多 C++ 新手程序员在使用该语言时遇到的常见 C++ 语义错误类型。如果错误是由于误用语言特性或逻辑错误造成的,则可以简单地纠正该错误。
但是程序中的大多数错误并非由于无意中误用语言特性而发生——相反,大多数错误是由于程序员的错误假设和/或缺乏适当的错误检测/处理而发生的。
例如,在旨在查找学生分数的函数中,您可能假设:
- 所查找的学生将存在。
- 所有学生姓名都将是唯一的。
- 该课程使用字母评分(而非及格/不及格)。
如果这些假设中的任何一个不成立会怎样?如果程序员没有预料到这些情况,当这些情况出现时(通常在将来某个时间点,函数编写很久之后),程序很可能会出现故障或崩溃。
通常发生假设错误的关键有三个地方:
- 当函数返回时,程序员可能假设被调用的函数成功了,而实际上并没有。
- 当程序接收输入(来自用户或文件)时,程序员可能假设输入格式正确且语义有效,而实际上并非如此。
- 当函数被调用时,程序员可能假设参数是语义有效的,而实际上并非如此。
许多新手程序员编写代码后只测试**“快乐路径”**:即没有错误的情况。但是你也应该为**“悲伤路径”**做计划和测试,因为这些情况下事情可能会出错,而且确实会出错。在课程 3.10 -- 在问题发生之前发现问题 中,我们将**防御性编程**定义为尝试预测软件可能被误用的所有方式的实践,无论是被最终用户还是被开发者(无论是程序员自己还是其他人)。一旦你预测到(或发现)了某种误用,接下来要做的就是处理它。
在本课中,我们将讨论函数内部的错误处理策略(当出现问题时该怎么做)。在随后的课程中,我们将讨论验证用户输入,然后介绍一个有用的工具来帮助记录和验证假设。
函数中的错误处理
函数可能因多种原因而失败——调用者可能传入了无效值的参数,或者函数主体内可能出现故障。例如,一个打开文件以供读取的函数如果找不到文件可能会失败。
发生这种情况时,您有多种选择。没有处理错误的最佳方法——这实际上取决于问题的性质以及问题是否可以修复。
有 4 种常用的策略:
- 在函数内处理错误
- 将错误传回给调用者处理
- 中止程序
- 抛出异常
在函数内处理错误
如果可能,最好的策略是在错误发生的同一函数中从错误中恢复,这样错误就可以被包含和纠正,而不会影响函数之外的任何代码。这里有两种选择:重复尝试直到成功,或者取消正在执行的操作。
如果错误是由于程序无法控制的原因发生的,程序可以反复尝试直到成功。例如,如果程序需要互联网连接,并且用户失去了连接,程序可以显示警告,然后使用循环定期重新检查互联网连接。或者,如果用户输入了无效输入,程序可以要求用户再试一次,并循环直到用户成功输入有效输入。我们将在下一课(9.5 -- std::cin 和处理无效输入)中展示处理无效输入和使用循环重试的示例。
另一种策略是直接忽略错误和/或取消操作。例如:
// Silent failure if y=0
void printIntDivision(int x, int y)
{
if (y != 0)
std::cout << x / y;
}
在上面的例子中,如果用户为 `y` 传入了无效值,我们只是忽略了打印除法结果的请求。这样做主要挑战在于调用者或用户无法得知出了问题。在这种情况下,打印错误消息会有所帮助:
void printIntDivision(int x, int y)
{
if (y != 0)
std::cout << x / y;
else
std::cout << "Error: Could not divide by zero\n";
}
然而,如果调用函数期望被调用函数产生返回值或一些有用的副作用,那么仅仅忽略错误可能不是一个选择。
将错误传回给调用者
在许多情况下,错误无法在检测到错误的函数中合理处理。例如,考虑以下函数:
int doIntDivision(int x, int y)
{
return x / y;
}
如果 `y` 是 `0`,我们该怎么办?我们不能仅仅跳过程序逻辑,因为函数需要返回某个值。我们不应该要求用户为 `y` 输入一个新值,因为这是一个计算函数,并且在其中引入输入例程可能不适合调用此函数的程序。
在这种情况下,最好的选择可能是将错误传回给调用者,希望调用者能够处理它。
我们该如何做到呢?
如果函数返回类型为 `void`,则可以将其更改为返回 `bool` 值以指示成功或失败。例如,不是:
void printIntDivision(int x, int y)
{
if (y != 0)
std::cout << x / y;
else
std::cout << "Error: Could not divide by zero\n";
}
我们可以这样做:
bool printIntDivision(int x, int y)
{
if (y == 0)
{
std::cout << "Error: could not divide by zero\n";
return false;
}
std::cout << x / y;
return true;
}
这样,调用者就可以检查返回值,看看函数是否因某种原因失败了。
如果函数返回一个正常值,事情会稍微复杂一些。在某些情况下,返回值的完整范围并未被使用。在这种情况下,我们可以使用一个正常情况下不可能发生的返回值来表示错误。例如,考虑以下函数:
// The reciprocal of x is 1/x
double reciprocal(double x)
{
return 1.0 / x;
}
某个数字 `x` 的倒数定义为 `1/x`,一个数字乘以它的倒数等于 1。
然而,如果用户将此函数调用为 `reciprocal(0.0)` 会发生什么?我们得到一个 `除以零` 错误,程序崩溃,所以显然我们应该防止这种情况。但是这个函数必须返回一个双精度值,所以我们应该返回什么值呢?事实证明,这个函数永远不会产生 `0.0` 作为合法结果,所以我们可以返回 `0.0` 来表示错误情况。
// The reciprocal of x is 1/x, returns 0.0 if x=0
constexpr double error_no_reciprocal { 0.0 }; // could also be placed in namespace
double reciprocal(double x)
{
if (x == 0.0)
return error_no_reciprocal;
return 1.0 / x;
}
**哨兵值**是在函数或算法上下文中具有特殊意义的值。在上面我们的 `reciprocal()` 函数中,`0.0` 是一个表示函数失败的哨兵值。调用者可以测试返回值,看它是否与哨兵值匹配——如果是,则调用者知道函数失败了。虽然函数通常直接返回哨兵值,但返回一个描述哨兵值的常量可以增加可读性。
然而,如果函数可以生成完整的返回值范围,那么使用哨兵值来表示错误就会有问题(因为调用者将无法判断返回值是有效值还是错误值)。
相关内容
在这种情况下,返回 `std::optional` (或 `std::expected`) 是更好的选择。我们在课程 12.15 -- std::optional 中介绍 `std::optional`。
致命错误
如果错误非常严重,以至于程序无法正常继续运行,这被称为**不可恢复的**错误(也称为**致命错误**)。在这种情况下,最好的做法是终止程序。如果您的代码在 `main()` 中或直接从 `main()` 调用的函数中,最好的做法是让 `main()` 返回非零状态码。但是,如果您处于某个嵌套子函数的深层,则将错误一直传播回 `main()` 可能不方便或不可能。在这种情况下,可以使用**中止语句**(例如 `std::exit()`)。
例如
double doIntDivision(int x, int y)
{
if (y == 0)
{
std::cout << "Error: Could not divide by zero\n";
std::exit(1);
}
return x / y;
}
异常
因为将错误从函数返回给调用者很复杂(而且多种不同的方式会导致不一致,不一致又会导致错误),C++ 提供了一种完全独立的方式将错误传回给调用者:`异常`。
基本思想是,当发生错误时,会“抛出”一个异常。如果当前函数没有“捕获”该错误,则该函数的调用者有机会捕获该错误。如果调用者没有捕获该错误,则调用者的调用者有机会捕获该错误。错误会逐步向上移动调用堆栈,直到它被捕获并处理(此时执行正常继续),或者直到 `main()` 未能处理该错误(此时程序以异常错误终止)。
我们将在本教程系列的 第 27 章 中介绍异常处理。
何时使用 `std::cout`、`std::cerr` 和日志记录
在课程 3.4 -- 基本调试策略 中,我们介绍了 `std::cerr`。您可能想知道何时(或是否)应该使用 `std::cerr`、`std::cout` 或记录到文本文件。
默认情况下,`std::cout` 和 `std::cerr` 都将文本打印到控制台。然而,现代操作系统提供了一种将输出流重定向到文件的方法,以便可以捕获输出以供以后查看或自动化处理。
对于这个讨论,区分两种类型的应用程序很有用:
- **交互式应用程序**是用户运行后将与之交互的应用程序。大多数独立应用程序,如游戏和音乐应用程序,都属于此类别。
- **非交互式应用程序**是不需要用户交互即可操作的应用程序。这些程序的输出可能被用作另一个应用程序的输入
在非交互式应用程序中,有两种类型:
- **工具**是非交互式应用程序,通常启动以产生一些即时结果,然后在产生结果后终止。一个例子是 Unix 的 grep 命令,它是一个搜索文本以查找匹配某种模式的行的实用程序。
- **服务**是非交互式应用程序,通常在后台静默运行以执行某些持续功能。一个例子是病毒扫描程序。
这里有一些经验法则:
- 将 `std::cout` 用于所有常规的、面向用户的文本。
- 对于交互式程序,将 `std::cout` 用于正常的面向用户的错误消息(例如,“您的输入无效”)。将 `std::cerr` 或日志文件用于可能有助于诊断问题但普通用户可能不感兴趣的状态和诊断信息。这可能包括技术警告和错误(例如,函数 x 的错误输入)、状态更新(例如,成功打开文件 x,连接到互联网服务 x 失败)、长时间任务的完成百分比(例如,编码完成 50%)等……
- 对于非交互式程序(工具或服务),仅将 `std::cerr` 用于错误输出(例如,无法打开文件 x)。这允许错误与正常输出分开显示或解析。
- 对于任何事务性质的应用程序类型(例如,处理特定事件的应用程序,如交互式 Web 浏览器或非交互式 Web 服务器),使用日志文件生成事件的事务日志,以便以后查看。这可能包括将正在处理的文件、完成百分比的更新、开始某些计算阶段的时间戳、警告和错误消息等输出到日志文件。