跳转到内容

C++ 编程

来自维基教科书,开放世界中的开放书籍

编程是一个复杂的过程,而且是由人完成的,所以它经常会导致错误。这使得调试成为任何程序员的一项基本技能,因为调试是编程的固有部分。

出于历史原因,编程错误被称为 bug(在 Grace Hopper 博士记录的一台计算机的机械继电器中发现了一个真正的 bug,导致它发生故障),而通过代码,检查它,寻找实现中的错误(bug)并纠正它们的过程称为调试。程序员可用的唯一帮助是可观察输出生成的线索。其他方法是运行自动化工具来测试或验证代码或分析代码运行情况,这是一个 调试器 可以帮助你的任务。

调试可能非常令人沮丧,尤其是 多线程 程序,这些程序极难调试,但它也可以是一项非常有趣的智力活动,有点像逻辑谜题。调试经验不仅会减少未来的错误,还会产生更好的假设,说明可能出现的问题以及改进设计的方法。

在调试代码时,已经有一些已知的代码部分和容易出错的情况,例如关于指针运算的问题,这是从 C 语言继承来的一个众所周知的弱点。在调试中,与任何其他方法一样,已经有一些既定的技术、程序和实践可以使 bug 的检测更容易(例如:Delta Debugging)。

调试领域还包括为代码(或它将在其下运行的系统)建立安全。当然,这将完全取决于特定实现的设计限制和要求。

错误定义

[编辑 | 编辑源代码]

程序中的 bug 被定义为程序员意外的行为,并非程序员有意为之。它发生在该程序代码中预期或意图的行为没有发生时。bug 也可以被描述为错误、缺陷、失误、故障错误

大多数 bug 都是由编程错误引起的,而少数 bug 则是由外部因素(编译器、硬件或其他系统)引起的,这些因素不在程序员的直接责任范围内。包含大量 bug 或严重干扰其功能的 bug 的程序被称为 有 bug 的

详细说明程序中 bug 的报告通常被称为 bug 报告、故障报告、问题报告、故障报告、变更请求等等。

程序中可能出现几种不同的 bug,区分它们有助于更快地追踪它们。

关于 bug 起源的分类


Clipboard

待办事项
按结果/严重性(安全性)分类


自动化调试

[编辑 | 编辑源代码]
Clipboard

待办事项
提及有助于以自动化方式检测 bug 的特定工具和编译器配置。参见 Linux 应用调试技术/堆损坏堆栈损坏。对此进行扩展,并有机会再次阐明语言规范、编译器对它的实现以及自动化警告和先发制人的 bug 检测之间的区别。


常见错误

[编辑 | 编辑源代码]

常见的编程错误,bug 大多是由于缺乏经验、注意力不集中或程序员将过多的责任委派给编译器、IDE 或其他开发工具造成的。

  • 使用未初始化的变量或指针。
  • 忘记编译代码的调试版本和发布版本之间的区别。
  • 在 `switch` 语句中忘记使用 `break` 语句,而本意并非要执行穿透(fall-through)。
  • 忘记在访问指针成员之前检查空指针。
// unsafe
p->doStuff(); 

// much better!
if (p) 
{
   p->doStuff(); 
}
这会导致访问冲突(段错误),并使程序意外停止。
拼写错误
[edit | edit source]

拼写错误是指在 C++ 语言中,在一些特定的情况下(语言本身比较模糊),容易犯的语法错误。这个词语源于 打字错误,指的是打字过程中的错误。

忘记在行尾加 `;` 。永远的经典!
使用错误的操作符,例如进行赋值操作而不是 相等性测试。在简单的情况下,编译器通常会发出警告。
// Example of an assignment of a number in an if statement when a comparison was meant.
if ( x = 143 ) // should be: if ( x == 143)
忘记在多行循环或 if 语句中添加括号。
if (x==3)
cout << x;
flag++;

理解时间

[edit | edit source]
编译时错误
[edit | edit source]

