9-4 检测与处理错误

第9.3节——C++中的常见语义错误中,我们探讨了初学C++程序员常遇到的多种常见语义错误类型。若错误源于语言特性的误用或逻辑错误,则可直接修正。

但程序中的多数错误并非源于无意间误用语言特性——更多是由于程序员的错误假设和/或缺乏完善的错误检测/处理机制所致。

例如,在设计查询学生成绩的函数时,你可能曾这样假设:

  • 被查询的学生必须存在。
  • 所有学生姓名必须唯一。
  • 该课程采用字母评分制(而非及格/不及格制)。

如果这些假设中的任何一个不成立会怎样?如果程序员未能预见到这些情况,当此类情况发生时(通常在函数编写完成很久之后的某个时刻),程序很可能会出现故障或崩溃。

假设错误通常发生在三个关键位置:

  • 当函数返回时,程序员可能误以为被调用的函数已成功执行,实则不然。
  • 当程序接收输入(无论是来自用户还是文件)时,程序员可能误以为输入格式正确且语义有效,实则不然。
  • 当函数被调用时,程序员可能误以为参数语义有效,实则不然。

许多新手程序员编写代码后,只测试成功路径happy path:即没有错误的情况。但你也应该规划并测试失败路径sad paths——那些可能出错且必然出错的情形。在第3.10课——在问题出现之前就发现它们中,我们定义了防御性编程defensive programming:即预判软件可能遭遇的所有滥用场景,无论是终端用户还是开发者(包括程序员自身或其他开发者)造成的滥用。当预判(或发现)滥用情况后,下一步便是制定应对方案。

本节将探讨函数内部的错误处理策略(异常情况下的应对方案)。后续课程将深入用户输入验证机制,并引入一款实用工具来辅助记录和验证程序假设。


函数中的错误处理

函数可能因多种原因失败——调用者可能传递了无效实参,或函数体内出现执行错误。例如,若文件无法被找到,用于读取文件的函数便可能失败。

此时可采取多种应对方案。错误处理并无标准答案——具体策略取决于问题性质及是否可修复。

可采用以下四种通用策略:

  • 在函数内部处理错误
  • 将错误传递给调用方处理
  • 终止程序
  • 抛出异常

在函数内部处理错误

若条件允许,最佳策略是在错误发生时于同一函数内进行恢复,这样既能控制错误范围,又可避免影响函数外部的代码。具体有两种处理方式:持续重试直至成功,或直接取消当前操作。

若错误源于程序无法控制的因素,程序可通过循环尝试直至成功。例如:当程序需要网络连接而用户断开连接时,程序可显示警告并使用循环定期重试网络连接。若用户输入无效数据,程序可提示用户重新输入,并循环直至用户成功输入有效数据。下节课程(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 返回类型,可改为返回布尔值以指示成功或失败。例如将以下代码:

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;
}

哨兵值sentinel value是在函数或算法上下文中具有特殊含义的值。在上文的reciprocal()函数中,0.0即为哨兵值,表示函数执行失败。调用方可通过检测返回值是否与哨兵值匹配来判断函数是否失败。虽然函数通常直接返回哨兵值,但返回描述哨兵值的常量能提升代码可读性。

然而,若函数可能返回完整值域中的任意值,此时使用哨兵值指示错误便存在问题(因为调用方无法区分返回值是有效值还是错误值)。

相关内容
在这种情况下,返回 std::optional(或 std::expected)会是更好的选择。我们在第 12.15 课——std::optional 中介绍了 std::optional。


致命错误

若错误严重到程序无法继续正常运行,则称为不可恢复错误non-recoverable(也称致命错误fatal error)。此类情况下,最佳处理方式是终止程序。若代码位于 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 都会将文本输出到控制台。但现代操作系统提供了将输出流重定向到文件的功能,这样就能捕获输出内容以便后续审查或自动化处理。

为便于讨论,我们需要区分两种应用场景:

  • 交互式应用程序Interactive applications是指运行后用户需要与其进行交互的应用程序。大多数独立应用程序,如游戏和音乐应用,都属于这一类。
  • 非交互式应用程序Non-interactive applications是指运行时无需用户交互的应用程序。这些程序的输出结果可作为其他应用程序的输入。

在非交互式应用程序中,存在两种类型:

  • 工具Tools是非交互式应用程序,通常启动后会立即产生某种结果,并在完成任务后终止运行。例如Unix系统的grep命令,这是一款用于在文本中搜索符合特定模式行数的实用程序。
  • 服务Services同样是非交互式应用程序,通常在后台静默运行以执行持续性功能。例如病毒扫描程序就属于此类服务。

以下是一些经验法则:

  • 所有常规的用户可见文本均应使用 std::cout 输出。
  • 交互式程序中,标准用户可见的错误信息(如“您的输入无效”)应通过 std::cout 显示。状态和诊断信息(可能有助于排查问题但对普通用户通常不重要)应使用 std::cerr 或日志文件输出。此类信息包括技术性警告和错误(例如函数 x 接收无效输入)、状态更新(例如成功打开文件 x,连接互联网服务 x 失败)、耗时任务的进度百分比(例如编码进度 50%)等。
  • 对于非交互式程序(工具或服务),仅将 std::cerr 用于错误输出(例如“无法打开文件 x”)。这可使错误信息与正常输出分离显示或解析。
  • 对于任何具有事务性质的应用程序类型(例如处理特定事件的应用,如交互式网页浏览器或非交互式网页服务器),应使用日志文件生成可供后续审查的事务日志。日志内容可包含:正在处理的文件信息、进度百分比更新、各计算阶段开始时间戳、警告与错误消息等。
posted @ 2026-03-01 12:09  游翔  阅读(1)  评论(0)    收藏  举报