3.10 — 在问题成为问题之前找到它们:C++预防性调试指南

当你犯了一个语义错误时,这个错误在你运行程序时可能不会立即显现出来。一个问题可能会在你的代码中潜伏很长时间,直到新引入的代码或改变的情况导致它表现为程序故障。一个错误在代码库中存在的时间越长,它就越有可能难以被发现,原本可能很容易修复的问题最终变成了一个耗费时间和精力的调试冒险。

那我们该怎么办呢?

不犯错误

最好的办法当然是从一开始就不要犯错误。以下是一些可以帮助避免犯错误的方法:

  • 遵循最佳实践。
  • 不要在疲劳或沮丧时编程。休息一下,稍后再回来。
  • 了解语言中的常见陷阱(所有我们警告你不要做的事情)。
  • 不要让函数变得过长。
  • 尽可能使用标准库而不是自己编写代码。
  • 大量注释你的代码。
  • 从简单的解决方案开始,然后逐步增加复杂性。
  • 避免使用巧妙的/不明显的解决方案。
  • 优化代码的可读性和可维护性,而不是性能。

Brian Kernighan 在《编程风格元素》第二版中写道:“每个人都知道调试比编写程序本身难两倍。所以如果你在编写程序时已经竭尽全力,那你又该如何调试它呢?”

重构代码

随着你为程序添加新功能(“行为更改”),你会发现有些函数的长度会增加。函数越长,它们就越复杂,也越难以理解。

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

那么,函数的长度达到多长就算是过长了呢?通常认为,占用一个垂直屏幕长度的代码的函数就算是过长了——如果你需要滚动才能阅读整个函数,那么函数的可理解性会显著下降。理想情况下,函数的长度应该少于十行。少于五行的函数就更好了。

请记住,这里的目标是最大化代码的可理解性和可维护性,而不是最小化函数的长度——为了节省一两行代码而放弃最佳实践或使用晦涩的编码技巧,并不能给你的代码带来任何好处。

关键见解

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

防御性编程简介

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

防御性编程是一种实践,程序员通过这种方式试图预见软件可能被滥用的所有方式,无论是最终用户还是其他开发人员(包括程序员自己)使用代码。这些滥用通常可以被检测出来,然后加以缓解(例如,要求输入错误的用户重新输入)。

我们将在后续课程中探讨与错误处理相关的主题。

快速发现错误

由于在大型程序中不犯错误很难,所以接下来最好的办法就是快速发现你所犯的错误。

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

然而,我们还可以使用一些其他技巧。

测试函数简介

帮助发现程序问题的一种常见方法是编写测试函数来"测试"你所编写的代码。以下是一个原始的尝试,更多是为了说明目的:

#include <iostream>

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

void testadd()
{
	std::cout << "This function should print: 2 0 0 -2\n";
	std::cout << add(1, 1) << ' ';
	std::cout << add(-1, 1) << ' ';
	std::cout << add(1, -1) << ' ';
	std::cout << add(-1, -1) << ' ';
}

int main()
{
	testadd();

	return 0;
}

testadd() 函数通过用不同的值调用它来测试 add() 函数。如果所有值都符合我们的预期,那么我们就可以合理地相信这个函数是正常的。甚至更好,我们可以保留这个函数,每次更改 add 函数时都运行它,以确保我们没有不小心破坏它。

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

与日志框架一样,有许多第三方单元测试框架可供使用。也有可能自己编写,尽管我们需要更多的语言特性才能深入探讨这个话题。我们将在后续课程中回到这个话题。

约束简介

基于约束的技术涉及添加一些额外的代码(如果需要,可以在非调试构建中编译出来),以检查一组假设或期望是否未被违反。

例如,如果我们正在编写一个计算数字阶乘的函数,该函数期望一个非负参数,那么该函数可以在继续之前检查调用者是否传入了一个非负数。如果调用者传入了一个负数,那么该函数可以立即报错,而不是产生一些不确定的结果,这有助于确保问题能够立即被发现。

实现这一点的一个常见方法是通过断言和静态断言,我们将在第 9.6 节 — 断言和静态断言中进行介绍。

通用问题的散弹枪式检测

程序员倾向于犯某些常见的错误,而这些错误可以通过专门寻找这些问题的程序来发现。这些程序通常被称为静态分析工具(有时非正式地称为代码检查器),它们是分析你的源代码以识别特定语义问题的程序(在这种情况下,静态意味着这些工具在不执行代码的情况下分析源代码)。静态分析工具发现的问题可能不是你遇到的任何特定问题的原因,但可能有助于指出代码中脆弱的部分或在某些情况下可能引起问题的问题。

你已经拥有了一个静态分析工具 —— 你的编译器!除了确保你的程序在语法上正确之外,大多数现代 C++ 编译器还会进行一些轻量级的静态分析,以识别一些常见问题。例如,许多编译器会在你尝试使用一个未初始化的变量时发出警告。如果你还没有这样做,提高编译器的警告和错误级别(参见第 0.11 节 — 配置你的编译器:警告和错误级别)可以帮助发现这些问题。

许多静态分析工具存在,其中一些可以识别超过 300 种编程错误。在我们的小型学术程序中,使用静态分析工具是可选的,但它可能有助于你发现代码不符合最佳实践的领域。在大型程序中,强烈推荐使用静态分析工具,因为它可以发现数十个或数百个潜在问题。

最佳实践

使用静态分析工具来帮助你发现代码不符合最佳实践的领域。

对于 Visual Studio 用户

Visual Studio 2019 及以后版本附带了一个内置的静态分析工具。你可以通过"Build" > “Run Code Analysis on Solution”(Alt+F11)来访问它。

提示

以下是一些常用的静态分析工具:

免费的:

  • clang-tidy
  • cpplint
  • cppcheck(已集成到 Code::Blocks 中)
  • SonarLint

付费的(对于开源项目可能免费):

  • Coverity
  • SonarQube

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"