Cpp学习12 第 9 章 错误检测与处理

铁名_IronName Lv5

9.1 — 代码测试简介

将代码的一小部分单独进行测试,以确保该“单元”代码正确无误,这称为单元测试 。每个单元测试都旨在确保该单元的特定行为正确无误。

将程序编写成小的、定义明确的单元(函数或类),经常编译,并随时测试代码。

自动化测试功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <iostream>

bool isLowerVowel(char c)
{
switch (c)
{
case 'a':
case 'e':
case 'i':
case 'o':
case 'u':
return true;
default:
return false;
}
}

// returns the number of the test that failed, or 0 if all tests passed
int testVowel()
{
if (!isLowerVowel('a')) return 1;
if (isLowerVowel('q')) return 2;

return 0;
}

int main()
{
int result { testVowel() };
if (result != 0)
std::cout << "testVowel() test " << result << " failed.\n";
else
std::cout << "testVowel() tests passed.\n";

return 0;
}

更好的方法是使用 assert ,如果任何测试失败,程序将中止并显示错误信息。这样我们就无需创建和管理测试用例编号了。将在第 9.6 课中介绍 assert

由于编写函数来测试其他函数非常常见且实用,因此存在一些专门的框架(称为单元测试框架 )来简化编写、维护和执行单元测试的过程。

Q: When should you start testing your code?
A: As soon as you’ve written a non-trivial function.

9.2 — 代码覆盖率

代码覆盖率是指在测试过程中程序源代码的执行比例。代码覆盖率的衡量指标有很多种。
语句覆盖率是指代码中已被测试程序执行的语句百分比。
分支覆盖率是指已执行分支的百分比,每个可能的分支都单独计算。

力争实现代码 100% 的分支覆盖率。

循环覆盖率 (俗称 0、1、2 测试 )是指,如果你的代码中有一个循环,你应该确保它在迭代 0 次、1 次和 2 次时都能正常工作。如果它在迭代 2 次的情况下工作正常,那么它在所有大于 2 次的迭代次数下也应该工作正常。因此,这三个测试涵盖了所有可能性(因为循环的执行次数不可能为负数)。

9.3 — C++ 中常见的语义错误

条件逻辑错误

语义错误中最常见的类型之一是条件逻辑错误。当程序员错误地编写条件语句或循环条件的逻辑时,就会发生条件逻辑错误 。

无限循环

1
2
3
4
5
6
7
for (unsigned int count{ 5 }; count >= 0; --count)
{
if (count == 0)
std::cout << "blastoff! ";
else
std::cout << count << ' ';
}

差一错误

差一 off-by-one 错误是指循环执行次数过多或过少一次时发生的错误。

运算符优先级错误

浮点类型的精度问题

整数除法

意外的空语句

需要复合语句时却未使用复合语句

在条件语句中使用赋值语句而不是相等语句。

调用函数时忘记使用函数调用运算符

不使用函数名直接引用函数通常会得到一个指向函数地址的函数指针。这样的函数指针会隐式地转换为 bool 值。

9.4 — 检测和处理错误

许多新手程序员写完代码后,只测试正常流程 :也就是没有错误的情况。但你也应该规划并测试异常流程 ,也就是那些可能出错的流程。在 3.10 课——“在问题出现之前发现它们” 中,我们将防御性编程定义为尝试预测软件可能被误用的所有方式,无论是最终用户还是开发人员(程序员本人或其他人员)。一旦你预测到(或发现)了某种误用,下一步就是处理它。

1
2
3
4
5
6
7
8
9
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 是一个哨兵值,表示函数执行失败。调用者可以检查返回值是否与哨兵值匹配——如果匹配,则调用者知道函数执行失败。虽然函数通常会直接返回哨兵值,但返回一个描述哨兵值的常量可以提高代码的可读性。

Fatal errors  致命错误

如果错误非常严重,导致程序无法继续正常运行,则称为不可恢复错误(也称为致命错误 )。在这种情况下,最好的做法是终止程序。

Exceptions

基本思路是,当发生错误时,会抛出一个异常。如果当前函数没有捕获到该错误,则该函数的调用者有机会捕获到该错误。如果调用者也没有捕获到该错误,则调用者的再调用者有机会捕获到该错误。错误会沿着调用栈向上移动,直到被捕获并处理(此时程序正常执行),或者直到 main() 函数无法处理该错误(此时程序会因异常而终止)。

何时使用 std::cout 、 std::cerr 或 logging

