C++学习笔记3 调试

3 调试

3.1 语法错误和语义错误

3.1.1 语法错误

错误通常分为两类:语法错误和语义错误(逻辑错误)。

当你编写的语句不符合 C++ 语言的语法时,就会发生语法错误。这包括缺少分号、括号或大括号不匹配等错误。

编译器会检测语法错误并发出编译警告或错误,因此您可以轻松识别并修复问题。之后,只需重新编译,直到消除所有错误即可。

3.1.2 语义错误

语义错误是指含义上的错误。当语句在语法上有效,但违反了语言的其他规则,或没有按照程序员的意图执行时,就会发生语义错误。

编译器可以捕获某些语义错误。常见示例包括使用未声明的变量、类型不匹配(在某个地方使用错误类型的对象)等等……

其他语义错误仅在运行时才会显现。有时这些错误会导致程序崩溃,例如除以零。

3.2 调试过程

调试的通用方法

调试问题通常包括六个步骤:

  1. 找到问题的根本原因(通常是无法正常工作的代码行)。
  2. 了解问题发生的原因。
  3. 确定如何解决该问题。
  4. 修复问题。
  5. 重新测试以确保问题已得到解决。
  6. 重新测试以确保没有出现新的问题。

考虑下面这个程序:

#include <iostream>

int add(int x, int y) // this function is supposed to perform addition
{
    return x - y; // but it doesn't due to the wrong operator being used
}

int main()
{
    std::cout << "5 + 3 = " << add(5, 3) << '\n'; // should produce 8, but produces 2

    return 0;
}

这段代码看起来可以正常编译运行,但函数返回的值并不是两变量相加的结果,是典型的语义错误。

找到根本原因:在第 10 行,我们可以看到我们传入的是字面量作为参数(5 和 3),所以那里不可能出错。由于函数add的输入是正确的,但输出却不正确,所以很明显函数add产生了错误的值。函数add中唯一的语句是 return 语句,它肯定是罪魁祸首。我们找到了问题所在。既然我们知道应该关注哪里,那么通过检查,你很可能会发现我们做的是减法而不是加法。

了解问题:在这种情况下,为什么会生成错误的值很明显——我们使用了错误的运算符。

**确定修复方法:**我们只需将operator-更改为operator+

**修复问题:**这实际上是将运算符-更改为运算符+,并确保程序重新编译。

**重新测试:**实施更改后,重新运行程序将表明我们的程序现在产生正确的值 8。对于这个简单的程序,这就是所需的全部测试。

3.3 调试策略

在调试程序时,大多数情况下,你的大部分时间都会花在查找错误的具体位置上。一旦找到问题所在,剩下的步骤(修复问题并验证问题是否已修复)通常就显得微不足道了。

3.3.1 通过代码检查发现问题

我们可以根据错误的性质和程序的结构来估计问题可能出现的位置。

考虑以下程序片段:

int main()
{
    getNames(); // ask user to enter a bunch of names
    sortNames(); // sort them in alphabetical order
    printNames(); // print the sorted list of names

    return 0;
}

如果您预期此程序会按字母顺序打印姓名,但它却以相反的顺序打印,那么问题可能出在sortNames函数上。如果您可以将问题范围缩小到某个特定函数,那么只需查看代码就能发现问题。

然而,随着程序变得越来越复杂,通过代码检查查找问题也变得越来越复杂。

首先,需要查看的代码量很大。查看数千行程序中的每一行代码可能会花费很长时间。其次,代码本身往往更复杂,出错的可能性也更大。第三,代码的行为可能无法提供太多线索来判断问题出在哪里。如果你编写了一个输出股票推荐的程序,但它实际上什么也没输出,那么你可能根本不知道该从哪里开始寻找问题所在。

最后,错误也可能是由错误的假设引起的。几乎不可能通过肉眼识别出由错误假设导致的错误,因为在检查代码时,你很可能会做出同样的错误假设,而忽略了错误本身。那么,如果我们遇到无法通过代码检查发现的问题,该如何找到它呢?

3.3.2 通过运行程序发现问题

如果我们无法通过代码检查发现问题,我们还可以采取另一种方法:观察程序运行时的行为,并尝试据此诊断问题。这种方法可以概括为:

  1. 弄清楚如何重现问题
  2. 运行程序并收集信息以缩小问题范围
  3. 重复前面的步骤,直到找到问题

3.3.3 重现问题

发现问题的第一步,也是最重要的一步,是能够重现问题。重现问题意味着使问题以一致的方式出现。原因很简单:除非你能够观察到问题的发生,否则很难发现问题。

