C++学习笔记9 错误检查与处理

C++错误检查与处理学习笔记

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种通用策略可以使用:

  1. 处理函数中的错误
  2. 将错误传递回调用方以处理
  3. 停止程序
  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都将文本打印到控制台。然而,现代操作系统提供了一种将输出流重定向到文件的方法,以便可以捕获输出到文件中以供以后审查或自动处理。

对于此讨论,区分两种类型的应用程序是有用的:

  1. 交互式应用程序,用户在运行后将与之交互的应用程序。大多数独立应用程序,如游戏和音乐应用程序,都属于这一类。
  2. 非交互式应用程序,是不需要用户交互才能运行的应用程序。这些程序的输出可以用作其他应用的输入

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

  1. 工具类,通常是为了产生某些即时结果而启动,然后在产生这样的结果后终止。这方面的一个例子是Unix的grep命令,这是一个实用程序,用于搜索文本中与某种模式匹配的行。

  2. 服务类,通常在后台静默运行以执行某些功能。这方面的一个例子是病毒扫描程序。

  3. 面向用户的文本输出结果使用std::cout。

  4. 对于交互式程序,请将std::cout用于普通的面向用户的错误消息(例如,“您的输入无效”)。使用std::cerr或日志文件来记录状态和诊断信息,这些信息可能有助于诊断问题,但对于普通用户来说可能并不有趣。这可以包括技术警告和错误(例如,函数x的输入错误)、状态更新(例如,成功打开文件x,未能连接到互联网服务x)、长任务的完成百分比(例如,格式转换完成50%)等…

  5. 对于非交互式程序(工具或服务),仅将std::cerr用于错误输出(例如,无法打开文件x)。这允许错误与正常输出分开显示或分析。

  6. 对于本质上是事务性的任何应用程序类型(例如,处理特定事件的应用程序类型,如交互式web浏览器或非交互式web服务器),使用日志文件生成事件的事务日志,这些事件可以在以后查看。例如,输出当前正在处理的文件、完成百分比、开始计算的某个阶段的时间戳、警告和错误消息。

9.4 std::cin和处理无效输入

大多数具有某种用户界面的程序都需要处理用户输入。在我们现在正在编写的程序中,一直在使用std::cin来要求用户输入文本。因为文本输入是如此自由的形式(用户可以输入任何内容),所以用户很容易输入不符合期望的输入。

在编写程序时,应该始终考虑用户将如何(无意中或以其他方式)滥用程序。一个编写良好的程序将预测用户将如何滥用它,并要么优雅地处理这些情况,要么从一开始就防止它们发生(如果可能)。一个能够很好地处理错误情况的程序被认为是健壮的。

9.4.1 std::cin、缓冲区和提取

为了讨论std::cin和operator»是如何失败的,首先了解一下它们是如何工作的。

当我们使用操作符»获取用户输入并将其放入变量时,这称为“提取”。在该上下文中使用时,»操作符相应地称为提取操作符。

当用户响应提取操作输入时,该数据被放在std::cin内的缓冲区中。缓冲区(也称为数据缓冲区)只是一块内存,用于在数据从一个位置移动到另一个位置时临时存储数据。在这种情况下,缓冲区用于保存用户输入,同时等待将其提取为变量。

使用提取操作符时,将执行以下过程:

  1. 如果输入缓冲区中已经存在数据,则使用该数据进行提取。
  2. 如果输入缓冲区不包含数据,则要求用户输入数据以进行提取(大多数情况下都是这样)。当用户点击enter时,将在输入缓冲区中放置“\n”字符。
  3. 操作符»将尽可能多的数据从输入缓冲区提取到变量中(忽略任何前导空格字符,如空格、制表符或“\n”)。
  4. 无法提取的任何数据都留在输入缓冲区中,供下一次提取使用。

如果从输入缓冲区提取了至少一个字符,则提取成功。任何未提取的输入都保留在输入缓冲区中,以供将来提取。

9.4.2 验证输入

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

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

用户边输入边校验:

  1. 阻止用户输入无效的字符。

用户输入完成后再校验:

  1. 将用户输入的所有保存到字符串中, 然后验证字符串是否ok, 如果ok, 将字符串转换成最终的格式
  2. 让用户任意输入, 使用 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 无效文本输入的类型

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

  1. 输入提取成功,但输入对程序没有意义(例如,输入“k”作为数学运算符)。
  2. 输入提取成功,但用户后续输入了其他输入(例如,输入“*q hello”作为数学运算符)。
  3. 输入提取失败(例如,尝试在数字输入中输入“q”)。
  4. 输入提取成功,但输入数值发生了溢出。

因此,为了使程序健壮,每当要求用户输入时,理想情况下,应该确定上述每一种情况是否可能发生,并编写代码来处理这些情况。

9.4.4 错误情况1:提取成功,但输入无意义

这里的解决方案很简单:进行输入验证。这通常包括3部分:

  1. 检查用户的输入是否是我们所期望的
  2. 如果是的话,执行后续流程
  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的一些有用点:

  1. 由于static_assert在编译期计算,因此条件表达式必须是常量表达式。
  2. static_assert可以放在代码文件中的任何位置(甚至在全局命名空间中)。
  3. 在发布版本中不编译static_assert。

在C++17之前,必须将诊断消息作为第二个参数提供。在C++17及之后,提供诊断消息是可选的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值