错误检测和处理
1. 测试代码简介
软件测试(也称为软件验证)是确定软件是否真正按预期工作的过程。在讨论一些测试代码的实用方法之前,让我们先谈谈为什么全面测试你的程序很困难。考虑这个简单的程序:
1 |
|
使用所有可能的输入组合显式测试此程序将要求您运行该程序 18,446,744,073,709,551,616 (~18 千万亿) 次。显然,这不是一个可行的任务!
最好编写小函数(或类),然后立即编译和测试它们。这样,如果你犯了一个错误,你就会知道它一定是自上次编译 / 测试以来你更改的少量代码。这意味着要查找的位置更少,调试时间也少得多。单独测试代码的一小部分以确保代码的“单元”正确,这称为单元测试。每个单元测试都旨在确保单元的特定行为正确。
测试代码的一种方法是在编写程序时进行非正式测试。例如,对于以下 isLowerVowel()
函数,您可以编写以下代码:
1 |
|
保留测试:尽管编写临时测试是测试某些代码的一种快速简便的方法,但它并不能说明在某些时候,你可能希望稍后再次测试相同的代码。也许你修改了一个函数来添加新功能,并希望确保没有破坏任何已经正常工作的内容。因此,保留你的测试以便将来可以再次运行它们可能更有意义。例如,你可以将测试移动到 testVowel()
函数中,而不是擦除临时测试代码:
1 |
|
自动化你的测试函数:我们可以通过编写一个包含测试和预期答案的测试函数来做得更好,并将它们进行比较,这样我们就不必这样做了。示例:
1 |
|
更好的方法是使用 assert
,如果任何测试失败,这将导致程序中止并显示错误消息。我们不必以这种方式创建和处理测试用例编号。
1 |
|
由于编写函数来执行其他函数非常常见和有用,因此有一整套框架(称为单元测试框架)旨在帮助简化编写、维护和执行单元测试的过程。
2. 代码覆盖率
术语 代码覆盖率 用于描述在测试时执行了多少程序的源代码。有许多不同的指标用于代码覆盖率。我们将在以下部分中介绍一些更有用和流行的方法。术语 语句覆盖率 是指代码中由测试例程执行的语句的百分比。请考虑以下函数:
1 | int foo(int x, int y) |
调用这个函数时,如foo(1, 0)
,将为你提供该函数的完整语句覆盖率,因为函数中的每条语句都会被执行。对于我们的 isLowerVowel()
函数:
1 | bool isLowerVowel(char c) |
此函数需要两次调用来测试所有语句,因为无法在同一函数调用中到达语句 2 和 3。虽然以 100% 的语句覆盖率为目标很好,但通常不足以确保正确性。
分支覆盖率是指已执行的分支的百分比,每个可能的分支单独计数。if 语句有两个分支,一个在条件为 true
时执行的分支,以及一个在条件为 false
时执行的分支(即使没有相应的 else 语句要执行)。switch 语句可以有许多分支。
之前对 foo(1,0)
的调用为我们提供了 100% 的语句覆盖率,并执行了 x > y
的用例,但这只给了我们 50% 的分支覆盖率。我们需要再调用 foo(0,1)
来测试 if 语句未执行的用例。
现在考虑以下函数:
1 | void compare(int x, int y) |
此处需要3次调用才能实现100%的分支覆盖率:compare(1, 0)
测试了第一个if
语句的正向用例;compare(0, 1)
测试了第一个if
语句的负向用例以及第二个if
语句的正向用例;compare(0, 0)
测试了第一个和第二个if
语句的负向用例,并执行了else
语句。因此,可以说通过这3次调用,该函数得到了可靠的测试(这比1800亿亿次调用要高效得多)。
循环覆盖率(非正式地称为 0、1、2 测试)表示,如果你的代码中有一个循环,则应确保它在迭代 0 次、1 次和 2 次时正常工作。如果它对 2 次迭代情况正常工作,则它应该对大于 2 的所有迭代正常工作。因此,这三个测试涵盖了所有可能性(因为循环不能执行负数)。示例:
1 |
|
要正确测试此函数中的循环,您应该调用它三次:spam(0)
用于测试零迭代情况,spam(1)
用于测试一次迭代情况,spam(2)
用于测试两次迭代情况。如果 spam(2)
有效,那么 spam(n)
应该有效,其中 n > 2
.
在编写接受参数的函数时,或者在接收用户输入时,需要考虑不同类别输入的情况。在此上下文中,“类别”指的是具有相似特征的一组输入。
-
对于整数,请确保你已考虑函数如何处理负值、零和正值。如果相关,你还应该检查溢出。
-
对于浮点数,请确保你已考虑函数如何处理存在精度问题的值(值略大于或小于预期值)。适合测试的
double
类型值为0.1
和-0.1
(测试的数字略大于预期)以及0.7
和-0.7
(测试的数字略小于预期数字)。 -
对于字符串,请确保你已考虑函数如何处理空字符串、字母数字字符串、具有空格(前导、尾随和内部)的字符串以及全部为空格的字符串。
3. C++ 中的常见语义错误
编译器通常不会捕获语义错误(尽管在某些情况下,智能编译器可能能够生成警告)。语义错误可能导致与未定义行为的大多数症状相同,例如导致程序产生错误的结果、导致不稳定的行为、损坏程序数据、导致程序崩溃 —— 或者它们可能根本没有任何影响。
3.1 条件逻辑错误
最常见的语义错误类型之一是条件逻辑错误(conditional logic error)。当程序员错误地编码条件语句或循环条件的逻辑时,就会发生条件逻辑错误。下面是一个简单的示例:
1 |
|
示例运行:
1 | Enter an integer: 5 |
这是另一个使用 for 循环的示例:
1 |
|
该程序应该打印 1 和用户输入的数字之间的所有数字。它没有打印任何东西。发生这种情况是因为在进入 for 循环时,count > x
为 false
,因此循环根本不会迭代。
3.2 无限循环
1 |
|
1 |
|
3.3 差1错误
差一错误是当循环执行一次太多或一次过少时发生的错误。
1 |
|
程序员打算让此代码打印 1 2 3 4 5
。但是,使用了错误的关系运算符(<
而不是 <=
),因此循环的执行次数比预期的少了一次,打印了 1 2 3 4
。
3.4 运算符优先级不正确
1 |
|
由于逻辑非的优先级高于operator >
,因此条件的计算结果就像它被写入 (!x)> y
一样,这不是程序员的本意。
3.5 浮点类型的精度问题
以下浮点变量没有足够的精度来存储整个数字
1 |
|
由于缺乏精度,该数字略微四舍五入:
1 | 0.123457 |
使用 operator==
和 operator!=
由于舍入误差而对浮点数造成问题
1 |
|
该程序打印:
1 | not equal |
你对浮点数进行的算术运算越多,它累积的小舍入误差就越多。
3.6 整数除法
在下面的示例中,我们打算进行浮点除法,但由于两个操作数都是整数,因此我们最终会进行整数除法:
1 |
|
输出:
1 | 5 divided by 3 is: 1 |
3.7 意外的 null 语句
1 |
|
在整个程序中,我们只想在得到用户许可的情况下炸毁世界,但是由于意外的 null
语句,对 blowUpWorld()
的函数调用总是被执行,因此我们无论如何都会将其炸毁:
1 | Should we blow up the world again? (y/n): n |
3.8 当需要复合语句时不使用复合语句
1 |
|
该程序打印:
1 | Should we blow up the world again? (y/n): n |
3.9 在条件中使用赋值而不是相等
1 |
|
该程序打印:
1 | Should we blow up the world again? (y/n): n |
赋值运算符返回其左操作数。c = 'y'
首先执行,它将 y
分配给 c
并返回 c
。然后,如果 c
被计算。由于 c
现在是非零的,因此它被隐式转换为 bool
值 true
,并且与 if 语句关联的语句将执行。
3.10 调用函数时忘记使用函数调用运算符
1 |
|
虽然你可能希望这个程序打印 5
,但它很可能会打印 1
(在某些编译器上,它将以十六进制打印内存地址)。我们没有使用 getValue()
(它将调用函数并生成一个 int
返回值),而是使用了没有函数调用运算符的 getValue
。在许多情况下,这将导致值转换为 bool
值 true
。
4. 检测和处理错误
大多数错误是由于程序员做出的错误假设和/或缺乏适当的错误检测/处理而发生的。假设错误通常发生在三个关键位置:
-
当函数返回时,程序员可能认为被调用的函数成功,但实际上并非如此。
-
当程序接收输入(无论是来自用户还是文件)时,程序员可能错误地假定输入的格式是正确的并且在语义上是有效的,而实际上并非如此。
-
当一个函数被调用时,程序员可能已经假设参数在语义上是有效的,但实际上它们不是。
函数失败的原因有很多 —— 调用者可能传入了一个值无效的参数,或者函数体中可能有一些东西失败。例如,如果找不到文件,则打开文件进行读取的函数可能会失败。发生这种情况时,你有很多选择。没有处理错误的最佳方法——它实际上取决于问题的性质以及问题是否可以修复。有 4 种通用策略可以使用:
-
处理函数中的错误
-
将错误传回给调用方处理
-
暂停程序
-
引发异常
4.1 处理函数中的错误
如果可能,最好的策略是从发生错误的同一函数中的错误中恢复,以便可以包含和纠正错误,而不会影响函数外部的任何代码。此处有两个选项:重试直到成功,或取消正在执行的作。如果错误是由于程序无法控制的原因而发生的,则程序可以重试,直到成功为止。例如,如果程序需要 Internet 连接,而用户已断开连接,则程序可能能够显示警告,然后使用循环定期重新检查 Internet 连接。或者,如果用户输入了无效的输入,程序可以要求用户重试,并循环直到用户成功输入有效的输入。另一种策略是忽略错误或取消操作。例如:
1 | void printIntDivision(int x, int y) |
4.2 将错误传回给调用方
在许多情况下,无法在检测错误的函数中合理地处理错误。例如,请考虑以下函数:
1 | int doIntDivision(int x, int y) |
如果函数具有 void
返回类型,则可以将其更改为返回指示成功或失败的 bool
1 | bool printIntDivision(int x, int y) |
这样,调用方可以检查返回值,以查看函数是否由于某种原因而失败。如果函数返回一个正常值,事情就会稍微复杂一些。例如,请考虑以下函数:
1 | // The reciprocal of x is 1/x |
然而,如果用户调用这个函数为reciprocal(0.0)
,我们会得到一个除以零的错误,并导致程序崩溃,因此显然我们需要防止这种情况的发生。但这个函数必须返回一个double
类型的值,那么我们应该返回什么值呢?事实证明,这个函数永远不会产生0.0
作为一个合法的结果,因此我们可以返回0.0
来表示错误情况。
1 | // The reciprocal of x is 1/x, returns 0.0 if x=0 |
哨兵值(sentinel value) 是在函数或算法的上下文中具有某种特殊含义的值。在上面的 reciprocal()
函数中,0.0
是一个哨兵值,表示该函数失败。调用方可以测试返回值以查看它是否与哨兵值匹配——如果匹配,则调用方知道函数失败。虽然函数通常直接返回哨兵值,但返回描述哨兵值的常量可以增加额外的可读性。但是,如果函数可以生成完整范围的返回值,则使用哨兵值来指示错误是有问题的(因为调用方无法判断返回值是有效值还是错误值)。
4.3 暂停程序
如果错误非常严重,以至于程序无法继续正常运行,则称为不可恢复错误(也称为致命错误)。在这种情况下,最好的办法是终止程序。如果你的代码在 main()
或直接从 main()
调用的函数中,最好的办法是让 main()
返回一个非零的状态代码。但是,如果你深入到某个嵌套的子函数中,则可能不方便或不可能将错误一直传播回 main()
。在这种情况下,可以使用 halt
语句(例如 std::exit()
)。
1 | double doIntDivision(int x, int y) |
4.4 引发异常
由于将错误从函数返回给调用方很复杂(并且许多不同的方法会导致不一致,而不一致会导致错误),因此 C++ 提供了一种完全不同的方法将错误传递回调用方:exceptions
。基本思想是,当发生错误时,会“引发”异常。如果当前函数没有 “捕获” 错误,则函数的调用方有机会捕获错误。如果调用方未捕获错误,则调用方的调用方有机会捕获错误。错误在调用堆栈中逐渐向上移动,直到它被捕获并处理(此时执行继续正常),或者直到 main() 无法处理错误(此时程序因异常错误而终止)。具体后面介绍
4.5 何时使用 std::cout
与 std::cerr
与 logging
默认情况下,std::cout
和 std::cerr
都会将文本打印到控制台。但是,现代作系统提供了一种将输出流重定向到文件的方法,以便可以捕获输出以供以后查看或自动处理。
对于此讨论,区分两种类型的应用程序非常有用:
-
交互式应用程序是用户在运行后将与之交互的应用程序。大多数独立应用程序(如游戏和音乐应用程序)都属于此类别。
-
非交互式应用程序是不需要用户交互即可运行的应用程序。这些程序的输出可以用作其他应用程序的输入
在非交互式应用程序中,有两种类型:
-
工具是非交互式应用程序,通常为了产生一些即时结果而启动,然后在产生此类结果后终止。这方面的一个例子是 Unix 的 grep 命令,它是一个实用程序,用于搜索文本中与某种模式匹配的行。
-
服务是非交互式应用程序,通常在后台静默运行以执行某些正在进行的功能。这方面的一个例子是病毒扫描程序。
经验法则:
-
对所有面向用户的常规文本使用
std::cout
。 -
对于交互式程序,请使用
std::cout
来显示面向用户的正常错误消息(例如,“您的输入无效”)。使用std::cerr
或日志文件获取状态和诊断信息,这些信息可能有助于诊断问题,但对于普通用户来说可能并不感兴趣。这可能包括技术警告和错误(例如,函数 x 的输入错误)、状态更新(例如,成功打开文件 x、无法连接到互联网服务 x)、长任务的完成百分比(例如编码完成 50%)等… -
对于非交互式程序(工具或服务),仅将
std::cerr
用于错误输出(例如,无法打开文件 x)。这允许将错误与正常输出分开显示或解析。 -
对于本质上是事务性的任何应用程序类型(例如,处理特定事件的应用程序类型,例如交互式 Web 浏览器或非交互式 Web 服务器),请使用日志文件生成事件的事务性日志,以便稍后查看。这可能包括将正在处理的文件输出到日志文件、完成百分比的更新、开始某些计算阶段的时间戳、警告和错误消息等…
5. std::cin
和处理无效输入
在编写程序时,应始终考虑用户将如何(无意或无意地)滥用你的程序。一个编写良好的程序将预见到用户将如何滥用它,并优雅地处理这些情况,或者从一开始就防止它们发生(如果可能的话)。能够很好地处理错误情况的程序被称为健壮的。
以下是对输入操作符>>
工作原理的简化描述:
-
首先,输入缓冲区前端的空白字符(包括空格、制表符和换行符)会被丢弃。这会移除之前输入行中未提取的换行符。
-
如果此时输入缓冲区为空,
operator>>
会等待用户输入更多数据,并再次丢弃前端的空白字符。 -
然后,
operator>>
会尽可能多地提取连续字符,直到遇到换行符(表示输入行的结束)或一个对目标变量无效的字符为止。
提取结果:
-
如果在上面的步骤 3 中提取了任何字符,则提取成功。提取的字符将转换为一个值,然后将其分配给变量。
-
如果在上述步骤 3 中无法提取任何字符,则提取失败。被提取到的对象被分配值
0
(从 C++11 开始),任何未来的提取都将立即失败(直到清除std::cin
)。
检查用户输入是否符合程序期望的过程称为输入验证。有三种基本方法可以进行输入验证:
-
当用户键入时:
- 首先防止用户输入无效。
-
用户输入后:
- 用户在字符串中输入他们想要的任何内容,然后验证字符串是否正确,如果正确,则将字符串转换为最终的变量格式。
- 让用户输入他们想要的任何内容,让
std::cin
和operator>>
尝试提取它,并处理错误情况。
请考虑以下没有错误处理的 calculator 程序:
1 |
|
这个简单的程序要求用户输入两个数字和一个数学运算符:
1 | Enter a decimal number: 5 |
现在考虑无效的用户输入可能会在哪些位置破坏此程序。
-
我们要求用户输入一些数字。如果他们输入的数字以外的内容(例如“q”)。
-
我们要求用户输入四个可能的符号之一。如果他们输入的字符不是我们期望的符号之一。
-
如果我们要求用户输入一个符号,而他们输入了一个字符串,比如
“*q hello”
。
我们通常可以将输入文本错误分为四种类型:
-
输入提取成功,但输入对程序没有意义(例如,输入 ‘k’ 作为数学运算符)。
-
输入提取成功,但用户输入了其他输入(例如,输入 ‘*q hello’ 作为数学运算符)。
-
输入提取失败(例如,尝试在数字输入中输入 ‘q’)。
-
输入提取成功,但用户溢出一个数值。
5.1 错误情况 1:提取成功,但输入毫无意义
这里的解决方案很简单:进行输入验证。这通常包括 3 个步骤:
-
检查用户的输入是否符合你的预期。
- 如果是这样,请将值返回给调用方。
- 如果没有,请告诉用户出错了,然后让他们重试。
下面是一个更新的 getOperator()
函数,用于执行输入验证。
1 | char getOperator() |
5.2 错误情况 2:提取成功,但输入无关
考虑上述程序的以下执行:
1 | Enter a decimal number: 5*7 |
输出:
1 | Enter a decimal number: 5*7 |
当用户输入 5*7
作为输入时,该输入将进入缓冲区。然后,operator>>
将 5 提取到变量 x,将 *7\n
保留在缓冲区中。接下来,程序将打印 “Enter one of the following: +, -, *, or /:”。但是,当调用提取运算符时,它会看到 *7\n
在缓冲区中等待提取,因此它使用该运算符,而不是要求用户提供更多输入。因此,它会提取 ‘*’ 字符,在缓冲区中留下 7\n
。在要求用户输入另一个十进制数后,缓冲区中的 7
将被提取,而不会询问用户。由于用户从未有机会输入其他数据并按 Enter(导致换行),因此输出提示都在同一行上运行。虽然上述程序可以工作,但执行起来很混乱。如果直接忽略输入的任何无关字符,那就更好了。幸运的是,很容易忽略字符:
1 | std::cin.ignore(100, '\n'); // clear up to 100 characters out of the buffer, or until a '\n' character is removed |
此调用将删除最多 100 个字符,但如果用户输入的字符超过 100 个,我们将再次得到混乱的输出。要忽略下一个 ‘\n’ 之前的所有字符,我们可以传递给 std::numeric_limits<std::streamsize>::max()
>std::cin.ignore()
。 std::numeric_limits<std::streamsize>::max()
返回可存储在 std::streamSize
类型的变量中的最大值。将此值传递给 std::cin.ignore()
会导致它禁用计数检查。因此要忽略下一个 ‘\n’ 字符之前的所有内容(包括下一个 ‘\n’ 字符),我们调用:
1 | std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n'); |
因为这一行对于它的作用来说很长,所以将其包装在一个可以代替 std::cin.ignore()
调用的函数中很方便。
1 |
|
让我们更新 getDouble()
函数以忽略任何无关的输入:
1 | double getDouble() |
在某些情况下,最好将无关的 input 视为失败情况(而不是直接忽略它)。然后,我们可以要求用户重新输入他们的输入。以下是 getDouble()
的一个变体,如果输入了任何无关的输入,则要求用户重新输入他们的输入:
1 | // returns true if std::cin has unextracted input on the current line, false otherwise |
上面的代码段使用了两个我们以前从未见过的函数:
-
如果最后一个输入的数(在本例中为提取到
x
)到达输入流的末尾,则std::cin.eof()
函数返回true
。 -
std::cin.peek()
函数允许我们查看输入流中的下一个字符,而无需提取它。
在将用户的输入提取到 x
后,std::cin
中可能会也可能不会留下其他(未提取的)字符。首先,我们调用 std::cin.eof()
来查看对 x
的提取是否到达了输入流的末尾。如果是这样,那么我们知道所有字符都被提取了,这是一个成功的案例。否则,std::cin
中必须仍有其他字符等待提取。在这种情况下,我们调用 std::cin.peek()
来查看下一个等待提取的字符,而无需实际提取它。如果下一个字符是 '\n'
,则意味着我们已经将这行输入中的所有字符提取到 x
中。这也是一个成功的案例。但是,如果下一个字符不是 '\n'
,则用户必须输入了未提取到 x
的无关输入。这就是我们的失败案例。我们清除所有无关的输入,然后继续返回到循环的顶部重试。
5.3 错误情况 3:提取失败
当无法将输入提取到指定变量时,提取将失败。现在考虑我们更新的 calculator 程序的以下执行:
1 | Enter a decimal number: a |
程序没有按预期执行不应该感到惊讶,但它的失败方式很有趣:
1 | Enter a decimal number: a |
当用户输入“a”时,该字符将放置在缓冲区中。然后,operator>>
尝试将 ‘a’ 提取到变量 x
,该变量是 double 类型。由于“a”无法转换为双精度值,因此operator>>
无法执行提取。此时会发生两件事:‘a’ 留在缓冲区中,并且 std::cin
进入 “失败模式”。一旦进入 “失败模式” ,将来的输入提取请求将以静默方式失败。因此,在我们的 calculator 程序中,输出提示仍然会打印,但任何进一步提取的请求都会被忽略。这意味着,我们不是等待我们输入操作,而是跳过了输入提示,我们陷入了无限循环,因为无法达到有效情况之一。
为了让 std::cin
再次正常工作,我们通常需要做三件事:
-
检测先前的提取是否失败。
-
将
std::cin
放回正常作模式。 -
删除导致失败的输入(这样下一个提取请求就不会以相同的方式失败)。
1 | if (std::cin.fail()) // If the previous extraction failed |
因为 std::cin
有一个布尔转换来指示最后一个输入是否成功,所以将上述内容写成如下更地道:
1 | if (!std::cin) // If the previous extraction failed |
让我们将其集成到我们的 getDouble()
函数中:
1 | double getDouble() |
即使提取没有失败,也可以调用 clear()
—— 它不会做任何事情。如果我们要调用 ignoreLine()
,无论我们是成功还是失败,我们基本上可以将这两种情况结合起来:
1 | double getDouble() |
还有另一个情况我们需要解决。文件结束 (EOF) 是一种特殊的错误状态,表示“没有更多可用数据”。这通常是在输入操作因没有可用数据而失败后生成的。例如,如果你正在读取磁盘上文件的内容,然后在到达文件末尾后尝试读取更多数据,则将生成 EOF 以告知你没有更多数据可用。对于文件输入,这没问题 – 我们只需关闭文件并继续作即可。
现在考虑 std::cin
。如果我们尝试从 std::cin
中提取输入,但没有,根据设计,它不会生成 EOF——它只会等待用户输入更多输入。但是,std::cin
在某些情况下可以生成 EOF——最常见的是当用户为其作系统输入特殊组合键时。Unix(通过 ctrl-D)和 Windows(通过 ctrl-Z + ENTER)都支持从键盘输入“EOF 字符”。
在 C++ 中,EOF 是错误状态,而不是字符。不同的作系统具有特殊的字符组合,这些字符组合被视为“用户输入的 EOF 请求”。这些字符有时称为“EOF 字符”。
将数据提取到 std::cin
并且用户输入 EOF 字符时,该行为是特定于作系统的。以下是通常发生的情况:
-
如果 EOF 不是输入的第一个字符:将刷新 EOF 之前的所有输入,并忽略 EOF 字符。在 Windows 上,将忽略在 EOF 之后输入的任何字符,换行符除外。
-
如果 EOF 是输入的第一个字符:将设置 EOF 错误。输入流可能(也可能不会)断开连接。
虽然 std::cin.clear()
会清除一个 EOF 错误,但如果输入流断开连接,下一个输入请求将生成另一个 EOF 错误。当我们的输入在 while(true)
循环中时,这是有问题的,因为我们将陷入 EOF 错误的无限循环中。由于键盘输入的 EOF
字符的目的是终止输入流,因此最好的办法是检测 EOF(通过 std::cin.eof()
),然后终止程序。因为清除失败的 input 流是我们可能会检查很多的事情,所以这是可重用函数的一个很好的候选者:
1 |
|
5.4 错误情况 4:提取成功,但用户溢出数值
请考虑以下简单示例:
1 |
|
如果用户输入的数字太大(例如 40000),会发生什么情况?
1 | Enter a number between -32768 and 32767: 40000 |
在上述情况下, std::cin
立即进入 “失败模式”,但也为变量分配了最接近的范围内值。当输入的值大于类型的最大可能值时,最接近的范围内值是该类型的最大可能值。因此,x
只剩下指定的值 32767
。跳过其他输入,使 y
的初始化值为 0
。我们可以像处理提取失败一样处理此类错误。
更新后的程序:
1 |
|
6. assert 和 static_assert
在采用参数的函数中,调用方可能能够传入语法上有效但在语义上无意义的参数。例如:
1 | void printDivision(int x, int y) |
在之前我们讨论了处理此类问题的几种方法,包括停止程序或跳过违规语句。
不过,这两个选项都是有问题的。如果程序由于错误而跳过语句,那么它实际上是无提示失败。尤其是在我们编写和调试程序时,静默失败是很糟糕的,因为它们掩盖了真正的问题。即使我们打印了一条错误消息,该错误消息也可能在其他程序输出中丢失,并且可能不清楚错误消息的生成位置或触发错误消息的条件是如何发生的。某些函数可能被调用数十次或数百次,如果其中只有一种情况产生问题,则很难知道是哪一种。
如果程序终止(通过 std::exit
),那么我们将丢失调用堆栈和任何可能有助于我们隔离问题的调试信息。对于这种情况,std::abort
是一个更好的选择,因为通常开发人员可以选择在程序中止的位置开始调试。
6.1 前提条件、不变量和后置条件
在编程中,前提条件是在执行代码的某个部分(通常是函数的主体)之前必须为 true 的任何条件。在前面的示例中,我们检查 y != 0
是一个前提条件,确保 y
在除以 y
之前具有非零值。函数的前提条件最好放在函数的顶部,如果不满足前提条件,则使用提前返回返回给调用方。例如:
1 | void printDivision(int x, int y) |
这有时称为 “弹跳模式”,因为当检测到错误时,您会立即从函数中弹出。弹跳器模式有两个主要优点:
-
您的所有测试用例都是预先的,并且处理错误的测试用例和代码是一起的。
-
您最终会得到更少的嵌套。
这是非弹跳版本的样子:
1 | void printDivision(int x, int y) |
这个版本严格来说更糟糕,因为测试用例和处理错误的代码更加分离,并且嵌套更多。
不变量是在执行代码的某个部分时必须为 true 的条件。这通常用于循环,其中循环体仅在不变量为 true 时才会执行。同样,后置条件是在执行某些代码段之后必须为 true 的内容。我们的函数没有任何后置条件。
6.2 断言
使用条件语句来检测无效参数(或验证某些其他类型的假设),以及打印错误消息和终止程序,是检测问题的一种常用方法,因此 C++ 提供了一种快捷方式来实现此目的。断言是一个表达式,除非程序中存在 bug,否则该表达式将为 true。如果表达式的计算结果为 true
,则断言语句不执行任何操作。如果条件表达式的计算结果为 false
,则会显示错误消息并终止程序(通过 std::abort
)。此错误消息通常包含失败为文本的表达式,以及代码文件的名称和断言的行号。这使得不仅很容易判断问题是什么,而且很容易判断问题在代码中发生的位置。这可以极大地帮助调试工作。
在 C++ 中,运行时断言是通过位于 header 中的 assert 预处理器宏实现的。
1 |
|
当程序调用 calculateTimeUntilObjectHitsGround(100.0, -9.8)
, assert(gravity > 0.0)
的计算结果为 false
,这将触发 assert。这将打印类似于以下内容的消息:
1 | dropsimulator: src/main.cpp:6: double calculateTimeUntilObjectHitsGround(double, double): Assertion 'gravity > 0.0' failed. |
实际消息因使用的编译器而异。有时 assert 表达式的描述性不是很强。请考虑以下语句:
1 | assert(found); |
这到底是什么意思呢?显然found
是 false
(因为触发了断言),但什么没有找到呢?你必须去查看代码才能确定这一点。幸运的是,您可以使用一个小技巧来使您的 assert 语句更具描述性。只需添加一个由逻辑与连接的字符串文本:
1 | assert(found && "Car could not be found in database"); |
以下是其工作原理:字符串文本的计算结果始终为布尔值 true
。所以如果found
是false
的,false&&true
就是false
的。如果found
是true
,那么 true && true
就是true
。因此,对字符串文字进行逻辑与不会影响 assert
的计算。但是,当 assert
触发时,字符串将包含在 assert
消息中:
1 | Assertion failed: found && "Car could not be found in database", file C:\\VCProjects\\Test.cpp, line 34 |
6.3 NDEBUG
assert
宏的性能成本很小,每次检查 assert
条件时都会产生该成本。此外,在生产代码中不应该(理想情况下)遇到断言(因为你的代码应该已经经过全面测试)。因此,大多数开发人员更喜欢 assert 仅在 debug build 中处于活动状态。C++ 提供了一种在生产代码中关闭断言的内置方法:如果定义了预处理器宏 NDEBUG
,则断言宏将被禁用。默认情况下,大多数 IDE 将 NDEBUG
设置为发布配置的项目设置的一部分。
出于测试目的,您可以在给定的翻译单元中启用或禁用 asserts。为此,请将以下选项之一放在任何 #includes
的单独行中:#define NDEBUG
(禁用断言) 或 #undef NDEBUG
(启用断言)。请确保不要以分号结束该行。示例:
1 |
|
6.4 static_assert
C++ 还有另一种类型的 assert 称为 static_assert
。static_assert 是在编译时而不是在运行时检查的断言,失败的static_assert
会导致编译错误。与在头文件中声明的 assert
不同,static_assert
是一个关键字,因此无需包含头文件即可使用它。
static_assert
采用以下形式:
1 | static_assert(condition, diagnostic_message) |
如果条件不为 true,则打印诊断消息。下面是使用 static_assert
确保类型具有特定大小的示例:
1 | static_assert(sizeof(long) == 8, "long must be 8 bytes"); |
关于 static_assert
的一些有用说明:
-
由于
static_assert
由编译器计算,因此条件必须是常量表达式。 -
static_assert
可以放置在代码文件中的任何位置(甚至在全局命名空间中)。 -
static_assert
在发布版本中不会被停用(就像普通的assert
一样)。 -
由于编译器执行评估,因此
static_assert
没有运行时成本。
在 C++17 之前,诊断消息必须作为第二个参数提供。从 C++17 开始,提供诊断消息是可选的。
尽可能使用
static_assert
而不是 `assert() 。
断言和错误处理非常相似,以至于它们的用途可能会混淆,因此让我们澄清一下。
-
断言(Assertions)用于在开发过程中通过记录那些“本不应发生”的假设来检测编程错误。如果这些假设被违反,那么责任在于程序员。断言不会允许从错误中恢复(毕竟,如果某件事本不应发生,那么就没有必要从中恢复)。由于断言通常会在发布版本中被编译器移除,因此你可以大量使用它们而不用担心性能问题,所以没有什么理由不广泛使用它们。
-
错误处理用于在发布版本中优雅地处理那些可能会发生的(尽管很少)情况。这些情况可能是可恢复的问题(程序可以继续运行),也可能是不可恢复的问题(程序必须关闭,但我们至少可以显示一条友好的错误消息,并确保一切被正确清理)。错误检测和处理既有运行时的性能成本,也有开发时的成本。