如果软件问题很明显(例如,程序每次运行都会在同一个地方崩溃),那么重现问题可能很容易。然而,有时重现问题会困难得多。问题可能只发生在特定的计算机上,或者在特定情况下(例如,当用户输入某些内容时)。在这种情况下,生成一组重现步骤会很有帮助。重现步骤是一系列清晰精确的步骤,遵循这些步骤可以高度可预测地使问题重现。目标是尽可能地使问题重现,以便我们反复运行程序并寻找线索来确定问题的原因。如果问题可以 100% 重现最好,但重现率低于 100% 也是可以接受的。如果问题发生的概率只有 50%,那么诊断问题所需的时间将加倍,因为一半的时间程序不会显示问题,因此不会提供任何有用的诊断信息。

3.3.4 定位发生错误的代码

在最坏的情况下,我们可能不知道错误在哪里。但是,我们确实知道问题一定出在程序开头和程序出现我们能观察到的第一个错误症状之间执行的代码中。这至少排除了在第一个可观察到的症状之后执行的程序部分。但这仍然可能留下大量代码需要覆盖。为了诊断问题,我们将对问题所在进行一些有根据的猜测,目的是快速找到问题所在。

通常,无论是什么原因导致我们注意到这个问题,都会给我们一个接近实际问题所在位置的初步猜测。例如,如果程序没有在应该写入数据到文件时将其写入,那么问题很可能出在处理写入文件的代码中。

3.3.5 基本调试策略

调试策略1:注释掉代码

我们先从一个简单的问题开始。如果你的程序出现了错误行为,减少需要搜索的代码量的一种方法是注释掉一些代码,看看问题是否仍然存在。如果问题仍然存在,那么注释掉的代码可能不是问题所在。

考虑以下代码:

int main()
{
    getNames(); // ask user to enter a bunch of names
    doMaintenance(); // do some random stuff
    sortNames(); // sort them in alphabetical order
    printNames(); // print the sorted list of names

    return 0;
}

假设这个程序应该按字母顺序打印用户输入的姓名,但它打印出来的却是逆序的。问题出在哪里?是getNames输入的姓名有误吗?是sortNames 的排序顺序颠倒了吗?还是printNames 的打印顺序颠倒了?以上任何一种情况都有可能。但我们可能怀疑 doMaintenance() 与问题无关,所以我们把它注释掉。

int main()
{
    getNames(); // ask user to enter a bunch of names
//    doMaintenance(); // do some random stuff
    sortNames(); // sort them in alphabetical order
    printNames(); // print the sorted list of names

    return 0;
}

可能的结果有三种:

  • 如果问题消失,那么一定是doMaintenance导致了问题,我们应该把注意力集中在那里。
  • 如果问题没有改变(这种可能性更大),那么我们可以合理地假设doMaintenance没有问题,并且暂时可以排除整个函数的搜索范围。这并不能帮助我们理解问题实际发生在doMaintenance调用之前还是之后,但它减少了我们后续需要查看的代码量。
  • 如果注释掉doMaintenance导致问题演变成其他相关问题(例如,程序停止打印姓名),那么很可能是doMaintenance正在执行一些其他代码依赖的有用操作。在这种情况下,我们可能无法判断问题出在doMaintenance还是其他地方,因此我们可以取消注释doMaintenance并尝试其他方法。

调试策略2:验证代码流

在更复杂的程序中常见的另一个问题是程序调用函数的次数过多或过少(包括根本没有调用)。

在这种情况下,在函数顶部放置语句来打印函数名称会很有帮助。这样,当程序运行时,你就可以看到哪些函数被调用了。

出于调试目的打印信息时,请使用std::cerr而不是std::cout。这样做的原因之一是std::cout是缓冲的,这意味着从您请求输出文本到实际输出之间可能会有一段时间,如果程序调用函数后立即崩溃,可能输出还在缓冲区,导致误判问题位置。

另一方面,std::cerr是非缓冲的,这意味着您发送给它的任何内容都会立即输出。这有助于确保所有调试输出尽快出现(但会牺牲一些性能,而我们在调试时通常不会关心这些性能)。

调试策略 3:打印值

虽然为了诊断目的而向程序中添加调试语句是一种常见的基本技术,也是一种实用的技术(特别是当由于某种原因无法使用调试器时),但它并不是那么好,原因如下:

  1. 调试语句会使您的代码变得混乱。
  2. 调试语句会使程序的输出变得混乱。
  3. 调试语句需要修改代码以添加和删除,这可能会引入新的错误。
  4. 使用完调试语句后必须将其删除,这使得它们不可重复使用。