编译器只能在程序语法正确的情况下才能编译程序;否则,编译会失败,你将无法运行你的程序。语法指的是程序的结构以及关于该结构的规则。

例如,在英文中,一个句子必须以大写字母开头,以句号结尾。这个句子包含语法错误。这个句子也是。

对于大多数人类读者来说,一些语法错误并不构成重大问题,这就是为什么我们能够阅读 E. E. Cummings 的诗歌,而不会出现错误信息。

编译器并不那么宽容。如果你的程序中存在任何一个语法错误,编译器会打印错误信息并退出,你将无法运行你的程序。

更糟糕的是,C++ 中的语法规则比英文中的语法规则更多,而且你从编译器得到的错误信息通常不太有用。在你编程生涯的前几周,你可能会花很多时间来追踪语法错误。然而,随着你经验的积累,你会犯更少的错误,并且会更快地找到错误。

链接错误
[edit | edit source]

大多数链接错误是在使用编译器/IDE 的不当设置时生成的。大多数最新的编译器会报告有关错误的一些信息,如果你牢记链接器的功能,你将能够轻松地解决它们。大多数其他类型的错误都是由于对语言或项目文件设置的使用不当造成的,这会导致由于重新定义或缺少信息而导致的代码冲突。

运行时错误
[edit | edit source]

运行时错误,之所以这样称呼是因为该错误只有在运行程序时才会出现。

逻辑错误和语义错误

第三种类型的错误是逻辑错误或语义错误。如果你的程序中存在逻辑错误,它会成功编译并运行,在某种意义上,计算机不会生成任何错误信息,但它不会做正确的事情。它会做别的事情。具体来说,它会按照你告诉它做的事情去做。

问题在于,你编写的程序并不是你想要编写的程序。程序的含义(它的语义)是错误的。识别逻辑错误可能很棘手,因为它要求你反向操作,查看程序的输出并试图找出它正在做什么。

编译器错误

[edit | edit source]

正如我们之前所见,错误是每个编程任务都会遇到的问题。创建编译器也不例外,事实上,创建 C++ 编译器是一项极其复杂的编程任务,尤其是在语言稳定但一直在发展的情况下,而且不仅仅是标准在发展。

C++ 允许的自由度使程序员能够突破可能的界限,并且期望在抽象方面提高代码复杂度。这导致编译器尝试自动化一些底层操作,以减轻程序员的负担,例如代码优化、更高级别的交互以及对编译器组件的控制,以及包含非常底层的配置可能性。所有这些功能都增加了编译器最终生成不正确(或者在技术上正确但意外)结果的方式数量。程序员应该始终牢记编译器错误是可能的,但极其罕见。

最常见的归因于编译器的错误之一是由于配置错误的优化选项(或无法理解它们)造成的。如果你怀疑是编译器错误,首先关闭优化。

实验性调试

[edit | edit source]

从本书中学习到的最重要的技能之一就是调试。虽然调试可能会让人沮丧,但它却是编程中最具智力挑战性、挑战性和趣味性的部分之一。

从某种意义上说,调试就像侦探工作。你面临着线索,你必须推断导致你所见结果的过程和事件。

调试也像实验科学。一旦你有了关于错误的猜测,你就会修改程序并再次尝试。如果你的假设是正确的,那么你可以预测修改的结果,并且你离编写一个有效的程序又近了一步。如果你的假设是错误的,你必须提出一个新的假设。正如 夏洛克·福尔摩斯 指出的,“当你排除了所有不可能的可能性,剩下的,无论多么不可能,都一定是真相。”(摘自 阿瑟·柯南·道尔 的《四签名》)。

对于一些人来说,编程和调试是一回事。也就是说,编程就是逐渐调试程序,直到它按你的意愿执行。这个想法是,你应该始终从一个有效的程序开始,这个程序可以做一些事情,然后进行小的修改,并在修改过程中进行调试,这样你始终拥有一个有效的程序。