以下是一些经验法则:

  • 对于所有常规的、面向用户的文本,请使用 std::cout 。
  • 对于交互式程序,使用 std::cout 面向用户的常规错误信息(例如“您的输入无效”)。使用 std::cerr 或日志文件输出状态和诊断信息,这些信息可能有助于诊断问题,但对普通用户来说可能并不重要。这些信息可以包括技术警告和错误(例如函数 x 的输入错误)、状态更新(例如文件 x 已成功打开,连接到互联网服务 x 失败)、长时间任务的完成百分比(例如编码已完成 50%)等等。
  • 对于非交互式程序(工具或服务),仅使用 std::cerr 输出错误信息(例如:无法打开文件 x)。这样可以将错误信息与正常输出分开显示或解析。
  • 对于任何具有事务性的应用程序类型(例如处理特定事件的应用程序,如交互式 Web 浏览器或非交互式 Web 服务器),请使用日志文件生成事务事件日志,以便稍后查看。这可以包括将正在处理的文件、完成百分比更新、计算特定阶段的开始时间戳、警告和错误消息等输出到日志文件中。

9.5 — std::cin 和处理无效输入

编写程序时,应始终考虑用户可能(有意或无意地)如何误用程序。优秀的程序会预先考虑到用户可能出现的误用情况,并妥善处理这些情况,或者尽可能从一开始就防止它们发生。能够很好地处理错误情况的程序被称为健壮 robust 程序。

验证输入

检查用户输入是否符合程序预期结果的过程称为输入验证 。

输入验证有三种基本方法:

内联显示(用户输入时显示):

  1. 首先防止用户输入无效内容。

输入后(用户输入之后):
2. 让用户在字符串中输入任何内容,然后验证字符串是否正确,如果正确,则将字符串转换为最终的变量格式。
3. 让用户输入他们想要的任何内容,让 std::cin 和 operator>> 尝试提取它,并处理错误情况。

无效文本输入类型

我们通常可以将输入文本错误分为四种类型:

  • 输入提取成功,但输入对程序来说没有意义(例如,输入“k”作为数学运算符)。
  • 输入提取成功,但用户输入了额外的输入(例如,输入“*q hello” 作为数学运算符)。
  • 输入提取失败(例如,尝试在数字输入框中输入“q”)。
  • 输入提取成功,但用户输入的数值超出了限制。

处理办法,略。

显然做游戏客户端不太会用到。

9.6 — Assert 和 static_assert

前提条件、不变量和后置条件

Preconditions, invariants, and postconditions
在编程中, 前提条件是指在执行某段代码(通常是函数体)之前必须为真的任何条件。
不变量是指在代码执行期间必须为真的条件。它常用于循环中,循环体只有在不变式为真时才会执行。
类似地, 后置条件是指在执行完一段代码后必须成立的条件。

Assertions  断言

断言是一个表达式,除非程序中存在错误,否则它始终为真。如果表达式的计算结果为 true ,则断言语句不执行任何操作。如果条件表达式的计算结果为 false ,则会显示错误消息并终止程序(通过 std::abort )。此错误消息通常包含失败的表达式文本,以及代码文件名和断言所在的行号。这使得我们不仅能够轻松确定问题所在,还能确定问题在代码中的位置。这对于调试工作大有裨益。

在 C++ 中,运行时断言是通过 assert 预处理器宏实现的,该宏位于头文件<cassert>中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <cassert> // for assert()
#include <cmath> // for std::sqrt
#include <iostream>

double calculateTimeUntilObjectHitsGround(double initialHeight, double gravity)
{
assert(gravity > 0.0); // The object won't reach the ground unless there is positive gravity.

if (initialHeight <= 0.0)
{
// The object is already on the ground. Or buried.
return 0.0;
}

return std::sqrt((2.0 * initialHeight) / gravity);
}

int main()
{
std::cout << "Took " << calculateTimeUntilObjectHitsGround(100.0, -9.8) << " second(s)\n";

return 0;
}

你可以使用一个小技巧来使你的断言语句更具描述性。只需添加一个字符串字面量,并用逻辑 AND 连接即可:

1
assert(found && "Car could not be found in database");

当断言触发时,字符串字面量将包含在断言消息中:
Assertion failed: found && “Car could not be found in database”, file C:\VCProjects\Test.cpp, line 34

断言有时也用于记录那些由于程序员编写代码时不需要而未实现的用例:

1
assert(moved && "Need to handle case where student was just moved to another classroom");

如果开发人员遇到需要这种情况的情况,代码就会报错并显示有用的错误消息,然后程序员就可以确定如何实现这种情况。