3.3.6 更多调试策略

条件化你的调试代码

考虑以下包含一些调试语句的程序:

#include <iostream>

int getUserInput()
{
std::cerr << "getUserInput() called\n";
	std::cout << "Enter a number: ";
	int x{};
	std::cin >> x;
	return x;
}

int main()
{
std::cerr << "main() called\n";
    int x{ getUserInput() };
    std::cout << "You entered: " << x << '\n';

    return 0;
}

完成调试语句后,您需要将其删除或注释掉。之后,如果以后需要它们,则必须将其重新添加或取消注释。

在整个程序中更容易禁用和启用调试的一种方法是使用预处理器指令使调试语句有条件:

#include <iostream>

#define ENABLE_DEBUG // comment out to disable debugging

int getUserInput()
{
#ifdef ENABLE_DEBUG
std::cerr << "getUserInput() called\n";
#endif
	std::cout << "Enter a number: ";
	int x{};
	std::cin >> x;
	return x;
}

int main()
{
#ifdef ENABLE_DEBUG
std::cerr << "main() called\n";
#endif
    int x{ getUserInput() };
    std::cout << "You entered: " << x << '\n';

    return 0;
}

现在,我们只需注释/取消注释*#define ENABLE_DEBUG*即可启用调试。这样,我们就可以重复使用之前添加的调试语句,并在使用完毕后禁用它们,而不必真正将它们从代码中删除。如果这是一个多文件程序,#define ENABLE_DEBUG 应该放在一个头文件中,该头文件会被包含在所有代码文件中,这样我们就可以在单个位置注释/取消注释 #define 并将其传播到所有代码文件。

这解决了必须删除调试语句以及删除调试语句所带来的风险的问题,但却会导致代码更加混乱。这种方法的另一个缺点是,如果您输入错误(例如拼写错误“DEBUG”)或忘记将头文件包含在代码文件中,则该文件的部分或全部调试功能可能无法启用。因此,尽管这比无条件版本更好,但仍有改进的空间。

使用记录器

通过预处理器进行条件调试的另一种方法是将调试信息发送到日志。日志是对已发生事件的顺序记录,通常带有时间戳。生成日志的过程称为日志记录。通常,日志会被写入磁盘上的文件(称为日志文件),以便日后查看。大多数应用程序和操作系统都会编写日志文件,用于帮助诊断发生的问题。

日志文件有几个优点。由于写入日志文件的信息与程序的输出是分开的,因此您可以避免将正常输出和调试输出混在一起造成的混乱。日志文件还可以轻松地发送给其他人进行诊断——因此,如果使用您的软件的人遇到问题,您可以让他们将日志文件发送给您,这可能会帮助您找到问题所在。

C++ 包含一个名为std::clog的输出流,旨在用于写入日志信息。但是,默认情况下,std::clog会写入标准错误流(与std::cerr相同)。虽然您可以将其重定向到文件,但在这种情况下,通常最好使用众多现有的第三方日志记录工具之一。使用哪一个取决于您自己。

为了便于说明,我们将使用Plog记录器演示如何将数据输出到记录器。Plog 实现为一组头文件,因此可以轻松将其添加到任何需要的地方,而且它轻量级且易于使用。

#include <plog/Log.h> // Step 1: include the logger headers
#include <plog/Initializers/RollingFileInitializer.h>
#include <iostream>

int getUserInput()
{
	PLOGD << "getUserInput() called"; // PLOGD is defined by the plog library

	std::cout << "Enter a number: ";
	int x{};
	std::cin >> x;
	return x;
}

int main()
{
	plog::init(plog::debug, "Logfile.txt"); // Step 2: initialize the logger

	PLOGD << "main() called"; // Step 3: Output to the log as if you were writing to the console

	int x{ getUserInput() };
	std::cout << "You entered: " << x << '\n';

	return 0;
}

这是上述记录器的输出(在Logfile.txt文件中):

2018-12-26 20:03:33.295 DEBUG [4752] [main@19] main() called
2018-12-26 20:03:33.296 DEBUG [4752] [getUserInput@7] getUserInput() called

如何包含、初始化和使用记录器将根据您选择的具体记录器而有所不同。

请注意,使用此方法也不需要条件编译指令,因为大多数日志记录器都提供了减少/消除写入日志输出的方法。这使得代码更易于阅读,因为条件编译语句会增加很多混乱。使用 plog,可以通过将 init 语句更改为以下内容来暂时禁用日志记录:

plog::init(plog::none , "Logfile.txt"); // plog::none eliminates writing of most messages, essentially turning logging off