例如,Linux 是一个包含数千行代码的操作系统,但它起源于 林纳斯·托瓦兹 用于探索英特尔 80386 芯片的一个简单程序。据拉里·格林菲尔德说,“林纳斯的早期项目之一是一个在打印 AAAA 和 BBBB 之间切换的程序。它后来发展成了 Linux。”(摘自 Linux 用户指南 Beta 版 1,第 10 页)。

耐久性/压力测试

[edit | edit source]

这种类型的测试不仅是为了检测错误,也是为了标记优化机会。耐久性测试是通过分析多次执行相同操作来收集统计显著数据来进行的。请注意,这种类型的测试仅限于选定的操作集以及测试过程中输入处理的预计变化。

在执行这种类型的测试时,可以进行一些自动化操作,甚至可以处理模拟与用户界面交互的操作。

压力测试是耐久性测试的一种细微变化,其目的是确定甚至建立程序在处理输入时的极限。同样,收集的指标仅在执行的操作方面具有意义。

因此,这些测试以及任何变化将取决于它们的设计方式,并且具有极强的目标导向性,也就是说,它们只会针对正确提出的问题提供正确的答案。对结果的依赖性必须保守,因为测试人员必须承认某些事件可能没有受到审查。这种特性使它们更适合于优化,因为资源使用中的瓶颈将提供比崩溃或死锁更好的分析起点。

跟踪

[edit | edit source]

跟踪 技术直接从硬件领域发展到 软件工程 领域。在硬件领域,它包括对给定电路的信号进行采样,以验证硬件实现的逻辑/算法的一致性。因此,早期的程序员采用了该术语和功能来跟踪软件的执行,但有一个明显的区别,即跟踪不应在公开发行的版本中执行或启用。

执行跟踪有几种方法,例如,在代码中包含报告功能,这些功能将在运行时生成其状态的输出(类似于编译器和链接器生成的错误和警告),甚至可以使用编译器和链接器来报告特殊消息。另一种方法是直接与调试器进行交互,在指定调试模式下,调试器与正在运行的代码进行交互。还可以集成全面的 日志记录 系统,这些系统可以大量记录相同的信息,并以有组织的方式记录信息,这一切都取决于所需功能的复杂性和详细程度。

事件日志与跟踪

日志记录可能是最终产品的目标,但很少涵盖主程序的直接内部运作,提供用于诊断和审计的有用调试信息。调试信息通常只对程序员进行调试感兴趣,此外,根据跟踪日志中包含的信息类型和详细信息,经验丰富的系统管理员技术支持人员可以诊断软件的常见问题。跟踪是一个横切关注点

调试器

[edit | edit source]

通常,在程序运行时无法查看程序的源代码。这种在程序执行时“看不到内部”的能力,在调试程序时是一个真正的障碍。最原始的查看内部的方法是将(取决于你的编程语言)打印或显示或展示或回显语句插入到你的代码中,以显示正在发生的事情的信息。但是,用这种方法找到问题的位置可能是一个缓慢而痛苦的过程。这就是调试器派上用场的地方。

如果你想使用调试器,并且以前从未使用过,那么你有两个任务要完成。你的第一个任务是学习基本的调试器概念和词汇。第二个是学习如何使用你所拥有的特定调试器。你的调试器文档将帮助你完成第二个任务,但它可能无法帮助你完成第一个任务。在本节中,我们将通过介绍有关手头语言的基本调试器概念和术语来帮助你完成第一个任务。一旦你熟悉了这些基础知识,那么你的调试器文档/使用对你来说就会更有意义。大多数软件调试是一个缓慢的手动过程,无法很好地扩展。

调试器是一种软件,它使你能够以调试模式而不是正常模式运行你的程序。在调试模式下运行程序允许你在程序运行时查看内部。具体来说,调试器使你能够

  1. 在每个语句执行时,查看程序中每个语句的源代码。
  2. 在你自己选择的地方暂停或挂起程序执行。
  3. 当程序暂停时,发出各种命令以检查和更改程序的内部状态。
  4. 恢复(或继续)执行。