NDEBUG

assert 宏会带来一定的性能开销,每次检查断言条件时都会产生这种开销。此外,断言(理想情况下)不应该出现在生产代码中(因为代码应该已经过全面测试)。因此,大多数开发人员倾向于仅在调试版本中启用断言。C++ 提供了一种内置方法来禁用生产代码中的断言:如果定义了预处理器宏 NDEBUG ,则断言宏将被禁用。

大多数 IDE 默认会将 NDEBUG 作为发布配置项目设置的一部分。例如,在 Visual Studio 中,项目级别会设置以下预处理器定义: WIN32;NDEBUG;_CONSOLE 。如果您使用的是 Visual Studio,并且希望断言在发布版本中触发,则需要从该设置中移除 NDEBUG 。

static_assert   静态断言

C++ 还有另一种断言类型,称为 static_assert 是一种在编译时而非运行时进行检查的断言,如果 static_assert 失败  则会导致编译错误。与在头文件中声明的 assert 不同,static_assert 是一个关键字,因此无需包含任何头文件即可使用它。

static_assert 采用以下形式:
static_assert(condition, diagnostic_message)

关于 static_assert 一些实用说明:

  • 因为 static_assert 由编译器进行求值,所以条件必须是常量表达式。
  • static_assert 可以放在代码文件中的任何位置(甚至在全局命名空间中)。
  • 在发布版本中, static_assert 不会被禁用(就像普通的 assert 会被禁用一样)。
  • 因为编译器会进行求值,所以 static_assert 没有运行时开销。

在 C++17 之前,诊断信息必须作为第二个参数提供。从 C++17 开始,提供诊断信息是可选的。

static_assert 在发布(Release)构建中不会被禁用(而普通的 assert 会被禁用)。

断言与错误处理

断言用于在开发过程中检测 编程错误, 它通过记录那些不应该发生的事情的假设来实现。如果这些事情真的发生了,那就是程序员的错。断言不允许从错误中恢复(毕竟,如果某件事不应该发生,那就没有必要恢复)。由于断言通常会在发布版本中被编译掉,因此你可以大量使用它们而不用担心性能问题,所以几乎没有理由不大量使用它们。

错误处理用于优雅地处理发布版本中可能发生的(即使很少发生)的情况。这些情况可能是可恢复的问题(程序可以继续运行),也可能是不可恢复的问题(程序必须关闭,但我们至少可以显示友好的错误信息并确保所有问题都得到妥善清理)。错误检测和处理既会影响运行时性能,也会增加开发时间。

在某些情况下,我们应该怎么做并不那么明确。考虑如下函数:

1
2
3
4
double getInverse(double x)
{
return 1.0 / x;
}

如果 x 为 0.0 ,此函数将运行异常,我们需要防止这种情况发生。我们应该使用断言还是错误处理?最佳答案可能是“两者都用”。

1
2
3
4
5
6
7
8
double getInverse(double x)
{
assert(x != 0.0);
if (x == 0.0)
// handle error somehow (e.g. throw an exception)

return 1.0 / x;
}

一些断言限制和警告

断言存在一些陷阱和局限性。首先,断言本身可能编写不当。如果发生这种情况,断言要么会在不存在错误的地方报告错误,要么会在存在错误的地方漏报错误。
其次,你的 assert() 表达式不应该有任何副作用,因为当定义了 NDEBUG 时,assert 表达式不会被求值(因此副作用也不会生效)。否则,你在调试配置中测试的内容将与发布配置中测试的内容不同(假设你发布了带有 NDEBUG 的版本)。
另请注意, abort() 函数会立即终止程序,没有机会进行任何后续清理工作(例如关闭文件或数据库)。因此,断言仅应在程序意外终止不太可能造成数据损坏的情况下使用。

9.x — 第九章总结和测验

更新之前的解决方案,使其能够处理无效猜测(例如“x”)、超出范围的猜测(例如 0 或 101 )或包含多余字符的有效猜测(例如 43x )。此外,还要处理游戏询问用户是否要再次游戏时,用户输入额外字符的情况。
编写一个单独的函数来处理用户输入他们的猜测(以及相关的错误处理)。
略。

  • 标题: Cpp学习12 第 9 章 错误检测与处理
  • 作者: 铁名_IronName
  • 创建于 : 2026-08-09 11:17:34
  • 更新于 : 2026-08-09 12:11:32
  • 链接: https://blog.ironname.top/2026/Cpp/Cpp学习12/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论