3.4 单步调试

程序的运行是从主函数的顶部开始按顺序逐条语句执行,直到程序结束。在程序运行的任何时间点,程序都会跟踪很多信息:正在使用的变量的值、已调用的函数(以便当这些函数返回时,程序知道返回到哪里),以及程序中的当前执行点(以便知道接下来要执行哪个语句)。这些跟踪的信息称为程序状态(或简称为state)。

大多数现代 IDE 都带有一个称为调试器的集成工具,它是一种计算机程序,允许程序员控制另一个程序的执行方式,并在该程序运行时检查程序状态。例如,程序员可以使用调试器逐行执行程序,并检查过程中变量的值。通过将变量的实际值与预期值进行比较,或者观察代码的执行路径,调试器可以极大地帮助追踪语义(逻辑)错误。

调试器的功能是双重的:精确控制程序执行的能力,以及查看(如果需要,还可以修改)程序状态的能力。

3.4.1 步进(stepping)

步进是一组相关调试器功能的名称,它允许我们逐语句执行(单步执行)代码。

单步执行(step into)

step into命令(visual studio命名为逐语句)会按照程序的正常执行路径执行下一条语句,然后暂停程序的执行,以便我们可以使用调试器检查程序的状态。如果正在执行的语句包含函数调用,step into会使程序跳转到被调用函数的顶部,并在那里暂停。

跳过执行(step over)

与step into类似,step over命令(visual studio命名为逐过程)会执行程序正常执行路径中的下一条语句。但是如果下一行是子函数,step over可以不间断地执行整个函数,并在函数执行完成后返还控制权。

跳出执行(step out)

与另外两个单步调试命令不同,step out命令(visual studio命名为跳出)不仅仅执行下一行代码,而是执行当前正在执行的函数中的所有剩余代码,并在函数执行完成后返还控制权。

回退(step back)

回退可以将程序返回到之前的状态。如果您跨步执行,或者想要重新检查刚刚执行的语句,此功能非常有用。

3.4.2 运行和断点

虽然单步执行对于单独检查代码的每一行很有用,但在大型程序中,单步执行代码可能需要很长时间才能到达您想要更详细检查的点。因此接下来我们将了解一些可以更快地浏览代码的调试器功能。

运行至光标处

运行至光标处命令会执行程序,直到执行到光标选定的语句时将控制权返还,以便您可以从该位置开始调试。这提供了一种高效的方法,可以从代码中的特定位置开始调试;或者,如果已经在调试,则可以直接移动到您想要进一步检查的位置。

继续和开始

一旦进入调试会话,您可能希望从该点开始继续运行程序。最简单的方法是使用continue命令。continue调试命令会继续正常运行程序,直到程序终止,或者直到某些事件触发再次返还控制权。

continue命令有一个孪生兄弟,名为start。start命令执行操作与continue相同,只是它会从程序开头开始执行。它只能在非调试会话中调用。

断点

断点是一个特殊的标记,它告诉调试器在调试模式下运行时在断点处停止程序的执行。

与运行到光标命令相比,断点有几个优点。首先,每次遇到断点时,调试器都会将控制权交还给你(这与运行到光标命令不同,后者每次调用时只会运行到光标处一次)。其次,你设置的断点会一直存在,直到你移除它;而运行到光标命令每次调用时,你都必须找到想要运行到的位置。

设置下一个语句

set next statement命令允许我们将执行点更改为其他语句(有时非正式地称为jumping)。这可以用来向前跳转执行点并跳过一些原本会执行的代码,或者向后跳转并重新运行已经执行过的代码。

3.4.3 观察变量

观察变量是指在程序以调试模式执行时检查变量值的过程。大多数调试器都提供了几种方法来执行此操作。

检查简单变量的值的最简单方法是将鼠标悬停在变量标识符上。一些现代调试器支持这种检查简单变量的方法,这是最直接的方法。

如果使用的是 Visual Studio,还可以使用“快速监视”。用鼠标突出显示变量名称,然后从右键菜单中选择“快速监视”。这将弹出一个包含变量当前值的子窗口。

监视窗口

如果您想知道某个特定时间点的变量值,使用鼠标悬停或快速监视方法来检查变量是可以的,但它并不特别适合在运行代码时观察变量值的变化,因为必须不断地重新悬停/重新选择变量。

为了解决这个问题,所有现代集成调试器都提供了另一个功能,称为“监视窗口”。在监视窗口里可以添加想要持续检查的变量,这些变量会在您单步执行程序时更新。进入调试模式时,监视窗口可能已经显示在屏幕上,但如果没有,您可以通过 IDE 的窗口命令(通常位于“视图”或“调试”菜单中)调出它。

