在C++ 常见语义错误中,我们已讨论过新手常犯的大量 C++ 语义错误。若错误源于语言特性误用或逻辑错误,只需直接修正即可。
然而,程序中的绝大多数错误并非源于无意间误用语言特性,而是源于程序员的错误假设,或缺乏必要的错误检测与处理。
例如,在某个用于查询学生成绩的函数里,你可能默认:
- 待查询的学生一定存在;
- 所有学生姓名均唯一;
- 该课程采用字母评分(而非及格/不及格)。
若上述假设任一不成立,而程序员又未预见这些情况,程序便可能在将来某个时刻出现故障甚至崩溃,且此时距函数编写完成已过去许久。
假设错误通常出现在以下三个关键环节:
- 函数返回时,程序员假设被调函数一定成功,实则可能失败;
- 程序接收输入(来自用户或文件)时,程序员假设输入格式正确且语义有效,实则未必;
- 函数被调用时,程序员假设实参语义有效,实则不然。
许多新手仅测试“快乐路径”(happy path)——即无任何错误的场景,却忽视了“悲伤路径”(sad path)——即各种可能出错的场景。在 3.10 课 —— 在问题出现前发现问题中,我们将“防御式编程”定义为:预先设想软件可能被终端用户或开发者(自己或他人)误用的所有方式。一旦预见(或发现)某种误用,下一步便是加以处理。
本课将讨论函数内部发生错误时的处理策略;后续课程再讨论用户输入验证,并介绍一种有助于记录和校验假设的实用工具。
函数中的错误处理
函数可能因多种原因失败:调用者传入无效实参,或函数体内部某步骤失败。例如,打开文件进行读取的函数在文件不存在时即会失败。
此时,你有多种可选策略,并无放之四海而皆准的最佳方案——须视问题性质及可否恢复而定。总体上有四种通用策略:
- 在函数内部自行处理错误;
- 将错误回传调用者处理;
- 终止程序;
- 抛出异常。
1. 在函数内部处理错误
若可行,最佳策略是在检测到错误的同一函数内恢复,从而将错误局限并修正,避免波及其他代码。此时有两种做法:重试直至成功,或取消当前操作。
若错误原因超出程序控制范围,可反复重试直至成功。例如,程序需要网络连接,而用户暂时断网,则可提示警告,并用循环定期重检网络。又如用户输入无效,可提示重新输入,直至其输入有效值。下一课 9.5 —— std::cin 与无效输入处理将给出相关示例。
另一策略是直接忽略错误或取消操作。例如:
```cpp
// 若 y==0,则静默失败
void printIntDivision(int x, int y)
{
if (y != 0)
std::cout << x / y;
}
若用户为 y 传入了无效值,函数直接放弃打印除法结果。其最大缺陷是调用者与用户无从知晓发生了错误。此时输出错误提示会更有帮助:
```cpp
```cpp
void printIntDivision(int x, int y)
{
if (y != 0)
std::cout << x / y;
else
std::cout << "错误:除零无法进行\n";
}
但如果调用方期望函数返回结果或产生某些副作用,则简单忽略错误往往不可取。
### 2. 将错误回传调用者
很多情况下,检测到错误的函数无法合理地就地处理。例如:
```cpp
```cpp
int doIntDivision(int x, int y)
{
return x / y;
}
若 y 为 0,该如何处理?既不能跳过逻辑(函数必须返回值),也不应在此函数内请求用户重新输入 y,因为这会破坏计算函数的纯粹性,且未必适合调用场景。
此时,最佳做法是把错误回传调用者,由其决定如何处置。具体做法:
1. 若函数返回类型为 void,可改为返回 bool 表示成功/失败。例如:
```cpp
```cpp
bool printIntDivision(int x, int y)
{
if (y == 0)
{
std::cout << "错误:除零无法进行\n";
return false;
}
std::cout << x / y;
return true;
}
调用者可检查返回值以判断函数是否失败。
2. 若函数原本返回正常值,但返回值范围并未用尽,则可选取一个正常情况下不可能出现的值作为错误标志。例如:
```cpp
```cpp
// 求倒数 1/x
double reciprocal(double x)
{
return 1.0 / x;
}
若用户调用 `reciprocal(0.0)`,将触发除零崩溃。需防范此情况,但函数必须返回 double。由于该函数永远不会返回 0.0 作为合法结果,因此可用 0.0 表示错误:
```cpp
```cpp
constexpr double error_no_reciprocal { 0.0 };
double reciprocal(double x)
{
if (x == 0.0)
return error_no_reciprocal;
return 1.0 / x;
}
哨兵值(sentinel value)即在函数或算法上下文中具有特殊含义的值。上例中 0.0 即为哨兵值。调用者可检测返回值是否等于该哨兵值,以判断函数是否失败。
若函数返回值范围已全部用于合法结果,则不宜再用哨兵值区分错误,此时应使用 `std::optional`(或 `std::expected`)。相关内容见 12.15 课 —— std::optional。
### 3. 致命错误
若错误严重到程序无法继续正常运行,则称为不可恢复错误(或称致命错误)。此时最佳做法是终止程序。
- 若错误发生在 `main()` 或由 `main()` 直接调用的函数内,可直接让 `main()` 返回非零状态码。
- 若错误发生在深层嵌套函数,不便逐级回传,则可使用 `std::exit()` 等终止语句。
示例:
```cpp
```cpp
double doIntDivision(int x, int y)
{
if (y == 0)
{
std::cout << "错误:除零无法进行\n";
std::exit(1);
}
return x / y;
}
### 4. 异常机制
由于通过返回值回传错误方式繁琐,且实现方式多样易导致不一致和错误,C++ 提供了另一种独立机制:异常(exception)。
基本思想:错误发生时“抛出”异常。若当前函数未“捕获”,则沿调用链逐层回溯,直至被捕获并处理(之后正常继续执行);若直至 `main()` 仍未捕获,则程序终止并输出异常信息。
异常处理将在本教程第 27 章详述。
## 日志与输出流的选择实践
在 3.4 课 —— 基本调试技巧中,我们介绍了 `std::cerr`。你可能疑惑何时(或是否)应使用 `std::cerr`、`std::cout` 或写入日志文件。
默认情况下,`std::cout` 与 `std::cerr` 均将文本输出至控制台。但现代操作系统支持重定向输出流至文件,以便后续复查或自动化处理。
为便于讨论,可将应用程序分为两类:
- 交互式应用:运行后用户需与之交互。大多数独立应用(如游戏、音乐播放器)均属此类。
- 非交互式应用:无需用户交互即可运行。其输出可被其他应用作为输入。
非交互式应用又分两种:
- 工具:通常启动后立即产生结果并终止。如 Unix 的 `grep`。
- 服务:通常在后台静默运行,执行持续任务。如病毒扫描器。
经验法则如下:
- 所有面向用户的常规文本,使用 `std::cout`。
- 交互式程序:
- 用户可见的错误提示(如“输入无效”)用 `std::cout`;
- 用于诊断的状态信息、技术警告、错误、进度百分比等,用 `std::cerr` 或日志文件。
- 非交互式程序(工具或服务):
- 错误输出仅使用 `std::cerr`,以便错误可与正常输出分离显示或解析。
- 任何具有事务性质的应用(如交互式浏览器或非交互式服务器),应使用日志文件记录事件流,便于后续审查,包括:处理的文件名、进度百分比、各阶段时间戳、警告与错误信息等。