值得注意的是,有一套普遍接受的调试器术语和概念。大多数调试器都是 Unix 控制台调试器(针对 C 语言的 dbx)的进化后代,因此它们共享从 dbx 衍生的概念和术语。许多可视化调试器只是控制台调试器的图形包装器,因此可视化调试器具有相同的传承,以及相同的概念和术语集。程序员不断遇到其他人遇到的相同类型的错误(即使通过重用代码在不同的语言之间也是如此);一个常见的例子是缓冲区溢出。

调试器有两种形式:控制台模式(或简称为控制台)调试器和可视化图形化调试器。

控制台调试器通常是语言本身的一部分,或者包含在语言的标准库中。控制台调试器的用户界面是键盘和控制台模式窗口(Microsoft Windows 用户将其称为“DOS 控制台”)。当程序在控制台调试器下执行时,源代码行会在执行时流过控制台窗口。一个典型的调试器有许多方法来指定程序中你希望执行暂停的精确位置。当调试器暂停时,它会显示一个特殊的调试器提示,指示调试器正在等待键盘输入。用户输入命令告诉调试器下一步该做什么。典型的命令是显示某些程序变量的值,或者继续执行程序。

可视化调试器通常作为多功能 IDE(集成开发环境)的一个组件提供。一个强大而易于使用的可视化调试器是 IDE 的一个重要卖点。可视化调试器的人机界面通常看起来像图形文本编辑器的界面。源代码显示在屏幕上,与你编辑它时显示的方式几乎相同。调试器有自己的工具栏或菜单,其中包含专门的调试器功能。它可能有一个特殊的调试器边距(源代码左侧的区域),用于显示断点的符号、当前行指针等。当调试器运行时,某种可视化指针(可能是一个黄色箭头)将沿着此调试器边距向下移动,指示哪个语句刚刚完成执行,或哪个语句将要执行。可以通过鼠标单击源代码区域、调试器边距或调试器菜单来调用调试器的功能。

如何启动调试器?

[edit | edit source]

如何启动调试器(或将你的程序置于调试模式)取决于你的编程语言和你使用的调试器类型。如果你使用的是控制台调试器,那么根据你的特定调试器提供的功能,你可能可以选择几种不同的启动调试器的方式。一种方法可能是向启动程序运行的命令行添加一个参数(例如 -d)。如果你这样做,那么程序将从它开始运行的那一刻起就处于调试模式。第二种方法可能是启动调试器,并将程序的名称作为参数传递给它。例如,如果你的调试器的名称是pdb而你的程序的名称是myProgram,那么你可以在命令提示符下输入pdb myProgram来启动执行你的程序。第三种方法可能是将语句插入到程序的源代码中,这些语句将你的程序置于调试模式。如果你这样做,当你启动你的程序运行时,它将正常执行,直到它到达调试语句。当这些语句执行时,它们会将你的程序置于调试模式,从那时起,你将处于调试模式。

如果你正在使用一个提供可视化调试器的 IDE,那么通常在你的工具栏上有一个“调试”按钮或菜单项。单击它将启动你的程序在调试模式下运行。当调试器运行时,某种可视化指针将沿着调试器边距向下移动,指示哪个语句正在执行。

跟踪你的程序

[edit | edit source]

为了探索调试器提供的功能,让我们从想象你有一个简单的调试器来使用开始。这个调试器非常原始,功能集非常有限。但作为一个纯粹的假设调试器,它比所有真正的调试器都具有一个主要优势:只要你希望有一个新的功能,这个功能就会神奇地被添加到调试器的功能集中!

一开始,你的调试器只有很少的能力。一旦你启动了调试器,它会显示你的程序中一个语句的代码,执行该语句,然后暂停。当调试器暂停时,你只能告诉它做两件事

  1. 命令 print <aVariableName> 将打印一个变量的值,以及
  2. 命令 step 将执行下一条语句,然后再次暂停。