使用监视是观察程序执行过程中变量值随时间变化的最佳方式。一些调试器允许你在监视的变量上设置断点,而不是在一行代码上。这样,只要该变量的值发生变化,程序就会停止执行。

监视窗口还支持计算简单表达式的值。被监视的表达式中的标识符将计算为其当前值。如果您想知道代码中某个表达式的实际计算结果,要先将光标移动到该表达式,以确保所有标识符都具有正确的值。

3.4.4 调用堆栈

当你的程序调用一个函数时,你已经知道它会标记当前位置,进行函数调用,然后返回。它如何知道返回到哪里?答案是它会在调用堆栈中跟踪。

调用堆栈是所有已调用的活动函数的列表,这些函数最终会到达当前的执行点。调用堆栈包含每个被调用函数的条目,以及函数返回时将返回到哪一行代码。每当调用一个新函数时,该函数都会被添加到调用堆栈的顶部。在当前函数返回给调用者时,它会从调用堆栈的顶部移除,控制权将返回到它下面的函数。

调用堆栈窗口是一个调试器窗口,用于显示当前的调用堆栈。

第一行是正在执行的函数,函数名后的行号是函数将执行的下一条语句。

第二行是第一行函数返回后将执行的函数,函数名后的行号是函数返回后将执行的下一条语句。

函数返回后将从调用堆栈中移除。

3.5 重构代码

随着程序不断添加新功能(“行为变化”),某些函数的长度会变长。随着函数变长,它们会变得更加复杂,也更难理解。

解决这个问题的一种方法是将一个长函数拆分成多个短函数。这种在不改变代码行为的情况下对代码进行结构性修改的过程称为重构。重构的目标是通过增强程序的组织性和模块化来降低程序的复杂性。

对于一个函数来说,多长才算太长呢?一个函数如果占据了整个垂直屏幕的代码量,通常就被认为太长了——如果你必须滚动才能阅读整个函数,那么该函数的可理解性就会大大降低。理想情况下,一个函数应该少于十行。少于五行的函数就更好了。

修改代码时,最好只修改行为或只修改结构,然后重新测试以确保正确性。同时修改行为和结构往往会导致更多错误,而且这些错误更难发现。

3.6 防御性编程

错误不仅可能由您自己造成(例如逻辑错误),也可能由用户以您未预料到的方式使用应用程序而导致。例如,如果您要求用户输入一个整数,而他们输入的却是一个字母,您的程序在这种情况下会如何表现?除非您预料到了这种情况,并为此添加了一些错误处理,否则程序的表现可能不太好。

防御性编程是一种软件开发方法论,其核心思想是通过预先设计代码来预防潜在的错误、异常或恶意输入,从而提高程序的健壮性、安全性和可维护性。它强调对代码的“不信任”,即假设外部输入、依赖或环境可能存在不可预见的问题,并主动采取措施应对。

3.7 快速查找错误

3.7.1 步步调试

由于在大型程序中不犯错误是很困难的,因此下一个最好的办法就是快速发现所犯的错误。

最好的方法是一次编写一点程序,然后调试代码并确保它能正常工作。

3.7.2 测试函数

发现程序问题的一个常见方法是编写测试函数来“测试”你写的代码。

这是单元测试的原始形式,单元测试是一种软件测试方法,通过测试源代码的小单元来确定它们是否正确。

与日志记录框架一样,有许多第三方单元测试框架可供使用。

3.7.3 约束

基于约束的技术涉及添加一些额外的代码以检查某些异常。

例如,如果我们编写一个函数来计算一个数字的阶乘,该函数需要一个非负参数,那么该函数可以检查调用者传入的参数是否为非负数,然后再继续执行。如果调用者传入的是负数,那么函数可以立即出错,而不是产生一些不确定的结果,这有助于确保问题能够被立即发现。一种常见的方法是通过assertstatic_assert

3.7.4 静态分析工具

程序员往往会犯某些常见错误,而其中一些错误可以通过专门用于识别这些错误的程序发现。这些程序通常被称为静态分析工具

它们会分析您的源代码以识别特定的语义问题(此处的”静态“指的是这些工具只分析源代码而不执行代码)。静态分析工具发现的问题可能是您遇到的任何问题的根源,也可能不是,但可能有助于指出代码中的薄弱环节或在某些情况下可能出现的问题。

对于大型程序,强烈建议使用静态分析工具,因为它可以发现数十甚至数百个潜在问题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值