跳转至

Error Detection and Handling

Unit Test

单独测试一小部分代码以确保代码“单元”正确,称为单元测试。每个单元测试旨在确保单元的特定行为是正确的。

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

如何写单元测试?

不同的语言有不同的测试框架,我们暂时先忽略这一块

函数错误处理

这里我们讨论==函数内部的错误处理==策略(出现问题时该怎么做),至于检验用户输入是否合法,我们留给后面介绍

策略:

  1. Handling errors within the function
  2. Pass the error back to the caller to deal with
  3. Halt the program
  4. Throw an exception

Handling errors within the function

如果可能,最好的策略是在发生错误的同一函数中从错误中恢复,以便可以包含并纠正错误,而不会影响函数外部的任何代码。

这里有两个选项:重试直到成功,或者取消正在执行的操作。

1)重试直到成功:

比如:如果程序需要互联网连接,而用户失去了连接,则程序可能会显示警告,然后使用循环定期重新检查互联网连接

2)忽略错误和/或取消操作:

C++
1
2
3
4
5
6
// Silent failure if y=0
void printIntDivision(int x, int y)
{
    if (y != 0)
        std::cout << x / y;
}

修改后:

C++
1
2
3
4
5
6
7
void printIntDivision(int x, int y)
{
    if (y != 0)
        std::cout << x / y;
    else
        std::cout << "Error: Could not divide by zero\n";
}

当然,这只是显示地爆出了错误,并没有起到修正的作用;如果调用函数期望被调用函数产生返回值或一些有用的副作用,那么仅仅忽略错误可能不是一个 good choice.

Pass the error back to the caller to deal with

在许多情况下,检测到错误的函数中无法合理地处理错误,我们有时候希望最好能将错误传递回调用函数,让调用函数决定如何处理错误。

我们通常采用 return value 来反馈!

C++
1
2
3
4
5
6
7
void printIntDivision(int x, int y)
{
    if (y != 0)
        std::cout << x / y;
    else
        std::cout << "Error: Could not divide by zero\n";
}

这样是可以打印出错误情况,但是我们可能不仅仅需要知道,还得handle它,因此我们改变函数的返回值类型

C++
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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;
}

这样我们就可以在调用函数中处理错误情况了,比如在修改后的函数中,我们可以用handle()函数来进行错误处理:

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

void handle (bool ret_v)
{
    if (!ret_v) {
        // handle error
        return;
    }
    cout << "Success\n";
}

int main()
{
    handle(printIntDivision(10, 2));
    handle(printIntDivision(10, 0));
    return 0;
}

但是!如果函数返回一个正常值,事情就有点复杂了。

在某些情况下,不会使用完整范围的返回值。在这种情况下,我们可以使用通常不可能出现的返回值来指示错误。例如,考虑以下函数:

C++
1
2
3
4
5
// The reciprocal of x is 1/x
double reciprocal(double x)
{
    return 1.0 / x;
}

如果用户将此函数调用为reciprocal(0.0)会发生什么情况?我们遇到divide by zero错误和程序崩溃,因此显然我们应该防止这种情况。

但是这个函数必须返回一个double值,那么我们应该返回什么值呢?事实证明,这个函数永远不会产生0.0作为合法结果,因此我们可以返回0.0来指示错误情况。

C++
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 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;
}
哨兵值

哨兵值:是在函数或算法上下文中具有某些特殊含义的值

比如这里的error_no_reciprocal,它的值是0.0,这就是一个哨兵值,指示函数失败。

调用者可以测试返回值以查看它是否与哨兵值匹配 - 如果是,则调用者知道函数失败

如果函数可以生成完整范围的返回值,则不存在哨兵值

函数可以生成完整范围的返回值,哨兵值不存在,这该咋办?

这种情况下,返回std::optional(或std::expected)将是更好的选择!

ref

Fatal errors

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

如果代码位于main()或直接从main()调用的函数中,最好的办法是让main()返回非零状态代码。但是,如果我们深入某些嵌套子函数,则可能不方便或不可能将错误一直传播回main()

