9 错误检查与处理
9.1 代码检测
9.1.1 在小代码段中测试程序
如果程序简短并接受用户输入,则尝试各种用户输入可能就足够了。但随着程序变得越来越长,这就变得不够了,在将单个函数或类集成到程序的其余部分之前测试它们更有价值。
因此建议用小的、定义良好的单元(函数或类)编写程序,经常编译,并在单元完成时测试代码。
9.1.2 非正式测试
测试代码的一种方法是在编写程序时进行非正式测试。在编写代码单元(函数、类或其他离散的代码“包”)后,可以编写一些代码来测试刚刚添加的单元,然后在测试通过后删除测试代码。
9.1.3 保留测试代码
尽管编写临时测试是测试某些代码的快速而简单的方法。但它没有考虑到这样一个事实,即在某个时刻,您可能希望稍后再次测试相同的代码。也许您修改了一个函数来添加新的功能,并希望确保没有破坏任何已经工作的功能。由于这个原因,保存测试更合理,以便将来可以再次运行它们。例如,您可以将测试移动到testVowel()函数中,而不是删除临时测试代码:
#include <iostream>
bool isLowerVowel(char c)
{
switch (c)
{
case 'a':
case 'e':
case 'i':
case 'o':
case 'u':
return true;
default:
return false;
}
}
// 不在会被调用到
// 允许我们稍后可以再次使用
void testVowel()
{
std::cout << isLowerVowel('a') << '\n'; // 临时测试代码, 应该产出 1
std::cout << isLowerVowel('q') << '\n'; // 临时测试代码, 应该产出 0
}
int main()
{
return 0;
}
9.1.4 自动化测试函数
上述测试函数的一个问题是,它依赖于您在运行时手动验证结果。这需要您记住每个预期的输出结果,并手动将实际结果与预期结果进行比较。
通过编写一个包含测试和预期答案的测试函数,并对它们进行比较,我们可以做得更好。
更好的方法是使用断言,如果任何测试失败,它将导致程序中止并显示错误消息。
#include <cassert> // for assert
#include <cstdlib> // for std::abort
#include <iostream>
bool isLowerVowel(char c)
{
switch (c)
{
case 'a':
case 'e':
case 'i':
case 'o':
case 'u':
return true;
default:
return false;
}
}
// 如果测试失败,程序会立即退出
int testVowel()
{
#ifdef NDEBUG
std::cerr << "Tests run with NDEBUG defined (asserts compiled out)";
std::abort();
#endif
assert(isLowerVowel('a'));
assert(isLowerVowel('e'));
assert(isLowerVowel('i'));
assert(isLowerVowel('o'));
assert(isLowerVowel('u'));
assert(!isLowerVowel('b'));
assert(!isLowerVowel('q'));
assert(!isLowerVowel('y'));
assert(!isLowerVowel('z'));
return 0;
}
int main()
{
testVowel();
// 如果执行到这里,说明所有的测试样例通过
std::cout << "All tests succeeded\n";
return 0;
}
9.1.5 单元测试框架
由于编写函数来执行其他函数是如此常见和有用,因此有一些完整的框架(称为单元测试框架)旨在帮助简化编写、维护和执行单元测试的过程。
9.1.6 集成测试
一旦隔离测试了每个单元,就可以将它们集成到程序中并重新测试,以确保它们正确集成。这称为集成测试。集成测试往往更复杂——现在,运行程序几次并抽查集成后的行为就足够了。
9.2 代码覆盖率
9.2.1 代码覆盖率
术语代码覆盖率用于描述在测试时执行程序的源代码量。有许多不同的度量用于代码覆盖率。
9.2.2 语句覆盖率
术语语句覆盖率是指代码中由测试例程执行的语句的百分比。
9.2.3 分支覆盖率
分支覆盖率是指已执行的分支的百分比,每个可能的分支单独计数。if语句有两个分支——一个分支在条件为true时执行,另一个分支则在条件为false时执行(即使没有相应的else语句要执行)。switch语句可以有多个分支。
9.2.4 循环覆盖率
循环覆盖率(非正式地称为0、1、2测试)表示,如果代码中有循环,则应确保它在迭代0次、1次和2次时正常工作。如果它在2次迭代的情况下正确工作,则它应该在大于2的所有迭代中正确工作。因此,这三个测试涵盖了所有可能性(因为循环不能执行负次数)。
9.2.5 测试不同类别的输入
在编写接受参数的函数时,或者在接受用户输入时,考虑不同类别的输入会发生什么。在这种情况下,我们使用术语“类别”来表示具有类似特征的一组输入。
下面是类别测试的一些基本准则:
对于整数,请确保考虑了函数如何处理负值、零值和正值。如果可能,还应该检查溢出。
对于浮点数,请确保您考虑了函数如何处理具有精度问题的值(比预期稍大或稍小的值)。用于测试的双精度类型值,好的选择,例如0.1和-0.1(用于测试比预期稍大的数字,如果预期是0.000x)以及0.6和-0.6(用于测试略小于预期的数字,如果预期是xxxxx.yy)。
对于字符串,请确保您考虑了函数如何处理空字符串、含字母数字字符串、具有空格的字符串(前导、尾随和内部)以及全部为空格的字符串。
如果函数采用指针,也不要忘记测试nullptr。
9.3 检测和处理错误
9.3.1 处理函数中的错误
函数可能会由于许多原因而失败——调用方可能传入了一个具有无效值的参数,或者函数体中的某些内容可能会失败。例如,如果找不到文件,则打开文件进行读取的函数可能会失败。
当这种情况发生时,您有相当多的选项可供选择。没有一个固定最好的方法来处理错误,这实际上取决于问题的性质以及问题是否可以修复。
有4种通用策略可以使用:
- 处理函数中的错误
- 将错误传递回调用方以处理
- 停止程序
- 抛出异常
如果可能,最好的策略是从发生错误的同一函数中进行错误恢复,以便可以在不影响函数外部的任何代码的情况下更正错误。这里有两个选项:重试直到成功,或取消正在执行的操作。
如果错误是由于程序无法控制的原因而发生的,则程序可以重试,直到成功。例如,如果程序需要互联网连接,而用户已失去连接,则可以显示警告,然后使用循环定期重新检查互联网连接。或者,如果用户输入了无效的输入,程序可以要求用户重试,并循环,直到用户成功输入有效的输入。
另一种策略是忽略错误和/或取消操作。例如:
void printDivision(int x, int y)
{
if (y != 0)
std::cout << static_cast<double>(x) / y;
}
在上面的例子中,如果用户为y传递了无效的值,我们就忽略打印除法运算结果的请求。这样做的主要问题是,调用方或用户无法识别出问题。在这种情况下,打印错误消息可能会有所帮助:
void printDivision(int x, int y)
{
if (y != 0)
std::cout << static_cast<double>(x) / y;
else
std::cout << "Error: Could not divide by zero\n";
}
然而,如果调用函数期望被调用函数产生返回值或一些有用的副作用,那么忽略错误可能不是一个可选项。
9.3.2 将错误传回调用方
在许多情况下,错误不能在检测错误的函数中合理地处理。例如,考虑以下函数:
double doDivision(int x, int y)
{
return static_cast<double>(x) / y;
}
如果y是0,我们应该怎么做?我们不能跳过程序逻辑,因为函数需要返回一些值。也不应该要求用户输入y的新值,因为这是一个计算函数,让用户输入的逻辑可能在其它函数中。
在这种情况下,最好的选择是将错误传递回调用方,希望调用方能够处理它。
如果函数具有void返回类型,则可以将其更改为返回指示成功或失败的布尔值。这样,调用方可以检查返回值,以查看函数是否因某种原因而失败。
如果函数返回正常值,则情况会稍微复杂一些。在某些情况下,不使用完整的返回值范围。在这种情况下,可以使用通常不可能发生的返回值来指示错误。例如,考虑以下函数:
// x的倒数是 1/x
double reciprocal(double x)
{
return 1.0 / x;
}
某个数x的倒数定义为1/x,一个数乘以它的倒数等于1。
然而,如果用户调用reciprocal(0),会发生什么情况?我们得到一个除以零的错误和一个程序崩溃,所以很明显,应该防止这种情况。但这个函数必须返回一个double值,所以应该返回什么值?很明显,该函数永远不会产生0.0作为合法结果,因此可以返回0.0来指示错误情况。
// x的倒数是 1/x, if x=0,返回0.0
double reciprocal(double x)
{
if (x == 0.0)
return 0.0;
return 1.0 / x;
}
然而,如果需要完整的返回值范围,则不可能使用返回值来指示错误(因为调用程序将无法区分返回值是有效值还是错误值)。在这种情况下,返回std::optional将是一个不错的选择。
9.3.3 致命错误
如果错误严重到程序无法继续正常运行,则这称为不可恢复错误(也称为致命错误, fatal error)。在这种情况下,最好的做法是终止程序。如果错误点在main()函数中,最好的办法是直接return一个非零值。然而,如果深入到某个嵌套子函数中,将错误传播回main()则不太可能。在这种情况下,可以使用退出语句( 例如std::exit() )。
例如:
double doDivision(int x, int y)
{
if (y == 0)
{
std::cout << "Error: Could not divide by zero\n";
std::exit(1);
}
return static_cast<double>(x) / y;
}
9.3.4 Exceptions (异常)
由于将错误从函数返回给调用者是复杂的(许多不同的方法都会导致不一致,而不一致会导致错误),C++提供了一种完全独立的方法来将错误传递回调用者: exceptions。
基本思想是,当错误发生时,会“抛出”异常。如果当前函数没有“捕获”错误,则函数的调用方有机会捕获该错误。如果调用者没有捕获错误,则调用者的调用者有机会捕获错误。错误在调用堆栈中逐渐向上移动,直到捕获并处理它(此时执行正常继续),或者直到main()函数中也无法处理错误(此时程序因异常错误而终止)。
9.3.5 何时使用std::cout vs std::cerr vs logging
默认情况下,std::cout和std::cerr都将文本打印到控制台。然而,现代操作系统提供了一种将输出流重定向到文件的方法,以便可以捕获输出到文件中以供以后审查或自动处理。
对于此讨论,区分两种类型的应用程序是有用的:
- 交互式应用程序,用户在运行后将与之交互的应用程序。大多数独立应用程序,如游戏和音乐应用程序,都属于这一类。
- 非交互式应用程序,是不需要用户交互才能运行的应用程序。这些程序的输出可以用作其他应用的输入
在非交互式应用程序中,有两种类型:
-
工具类,通常是为了产生某些即时结果而启动,然后在产生这样的结果后终止。这方面的一个例子是Unix的grep命令,这是一个实用程序,用于搜索文本中与某种模式匹配的行。
-
服务类,通常在后台静默运行以执行某些功能。这方面的一个例子是病毒扫描程序。
-
面向用户的文本输出结果使用std::cout。
-
对于交互式程序,请将std::cout用于普通的面向用户的错误消息(例如,“您的输入无效”)。使用std::cerr或日志文件来记录状态和诊断信息,这些信息可能有助于诊断问题,但对于普通用户来说可能并不有趣。这可以包括技术警告和错误(例如,函数x的输入错误)、状态更新(例如,成功打开文件x,未能连接到互联网服务x)、长任务的完成百分比(例如,格式转换完成50%)等…
-
对于非交互式程序(工具或服务),仅将std::cerr用于错误输出(例如,无法打开文件x)。这允许错误与正常输出分开显示或分析。
-
对于本质上是事务性的任何应用程序类型(例如,处理特定事件的应用程序类型,如交互式web浏览器或非交互式web服务器),使用日志文件生成事件的事务日志,这些事件可以在以后查看。例如,输出当前正在处理的文件、完成百分比、开始计算的某个阶段的时间戳、警告和错误消息。
9.4 std::cin和处理无效输入
大多数具有某种用户界面的程序都需要处理用户输入。在我们现在正在编写的程序中,一直在使用std::cin来要求用户输入文本。因为文本输入是如此自由的形式(用户可以输入任何内容),所以用户很容易输入不符合期望的输入。
在编写程序时,应该始终考虑用户将如何(无意中或以其他方式)滥用程序。一个编写良好的程序将预测用户将如何滥用它,并要么优雅地处理这些情况,要么从一开始就防止它们发生(如果可能)。一个能够很好地处理错误情况的程序被认为是健壮的。
9.4.1 std::cin、缓冲区和提取
为了讨论std::cin和operator»是如何失败的,首先了解一下它们是如何工作的。
当我们使用操作符»获取用户输入并将其放入变量时,这称为“提取”。在该上下文中使用时,»操作符相应地称为提取操作符。
当用户响应提取操作输入时,该数据被放在std::cin内的缓冲区中。缓冲区(也称为数据缓冲区)只是一块内存,用于在数据从一个位置移动到另一个位置时临时存储数据。在这种情况下,缓冲区用于保存用户输入,同时等待将其提取为变量。
使用提取操作符时,将执行以下过程:
- 如果输入缓冲区中已经存在数据,则使用该数据进行提取。
- 如果输入缓冲区不包含数据,则要求用户输入数据以进行提取(大多数情况下都是这样)。当用户点击enter时,将在输入缓冲区中放置“\n”字符。
- 操作符»将尽可能多的数据从输入缓冲区提取到变量中(忽略任何前导空格字符,如空格、制表符或“\n”)。
- 无法提取的任何数据都留在输入缓冲区中,供下一次提取使用。
如果从输入缓冲区提取了至少一个字符,则提取成功。任何未提取的输入都保留在输入缓冲区中,以供将来提取。
9.4.2 验证输入
检查用户输入是否符合程序期望的过程称为输入验证。
有三种基本的输入验证方法:
用户边输入边校验:
- 阻止用户输入无效的字符。
用户输入完成后再校验:
- 将用户输入的所有保存到字符串中, 然后验证字符串是否ok, 如果ok, 将字符串转换成最终的格式
- 让用户任意输入, 使用 std::cin 和 operator» 去提取数据, 同时处理提取失败的情形.
一些图形用户界面和高级文本界面允许您在用户输入时验证输入(逐个字符)。一般来说,程序员提供一个验证函数,该函数接受用户迄今为止的输入,如果输入有效则返回true,否则返回false。每次用户按下键时都会调用此函数。如果验证函数返回true,则接受用户刚才按下的键。如果验证函数返回false,则用户刚才输入的字符将被丢弃(并且不会显示在屏幕上)。使用此方法,可以确保用户输入的任何输入都是有效的,因为任何无效的击键都会被发现并立即丢弃。不幸的是,std::cin不支持这种类型的验证。
由于字符串对可以输入的字符没有任何限制,如果使用operator»将输入提取到字符串中,提取保证成功(请记住,std::cin在第一个非前导空格字符处停止提取)。一旦提取了字符串,程序就可以解析该字符串,看看它是否有效。然而,解析字符串并将字符串输入转换为其他类型(例如数字)可能是具有挑战性的,所以这种方式只在少数情况下使用。
最常见的是,让std::cin和提取操作符来完成困难的工作。在这种方法下,我们让用户输入他们想要的任何内容,让std::cin和操作符»尝试提取它,并在它失败时处理后果。这是最简单的方法,也是在下面详细讨论的方法。
考虑以下没有错误处理的计算器程序:
#include <iostream>
double getDouble()
{
std::cout << "Enter a decimal number: ";
double x{};
std::cin >> x;
return x;
}
char getOperator()
{
std::cout << "Enter one of the following: +, -, *, or /: ";
char op{};
std::cin >> op;
return op;
}
void printResult(double x, char operation, double y)
{
switch (operation)
{
case '+':
std::cout << x << " + " << y << " is " << x + y << '\n';
break;
case '-':
std::cout << x << " - " << y << " is " << x - y << '\n';
break;
case '*':
std::cout << x << " * " << y << " is " << x * y << '\n';
break;
case '/':
std::cout << x << " / " << y << " is " << x / y << '\n';
break;
}
}
int main()
{
double x{ getDouble() };
char operation{ getOperator() };
double y{ getDouble() };
printResult(x, operation, y);
return 0;
}
9.4.3 无效文本输入的类型
通常可以将输入文本错误分为四种类型:
- 输入提取成功,但输入对程序没有意义(例如,输入“k”作为数学运算符)。
- 输入提取成功,但用户后续输入了其他输入(例如,输入“*q hello”作为数学运算符)。
- 输入提取失败(例如,尝试在数字输入中输入“q”)。
- 输入提取成功,但输入数值发生了溢出。
因此,为了使程序健壮,每当要求用户输入时,理想情况下,应该确定上述每一种情况是否可能发生,并编写代码来处理这些情况。
9.4.4 错误情况1:提取成功,但输入无意义
这里的解决方案很简单:进行输入验证。这通常包括3部分:
- 检查用户的输入是否是我们所期望的
- 如果是的话,执行后续流程
- 如果不是,提示用户,并让用户进行重试
下面是一个新的getOperator()函数,对用户输入进行验证。
char getOperator()
{
while (true) // 无限循环,直到用户输入有效的数据
{
std::cout << "Enter one of the following: +, -, *, or /: ";
char operation{};
std::cin >> operation;
// 检查是否用户输入是否有效
switch (operation)
{
case '+':
case '-':
case '*':
case '/':
return operation; // 将有效的输入返回
default: // 否则提示用户输入有误
std::cout << "Oops, that input is invalid. Please try again.\n";
}
}
}
9.4.5 错误情况2:提取成功,但有多余的输入
多余的输入将保留在输入流中等待下次提取,为保证下次提取的是用户实时输入的数据,应该忽略任何无关字符:
std::cin.ignore(100, '\n'); // 清空缓存中的100个字符, 或者直到一个 '\n' 被清除
这个调用将删除多达100个字符,但如果用户输入的字符超过100个,我们将再次得到混乱的输出。要忽略下一个“\n”之前的所有字符,可以将std::numeric_limits<std::streamsize>::max()传递给std::cin.ignore()。std::numeric_limits<std::streamsize>::max() 返回可以存储在类型为std::streamsize的变量中的最大值。将该值传递给std::cin.ignore()会导致std::cin清空所有缓存。使用之前需要先 “#include <limits>”。
要忽略直到并包括下一个“\n”字符的所有内容,调用:
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
这一行代码对于它所做的事情来说相当长,所以将它包装在一个可以代替std::cin.ignore()调用的函数中很方便。
#include <limits> // for std::numeric_limits
void ignoreLine()
{
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
}
由于用户输入的最后一个字符通常是“\n”,因此我们可以告诉std::cin忽略缓冲字符,直到它找到换行符(“\n”也被删除)。
现在更新getDouble()函数以忽略任何无关的输入:
double getDouble()
{
std::cout << "Enter a decimal number: ";
double x{};
std::cin >> x;
ignoreLine();
return x;
}
在某些情况下,最好将无关的输入视为故障情况(而不是忽略它)。然后,我们可以要求用户重新输入。
为了做到这一点,我们需要某种方法来确定在成功提取后输入流中是否还有任何输入。我们可以使用std::cin.peek()函数,它允许查看输入流中的下一个字符,而不提取它。
下面是getDouble()的变体,它要求用户在输入任何无关输入时重新输入:
double getDouble()
{
while (true) // 无限循环,直到用户输入有效的数据
{
std::cout << "Enter a decimal number: ";
double x{};
std::cin >> x;
// 如果有额外的输入, 当做用户输入失败
if (!std::cin.eof() && std::cin.peek() != '\n')
{
ignoreLine(); // 移除缓冲区内的所有字符
continue;
}
ignoreLine();
return x;
}
}
9.4.6 错误情况3:提取失败
当用户输入“a”时,该字符将放在缓冲区中。然后操作符»尝试将“a”提取到变量x,该变量的类型为double。由于“a”无法转换为double,因此操作符»无法执行提取。此时会发生两件事:“a”留在缓冲区中,std::cin进入“故障模式”。
一旦进入“故障模式”,未来的输入提取请求将自动失败。因此,在我们的程序中,输出提示仍然打印,但任何进一步提取的请求都将被忽略。这意味着,进行输入操作时,将跳过输入提示符,并且将陷入无限循环中。
幸运的是,我们可以检测提取是否失败:
if (std::cin.fail()) // 是否之前的提取失败
{
// 这里来处理失败
std::cin.clear(); // 将std::cin调回 '正常' 模式
ignoreLine(); // 移除缓存中的错误数据
}
由于std::cin可以直接指示上一次提取操作的状态,因此更习惯于将上面的内容编写为:
if (!std::cin) // 是否之前的提取失败
{
// 这里来处理失败
std::cin.clear(); // 将std::cin调回 '正常' 模式
ignoreLine(); // 移除缓存中的错误数据
}
让我们将其集成到getDouble()函数中:
double getDouble()
{
while (true) // 无限循环,直到用户输入有效的数据
{
std::cout << "Enter a decimal number: ";
double x{};
std::cin >> x;
if (!std::cin) // 是否之前的提取失败
{
// 这里来处理失败
std::cin.clear(); // 将std::cin调回 '正常' 模式
ignoreLine(); // 移除缓存中的错误数据
}
else // 或者之前提取成功
{
ignoreLine();
return x; // 这里返回提取的数据
}
}
}
由于无效输入而导致提取失败,将导致变量分配为值0。
在Unix系统上,输入文件尾(EOF)字符(键盘上输入ctrl-D)关闭输入流。这是std::cin.clear()无法修复的问题,此时std::cin永远不会离开故障模式,这会导致所有后续输入操作失败。这也会导致程序无限循环,直到被杀死。
要更优雅地处理这种情况,可以显式检查是否EOF:
if (!std::cin) // 是否之前的提取失败
{
if (std::cin.eof()) // 是否输入流被关闭
{
exit(0); // 直接关闭程序
}
// 这里来处理失败
std::cin.clear(); // 将std::cin调回 '正常' 模式
ignoreLine(); // 移除缓存中的错误数据
}
9.4.7 错误情况4:输入提取成功,但输入数值发生了溢出
考虑下面的简单示例:
#include <cstdint>
#include <iostream>
int main()
{
std::int16_t x{}; // x 是 16 位, 存储范围 -32768 到 32767
std::cout << "Enter a number between -32768 and 32767: ";
std::cin >> x;
std::int16_t y{}; // y 是 16 位, 存储范围 -32768 到 32767
std::cout << "Enter another number between -32768 and 32767: ";
std::cin >> y;
std::cout << "The sum is: " << x + y << '\n';
return 0;
}
如果用户输入的数字太大(例如40000),会发生什么情况?
在上述情况下,std::cin会立即进入“故障模式”,但也会将范围内最接近的值分配给变量。因此,x的赋值为32767。这时会跳过其他输入,将y保留为初始化值0。可以用与处理提取失败相同的方法来处理这种错误。
下面的示例中将以上错误检查做了集成:
#include <iostream>
#include <limits>
void ignoreLine()
{
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
}
double getDouble()
{
while (true) // 无限循环,直到用户输入有效的数据
{
std::cout << "Enter a decimal number: ";
double x{};
std::cin >> x;
// 检查是否提取失败
if (!std::cin) // 提取失败的情况
{
if (std::cin.eof()) // 是否输入流被关闭
{
exit(0); // 直接关闭程序
}
// 这里来处理失败
std::cin.clear(); // 将std::cin调回 '正常' 模式
ignoreLine(); // 移除缓存中的错误数据
std::cout << "Oops, that input is invalid. Please try again.\n";
}
else
{
ignoreLine(); // 移除任何额外的输入
return x;
}
}
}
char getOperator()
{
while (true) // 无限循环,直到用户输入有效的数据
{
std::cout << "Enter one of the following: +, -, *, or /: ";
char operation{};
std::cin >> operation;
if (!std::cin) // 检查是否提取失败
{
if (std::cin.eof()) // 是否输入流被关闭
{
exit(0); // 直接关闭程序
}
// 这里来处理失败
std::cin.clear(); // 将std::cin调回 '正常' 模式
}
ignoreLine(); // 移除任何额外的输入
// 检查输入是否在范围内
switch (operation)
{
case '+':
case '-':
case '*':
case '/':
return operation; // 将输入返回
default: // 告诉用户输入错误
std::cout << "Oops, that input is invalid. Please try again.\n";
}
}
}
void printResult(double x, char operation, double y)
{
switch (operation)
{
case '+':
std::cout << x << " + " << y << " is " << x + y << '\n';
break;
case '-':
std::cout << x << " - " << y << " is " << x - y << '\n';
break;
case '*':
std::cout << x << " * " << y << " is " << x * y << '\n';
break;
case '/':
std::cout << x << " / " << y << " is " << x / y << '\n';
break;
default: // 即使getOperator()函数确保返回有效的输入,这里的检查可以让程序更加健壮
std::cout << "Something went wrong: printResult() got an invalid operator.\n";
}
}
int main()
{
double x{ getDouble() };
char operation{ getOperator() };
double y{ getDouble() };
printResult(x, operation, y);
return 0;
}
9.5 断言
在接受参数的函数中,调用方可以传入语法上有效但语义上无意义的参数,并且如果执行,将导致程序崩溃。
我们讨论了两种处理此类问题的方法,分为停止程序或跳过有问题的语句。
然而,这两种选择都有问题。
如果程序由于错误而跳过语句,则它本质上是在默默地失败。特别是当我们编写和调试程序时,静默故障是不好的,因为它们掩盖了真实的问题。即使我们打印错误消息,该错误消息也可能会在其他程序输出中丢失。并且在哪里生成错误消息,或者触发错误消息的条件是如何发生的,这可能是不明显的。一些函数可能被调用几十次或几百次,如果这些调用里只有一个产生了问题,则很难知道是哪一个。
如果程序终止(通过std::exit),那么我们将丢失调用堆栈和任何可能帮助隔离问题的调试信息。对于这种情况,std::abort是更好的选择,因为通常开发人员可以选择在程序中止的点开始调试。
9.5.1 前置条件、不变量和后置条件
在编程中,前置条件是在执行某些代码段(通常是函数体)之前必须为true的条件。
函数的前提条件最好放在函数的顶部,如果不满足前提条件,则使用提前返回来返回调用方。例如:
void printDivision(int x, int y)
{
if (y == 0) // 校验前置条件
{
std::cerr << "Error: Could not divide by zero\n";
return; // 返回给调用方
}
// 这里一定 y != 0
std::cout << static_cast<double>(x) / y;
}
不变量是在执行代码的某些部分时必须为true的条件。这通常用于循环,其中循环体仅在不变量为true时执行。
类似地,后置条件是在执行代码的某些部分后必须为true的东西。上面示例的函数没有任何后置条件。
9.5.2 断言
使用条件语句检测无效参数(或验证某种其他类型的假设),以及打印错误消息并终止程序,是检测问题的常见方法,C++为此提供了一种快捷方法。
断言是一个表达式。如果表达式的计算结果为true,则断言语句不执行任何操作。如果条件表达式的计算结果为false,则显示错误消息并终止程序(通过std::abort)。该错误消息通常包含表达式的文本,以及代码文件的名称和断言的行号。这不仅使得可以很容易地知道问题是什么,还可以知道问题发生在代码中的什么地方。这可以极大地帮助调试工作。
在C++中,运行时断言是通过断言预处理器宏实现的,该宏位于头中。
#include <cassert> // for assert()
#include <cmath> // for std::sqrt
#include <iostream>
double calculateTimeUntilObjectHitsGround(double initialHeight, double gravity)
{
assert(gravity > 0.0); // 重力一定为正
if (initialHeight <= 0.0)
{
// 东西已经落在地上了
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;
}
尽管断言最常用于验证函数参数,但它们可以用于您希望验证某些内容是否正确的任何地方。
尽管我们以前告诉过您不要使用预处理器宏,但断言是为数不多的被认为可以使用的预处理器宏之一。我们鼓励您在代码中自由地使用断言语句。
当断言的计算结果为false时,程序将立即停止。这使您有机会使用调试工具来检查程序的状态,并确定断言失败的原因。然后您可以找到并解决问题。
如果发生了错误,但没有设置断言检查,这样的错误可能会导致您的程序稍后发生故障。在这种情况下,很难确定哪里出了问题,或者问题的根本原因是什么。
9.5.3 断言与错误处理
断言和错误处理非常相似,因此它们的目的可能会混淆,因此让我们澄清一下:
断言的目标是记录不应该发生的事情来捕获编程错误。如果那件事真的发生了,那么程序员在某处犯了错误,并且该错误可以被识别和修复。断言不允许从错误中恢复(毕竟,如果某些事情永远不会发生,则不需要从中恢复),并且程序不会产生友好的错误消息。
另一方面,错误处理旨在优雅地处理可能发生问题的情况(尽管很少)。这些可以恢复,也可以不恢复,但应该始终假设程序的用户可能会遇到它们。
断言有时也用于记录未实现功能的情况,因为编写代码时还不需要实现它们。
这样,如果代码的未来用户确实遇到需要这种情况的情况,代码将失败,并显示有用的错误消息,然后程序员可以确定如何实现这种情况。
9.5.4 NDEBUG
每次检查断言条件时,断言宏都会产生较小的性能开销。此外,(理想情况下)在生产代码中永远不会遇到断言(因为您的代码应该已经过彻底测试)。因此,许多开发人员更喜欢仅在调试构建时使用断言。C++提供了一种在生产代码中关闭断言的方法。如果定义了宏NDEBUG,则断言宏将被禁用。
一些IDE默认将NDEBUG设置为发布配置的项目设置的一部分。例如,在VisualStudio中,在项目配置默认设置以下预处理器定义: WIN32;NDEBUG;_CONSOLE 。如果您正在使用VisualStudio,并且希望在发布版本中触发断言,则需要从该设置中删除NDEBUG。
如果您使用的IDE或构建系统没有在发布配置中自动定义NDEBUG,则需要将其手动添加到项目或编译设置中。
9.5.5 一些断言限制和警告
断言有一些缺陷和限制。首先,断言语句本身可能不正确地编写。如果发生这种情况,断言要么报告不存在的错误,要么不报告存在的错误。
其次,您的断言应该没有副作用——也就是说,程序应该在有断言和没有断言的情况下运行相同。否则,您在调试配置中测试的内容将与发布配置中的内容不同。
还要注意,abort()函数立即终止程序,没有机会进行任何进一步的清理(例如,关闭文件或数据库)。因此,断言应该仅在程序意外终止,且不太可能发生损坏的情况下使用。
9.5.6 static_assert
C++还有另一种类型的断言,称为static_assert。static_assert是在编译时而不是在运行时检查的断言,失败的static_assert会导致编译错误。与在头文件中声明的assert不同,static_assert是一个关键字,因此不需要包含任何头文件来使用它。
static_assert采用以下形式:
static_assert(条件表达式, 诊断信息)
如果条件不为真,则打印诊断消息。下面是使用static_assert确保类型具有特定大小的示例:
static_assert(sizeof(long) == 8, "long must be 8 bytes");
static_assert(sizeof(int) >= 4, "int must be at least 4 bytes");
int main()
{
return 0;
}
关于static_assert的一些有用点:
- 由于static_assert在编译期计算,因此条件表达式必须是常量表达式。
- static_assert可以放在代码文件中的任何位置(甚至在全局命名空间中)。
- 在发布版本中不编译static_assert。
在C++17之前,必须将诊断消息作为第二个参数提供。在C++17及之后,提供诊断消息是可选的。
C++错误检查与处理学习笔记

被折叠的 条评论
为什么被折叠?