如果调试器是控制台调试器,你必须在调试器提示符下键入这些命令。如果调试器是可视化调试器,你只需单击 Next 按钮,或在特殊的 Show Variable 窗口中键入一个变量名。这就是调试器所拥有的所有功能。

尽管这样一个简单的调试器相当有用,但它也非常笨拙。使用它,你很快就会发现自己希望对调试器暂停的位置有更多控制,以及在你暂停调试器时可以执行的一组更大的命令。

控制调试器暂停的位置

[edit | edit source]

你最希望的是调试器不要在每条语句之后暂停。大多数程序在进入真正存在问题的区域之前会执行很多设置工作,你已经厌倦了不得不一步一步地遍历每个设置语句才能到达真正的麻烦区域。简而言之,你希望能够设置断点断点是一个你可以附加到代码行上的对象。调试器运行而不暂停,直到它遇到一条带有断点附加的代码行。断点告诉调试器暂停,因此调试器会暂停。

随着断点功能被添加到调试器(希望它出现),你现在可以在问题所在代码段的开头设置一个断点,然后启动调试器。它将运行程序,直到它到达断点。然后它会暂停,你可以开始使用 print 命令检查情况。

但是,当你完成使用 print 命令后,你回到了之前使用 step 命令逐个单步执行程序剩余部分的地方。你开始希望有一个 step 命令的替代方案,用于运行到下一个断点命令。使用这样的命令,你可以在程序中设置多个断点。然后,当你暂停在一个断点处时,你可以选择使用 step 命令逐个单步执行代码,或者使用 run to next breakpoint 命令运行到下一个断点。

有了我们假设的调试器,希望使它成为现实!现在,你可以即时控制程序接下来将在哪里暂停。你开始对调试过程有了真正的控制!

运行到下一个断点命令的引入,让你开始思考。你能想到 step 命令的其他哪些有用的替代方案吗?

通常你会发现自己暂停在代码中的一个地方,你知道接下来的 15 个语句中没有问题。与其一个接一个地单步执行它们,你希望能够告诉调试器类似 step 15 的东西,它会在暂停之前执行接下来的 15 个语句。

在调试程序的过程中,你经常会遇到需要调用子程序的语句。在这种情况下,step 命令实际上是一个 step into 命令。也就是说,它会进入子程序,并允许你逐一跟踪子程序内部语句的执行。

然而,在很多情况下,你知道子程序中没有问题。在这种情况下,你希望告诉调试器跳过子程序调用,也就是说,运行子程序而不暂停子程序内部的任何语句。step over 命令是一种 step(但不要向我展示任何杂乱的细节)命令。(在一些调试器中,step over 命令被称为 next。)

当使用 step 或 step into 进入子程序时,有时会发现你到达一个子程序中不再感兴趣的点。你希望能够告诉调试器 step out 或 run until subroutine end,这将导致它在遇到 return 语句(或隐式返回到调用者)之前不暂停运行,然后暂停。

你还会发现,step over 和 step into 命令对于循环和子程序都有用。当遇到循环结构(例如 for 语句或 do while 语句)时,能够选择进入循环执行或跳过循环执行将非常方便。

几乎总会有那么一个时刻,通过代码逐行执行已经学不到更多东西了。你希望有一个命令告诉调试器继续执行,或直接运行到程序结束。

即使拥有所有这些命令,如果你使用的是控制台调试器,你仍然会发现自己经常使用 step 命令,并且你已经厌倦了输入 step 这个词。你希望,如果你想重复一个命令,你只需在调试器提示符下按下回车键,调试器就会重复你在调试器提示符下输入的最后一个命令。瞧,愿望成真了!

这是一个如此高效的功能,以至于你开始考虑控制台调试器可以提供的其他功能,以提高其易用性。你注意到,你经常需要打印多个变量,并且你经常想一遍又一遍地打印相同的变量集。你希望有一种方法可以为一组命令创建宏或别名。例如,你可能希望定义一个别名为 foo 的宏,该宏包含一组调试器打印语句。一旦定义了 foo,那么在调试器提示符下输入 foo 就会运行宏中的语句,就像你直接在调试器提示符下输入它们一样。