在这种情况下,可以使用halt statement (例如std::exit()

C++
1
2
3
4
5
6
7
8
9
double doIntDivision(int x, int y)
{
    if (y == 0)
    {
        std::cout << "Error: Could not divide by zero\n";
        std::exit(1);
    }
    return x / y;
}

这里我们需要区分:exit(1)return 1的区别:

  • return 将控制权返回给调用函数,允许程序继续执行调用栈的展开
  • std::exit() 直接终止整个程序,不会返回到调用函数,后面的代码都不会执行

当需要从深层嵌套函数立即终止程序时,使用std::exit()更方便; 如果需要正常的清理过程,应该使用return并将错误状态传播回main().

在main()函数中,return 0exit(0) 两者效果基本相同,因为都会导致程序终止。推荐使用return

Exceptions

C++ 提供了一种完全独立的方法将错误传递回调用者: exceptions.

基本思想是,当发生错误时,“抛出”异常。如果当前函数没有“捕获”错误,则函数的调用者有机会捕获错误。如果调用者没有捕获错误,则调用者的调用者有机会捕获错误。错误在调用堆栈中逐渐向上移动,直到它被捕获并处理(此时执行正常继续),或者直到 main() 无法处理错误(此时程序因异常错误而终止)。

将底层的错误逐级上传,在这一过程中能handle最好,真hold不住的话,那就在最顶层的main函数报错

断言

Assert是一种在==程序运行时==检查程序状态的方法。如果断言的条件为假,则程序会终止。assert通常用于检查不应该发生的错误。

如果表达式的计算结果为true,则断言语句不执行任何操作。如果条件表达式的计算结果为false,则会显示错误消息并终止程序(通过std::abort)。

此错误消息通常包含失败的文本表达式,以及代码文件的名称和断言的行号。这使得不仅可以很容易地判断出问题所在,还可以轻松判断代码中问题发生的位置。这可以极大地帮助调试工作。

最初形式:

C++
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
void printDivision(int x, int y)
{
    if (y == 0) // handle
    {
        std::cerr << "Error: Could not divide by zero\n";
        return; // bounce the user back to the caller
    }

    // We now know that y != 0
    std::cout << static_cast<double>(x) / y;
}

在 C++ 中,运行时assert是通过断言预处理器宏实现的,该宏位于标头:

C++
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
#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;
}

当程序调用 calculateTimeUntilObjectHitsGround(100.0, -9.8)时,assert(gravity > 0.0)将计算为false,这将触发断言,并将打印类似如下的消息:

C++
1
dropsimulator: src/main.cpp:6: double calculateTimeUntilObjectHitsGround(double, double): Assertion 'gravity > 0.0' failed.

不一定完全一致,实际消息因使用的编译器而异。

采用更具有描述性的断言语句更具描述性

有时断言表达式的描述性不强。考虑以下陈述:

C++
1
assert(found);

幸运的是,您可以使用一个小技巧来使您的断言语句更具描述性。只需添加一个由逻辑 AND 连接的字符串文字:

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

这样,当断言触发时,字符串文字将包含在断言消息中:

C++
1
Assertion failed: found && "Car could not be found in database", file C:\\VCProjects\\Test.cpp, line 34

如何我们可以获取一些额外背景信息。

想把assert关了怎么办

每次检查断言条件时,assert 宏都会产生少量性能成本。此外,断言(理想情况下)不应在生产代码中遇到(因为您的代码应该已经经过彻底测试)。

因此,大多数开发人员更喜欢断言仅在调试版本中有效。 C++ 提供了一种内置方法来关闭生产代码中的断言:如果定义了预处理器宏NDEBUG,则断言宏将被禁用。

放在自己的行上的任何 #include 之前: #define NDEBUG (以禁用断言)或 #undef NDEBUG (以启用断言)。

C++
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#define NDEBUG // disable asserts (must be placed before any #includes)
#include <cassert>
#include <iostream>

int main()
{
    assert(false); // won't trigger since asserts have been disabled in this translation unit
    std::cout << "Hello, world!\n";
    return 0;
}

std::optional

C++17 引入了 std::optional,它是实现可选值的类模板类型。也就是说,std::optional<T> 可以具有 T 类型的值,也可以没有。我们可以用它来实现上面的第三个选项:

C++
 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
#include <iostream>
#include <optional> // for std::optional (C++17)

// Our function now optionally returns an int value
std::optional<int> doIntDivision(int x, int y)
{
    if (y == 0)
        return {}; // or return std::nullopt
    return x / y;
}

int main()
{
    std::optional<int> result1 { doIntDivision(20, 5) };
    if (result1) // if the function returned a value
        std::cout << "Result 1: " << *result1 << '\n'; // get the value
    else
        std::cout << "Result 1: failed\n";

    std::optional<int> result2 { doIntDivision(5, 0) };

    if (result2)
        std::cout << "Result 2: " << *result2 << '\n';
    else
        std::cout << "Result 2: failed\n";

    return 0;
}

显示结果:

C++
1
2
Result 1: 4
Result 2: failed

初始化

C++
1
2
3
std::optional<int> o1 {5}; // init with a specific value
std::optional<int> o2 {}; // init with no value
std::optional<int> o3 {std::nullopt}; // init with no value

查看std::optional是否有值

C++
1
2
3
4
5
6
7
if (o1) { // method-1
    ...
}

if (o2.has_value()) { // method-2
    ...
}

std::optional获取值

C++
1
2
std::cout << *o1 <<std::endl; // dereference o1
std::cout << o2.value() <<std::endl; // get value in "what o2 pointing to"

工作原理

我们的doIntDivision()现在返回std::optional<int>而不是int

在函数体内,如果我们检测到错误,我们将返回{},它隐式返回一个不包含任何值的std::optional。如果我们有一个值,我们会返回该值,这会隐式返回包含该值的std::optional,如: {valueXXX}

建议仅当T通常==按值传递==时才使用std::optional<T>作为可选参数。否则,使用const T*.