3-4 基础调试策略

在上节课中,我们探讨了一种通过运行程序并借助猜测来锁定问题所在的排查策略。本节课我们将学习一些基础技巧,用于实际进行这些猜测并收集信息以协助发现问题。


调试技巧 #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还是其他地方,因此应取消注释并尝试其他排查方法。

警告

请务必记住哪些函数已被注释掉,以便后续取消注释!

在进行大量调试相关修改后,很容易遗漏一两处撤销操作。若发生这种情况,你最终可能修复了一个错误却引入了其他问题!

此时采用完善的版本控制系统将极具价值——你可以通过代码差异对比功能查看主分支中所有调试相关的变更(并在提交修改前确保这些变更已被撤销)。

提示:
重复添加/移除或取消注释/注释调试语句的另一种方法是使用第三方库,该库允许您在代码中保留调试语句,但在发布模式下通过预处理器宏将其编译掉。dbg 就是这样一个仅需头文件的库,它通过 DBG_MACRO_DISABLE 预处理器宏来实现此功能。

关于头文件库的详细讨论,请参见第7.9课——内联函数与变量。


调试技巧二:验证代码流程

更复杂程序中常见的另一个问题是函数被调用次数过多或过少(包括完全未被调用)。

此时,在函数开头添加打印函数名称的语句会很有帮助。这样程序运行时,就能看到哪些函数被调用了。

提示

进行调试输出时,请使用 std::cerr 代替 std::cout。其中一个原因是 std::cout 可能存在缓冲机制,这意味着从向 std::cout 发送输出请求到实际输出之间可能存在时间差。若使用 std::cout 输出后程序立即崩溃,std::cout 可能尚未完成输出。这会导致问题定位出现偏差。而 std::cerr 采用无缓冲机制,发送至该对象的任何内容都会立即输出。这有助于确保所有调试输出尽可能及时显示(代价是性能略有下降,但调试时通常不必在意)。

使用 std::cerr 还能明确区分输出信息属于异常情况而非正常情况。

关于何时使用 std::cout 与 std::cerr 的详细讨论,请参见第 9.4 课——错误检测与处理。

请考虑以下无法正常运行的简单程序:

#include <iostream>

int getValue()
{
	return 4;
}

int main()
{
    std::cout << getValue << '\n';

    return 0;
}

您可能需要禁用“将警告视为错误”才能编译上述代码。

虽然我们预期该程序会输出值4,但它应输出值:

(我这里使用了clang++ main.cpp -o main来构建, ./main运行)
image

在 Visual Studio(以及可能其他某些编译器)上,它可能会打印以下内容:

00101424

相关内容
我们在第20.1节——函数指针中讨论了为何某些编译器会输出1而非地址(以及当编译器输出1而你希望输出地址时该如何处理)。

让我们在这些函数中添加一些调试语句:

#include <iostream>

int getValue()
{
std::cerr << "getValue() called\n";
	return 4;
}

int main()
{
std::cerr << "main() called\n";
    std::cout << getValue << '\n';

    return 0;
}

提示
添加临时调试语句时,不缩进这些语句会更方便后续查找并移除。
若使用 clang-format 格式化代码,它会尝试自动缩进这些行。可通过以下方式抑制自动格式化:

// clang-format off
std::cerr << "main() called\n";
// clang-format on

现在当这些函数执行时,它们会输出自己的名称,表明它们已被调用:

image

(我这里使用了clang++ main.cpp -o main来构建, ./main运行)
image

现在我们可以看到,getValue函数从未被调用。调用该函数的代码中必定存在问题。让我们仔细看看那行代码:

std::cout << getValue << '\n';

哦,看,我们忘记在函数调用中添加括号了。正确写法应该是:

#include <iostream>

int getValue()
{
std::cerr << "getValue() called\n";
	return 4;
}

int main()
{
std::cerr << "main() called\n";
    std::cout << getValue() << '\n'; // added parenthesis here

    return 0;
}

这将产生正确的输出结果

image

我们可以移除临时调试语句。


调试技巧 #3:打印值

某些类型的错误可能源于程序计算或传递了错误的值。

我们也可以输出变量(包括参数)或表达式的值,以确保其正确性。

考虑以下程序:它本应执行两个数字的加法运算,但未能正确运行:

#include <iostream>

int add(int x, int y)
{
	return x + y;
}

void printResult(int z)
{
	std::cout << "The answer is: " << z << '\n';
}

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

int main()
{
	int x{ getUserInput() };
	int y{ getUserInput() };

	int z{ add(x, 5) };
	printResult(z);

	return 0;
}

以下是该程序的输出结果:

(此时, 其实y会报定义未使用错误, 前面添加[[maybe_unused]]才会通过构建)
image

不对。你发现错误了吗?即使在这个简短的程序中,也很难发现问题。让我们添加一些代码来调试我们的值:

#include <iostream>

int add(int x, int y)
{
	return x + y;
}

void printResult(int z)
{
	std::cout << "The answer is: " << z << '\n';
}

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

int main()
{
	int x{ getUserInput() };
std::cerr << "main::x = " << x << '\n';
	int y{ getUserInput() };
std::cerr << "main::y = " << y << '\n';

	int z{ add(x, 5) };
std::cerr << "main::z = " << z << '\n';
	printResult(z);

	return 0;
}

以下是上述输出:

image

变量x和y都获得了正确值,但变量z没有。问题必然出在这两点之间,这使得add函数成为关键嫌疑对象。

让我们修改add函数:

#include <iostream>

int add(int x, int y)
{
std::cerr << "add() called (x=" << x <<", y=" << y << ")\n";
	return x + y;
}

void printResult(int z)
{
	std::cout << "The answer is: " << z << '\n';
}

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

int main()
{
	int x{ getUserInput() };
std::cerr << "main::x = " << x << '\n';
	int y{ getUserInput() };
std::cerr << "main::y = " << y << '\n';

	int z{ add(x, 5) };
std::cerr << "main::z = " << z << '\n';
	printResult(z);

	return 0;
}

现在我们将得到输出:

image

变量 y 的值为 3,但不知为何函数 add 接收的 y 参数值却是 5。我们必定传入了错误的参数。果然如此:

int z{ add(x, 5) };

问题就在这里。我们把字面值5当作参数传递了,而不是变量y的值。这很容易修正,之后我们就可以移除调试语句了。


再举一个例子

这个程序与之前的程序非常相似,但同样无法正常运行:

#include <iostream>

int add(int x, int y)
{
	return x + y;
}

void printResult(int z)
{
	std::cout << "The answer is: " << z << '\n';
}

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

int main()
{
	int x{ getUserInput() };
	int y{ getUserInput() };

	int z { add(x, y) };
	printResult(z);

	return 0;
}

如果我们运行这段代码并看到以下内容:

image

嗯,哪里不对劲。但究竟是哪里呢?

让我们为这段代码添加一些调试:

#include <iostream>

int add(int x, int y)
{
std::cerr << "add() called (x=" << x << ", y=" << y << ")\n";
	return x + y;
}

void printResult(int z)
{
std::cerr << "printResult() called (z=" << z << ")\n";
	std::cout << "The answer is: " << z << '\n';
}

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::cerr << "main::x = " << x << '\n';
	int y{ getUserInput() };
std::cerr << "main::y = " << y << '\n';

	int z{ add(x, y) };
std::cerr << "main::z = " << z << '\n';
	printResult(z);

	return 0;
}

现在让我们使用相同的输入再次运行程序:

image

现在我们立刻发现问题所在:用户输入的值是4,但main函数中的x却被赋值为3。问题必然出在用户输入值与该值被赋给main函数变量x之间。让我们在getUserInput函数中添加调试代码,确保程序能正确获取用户输入的值:

#include <iostream>

int add(int x, int y)
{
std::cerr << "add() called (x=" << x << ", y=" << y << ")\n";
	return x + y;
}

void printResult(int z)
{
std::cerr << "printResult() called (z=" << z << ")\n";
	std::cout << "The answer is: " << z << '\n';
}

int getUserInput()
{
std::cerr << "getUserInput() called\n";
	std::cout << "Enter a number: ";
	int x{};
	std::cin >> x;
std::cerr << "getUserInput::x = " << x << '\n'; // added this additional line of debugging
	return --x;
}

int main()
{
std::cerr << "main() called\n";
	int x{ getUserInput() };
std::cerr << "main::x = " << x << '\n';
	int y{ getUserInput() };
std::cerr << "main::y = " << y << '\n';

	int z{ add(x, y) };
std::cerr << "main::z = " << z << '\n';
	printResult(z);

	return 0;
}

输出结果:

image

通过这段额外的调试代码,我们能看到用户输入正确接收到了getUserInput函数的变量x中。然而主函数的变量x却获取到了错误值。问题必然出在这两者之间。唯一可能的嫌疑就是getUserInput函数的返回值。让我们仔细检查这行代码。

return --x;

嗯,这有点奇怪。x前面那个 -- 符号是什么?教程里还没讲到这个,所以不懂也没关系。但即使不知道它的含义,通过你的调试努力,你已经基本确定问题出在这行代码上——因此很可能是这个 -- 符号导致了问题。

既然我们真正希望getUserInput只返回x的值,那就移除--符号看看会发生什么:

#include <iostream>

int add(int x, int y)
{
std::cerr << "add() called (x=" << x << ", y=" << y << ")\n";
	return x + y;
}

void printResult(int z)
{
std::cerr << "printResult() called (z=" << z << ")\n";
	std::cout << "The answer is: " << z << '\n';
}

int getUserInput()
{
std::cerr << "getUserInput() called\n";
	std::cout << "Enter a number: ";
	int x{};
	std::cin >> x;
std::cerr << "getUserInput::x = " << x << '\n';
	return x; // removed -- before x
}

int main()
{
std::cerr << "main() called\n";
	int x{ getUserInput() };
std::cerr << "main::x = " << x << '\n';
	int y{ getUserInput() };
std::cerr << "main::y = " << y << '\n';

	int z{ add(x, y) };
std::cerr << "main::z = " << z << '\n';
	printResult(z);

	return 0;
}

现在输出结果:

image

程序现已正常运行。即使不理解--的具体作用,我们仍能定位到引发问题的具体代码行,并成功修复了该问题。


为何使用打印语句调试并不理想

虽然为诊断目的在程序中添加调试语句是一种常见的基础技术,且具有实用性(尤其当调试器因故不可用时),但出于以下原因,这种做法并不理想:

  1. 调试语句会使代码杂乱无章。
  2. 调试语句会污染程序输出结果。
  3. 添加或移除调试语句都需要修改代码,可能引入新错误。
  4. 调试语句在完成调试后必须删除,导致其无法复用。

我们能做得更好。后续课程将探讨具体方法。

posted @ 2026-02-10 22:16  游翔  阅读(1)  评论(0)    收藏  举报