持久性

[edit | edit source]

最终,工作日结束了。你的调试工作还没有完成。你从电脑上注销,回家享受应得的休息。第二天早上,你精神抖擞地来到办公室,准备继续调试。你启动电脑,打开调试器,发现你前一天定义的所有别名、断点和观察点都消失了!现在,你对调试器有一个非常大的愿望。你希望它具有一定的持久性。你希望它能够记住这些东西,这样你就不必每次启动新的调试会话时都重新创建它们。

你可以在调试器提示符下定义别名,这对于需要为特殊场合发明的别名来说非常棒。但是,通常情况下,在每个调试会话中都需要一组别名。也就是说,你希望能够保存别名定义,并在启动任何调试会话时自动重新创建这些别名。

大多数调试器允许你创建一个包含别名定义的文件。该文件被赋予一个特殊名称。当调试器启动时,它会查找具有该特殊名称的文件,并自动加载这些别名定义。

检查调用堆栈

[edit | edit source]

当你步进程序时,你可能想知道的一个问题是“我如何到达代码中的这个位置?”这个问题的答案在于当前语句的调用堆栈(也称为执行堆栈)。调用堆栈是一个列表,其中列出了进入当前语句的所有函数。例如,如果主程序模块是 MAIN,而 MAIN 调用函数 A,函数 A 调用函数 B,函数 B 调用函数 C,而函数 C 包含语句 S,那么语句 S 的执行堆栈是

MAIN
    A
     B
      C
       statement S

在许多解释性语言中,如果你的程序崩溃,解释器会为你打印调用堆栈作为堆栈跟踪

条件断点

[edit | edit source]

一些调试器允许你为断点附加一组条件。你可能可以指定调试器只在满足某个条件时(例如VariableX > 100)或某个变量的值自上次遇到断点以来已更改时暂停在断点处。例如,你可以将断点设置为在某个计数器达到某个值(例如 100)时中断。这将允许循环运行 100 次然后中断。

带有条件附加的断点称为条件断点。没有条件附加的断点称为无条件简单断点。在一些调试器中,所有断点都附加了条件,而“无条件”断点只是条件为true的断点。

观察点

[edit | edit source]

一些调试器支持一种称为观察观察点的断点。观察点是一种条件断点,它不与任何特定行相关联,而是与变量相关联。当你想在某个变量的值改变时暂停时,观察点非常有用。在你的代码中搜索,查找每个更改变量值的代码行,并在这些代码行上设置断点,这既费力又容易出错。观察点允许你通过将断点与变量而不是源代码中的点关联来避免所有这些操作。一旦定义了观察点,它就会“观察”它的变量。每当变量的值改变时,代码就会暂停,你可能会收到一条消息,告诉你执行暂停的原因。然后,你可以查看你在代码中的位置以及变量的值。

在可视化调试器中设置断点

[edit | edit source]

创建(或“设置”或“插入”)断点的方式将取决于你的特定调试器,尤其是它是否是一个可视化调试器或一个控制台模式调试器。在本节中,我们将讨论如何在可视化调试器中设置断点,在下一节中,我们将讨论如何在控制台模式调试器中设置断点。

可视化调试器通常允许你滚动浏览代码,直到找到要设置断点的点。将光标放在要插入断点的代码行上,然后按一个特殊的热键或单击调试器工具栏上的菜单项或图标。如果有一个图标可用,它可能暗示观察的行为,例如它可能看起来像一副眼镜或望远镜。此时,可能会弹出一个特殊的对话框,允许你指定断点是有条件的还是无条件的,以及(如果是有条件的)允许你指定与断点相关的条件。

放置断点后,许多可视化调试器会在边距中放置一个红点或一个红色八边形(类似于美国/欧洲交通"STOP" 标志),以指示代码中该位置存在断点。

其他运行时分析器

[edit | edit source]
Clipboard

待办事项
添加缺失的信息

华夏公益教科书