编译:伯乐在线 - 郑芸,英文:Henrik Warne
来源:CPP开发者
Bug 是绝佳的学习机会。所以我们怎样尽可能多地从修复的 bug 中吸取经验?我至今已经用了 13 年的一个方法是,写下这个 bug 的简单描述、修复方法以及吸取的经验。
遥想 2002 年,我偶然发现一篇描述这个方法的博客(很不幸,我没再找到)。我从那时起就使用这个方法了,并且相信它能帮助我成为一个更出色的程序员。
每当我修复一个特别棘手或有趣的 bug,我总会花几分钟写下一些这个 bug 的信息。这有一个典型记录的例子:
例子
【日期】:2004-08-17
【问题】:当解码 Q.931 信令时无限循环
【原因】:当在Q.931信令中发现一个未知的元素id时,我们试图通过读取它的长度来跳过它,并且将位置指针迁移几个字节。但是,在这个例子中的长度是零,导致我们反复跳过相同的元素id。
【怎么发现的】:在解码一个 Ethereal 从 Nortel 追踪到的安装信息时发现了这个问题。他们的信息是 1016 字节长度(包含大量快速启动元素),但我们的 MSG_MAX_LEN 是 1000。通常我们会收到一条来自 common/Communication.cxx 的信息,但现在,当直接输入需要解析的数据时,数组末端内存访问越界,其恰好是 0,暴露了这个问题。
为了找到它,我仅仅在 9931 解码中添加一些打印输出。但很幸运数据恰好是零。
【修复】:如果长度是零,设置为 1。这方式总是行得通。
【在哪些文件修改了】:
callh/q931_msg.cxx
callh/q931_msg.cxx【我导致的】:是的
【解决Bug的时间】:1小时
【教训】:信任收到信息中获得的数据。不仅仅是产生大量可能导致问题的数据。显示长度为 0 也同样不好。
实施方式
我有一个命名为 bugs.txt 的纯文本文件。在文件的顶部是一个具备所有标题的模板,但是没有包含信息。当我添加一个新的记录,我复制模板部分,粘贴在模板下面。然后完成记录并且填满信息。大多数展示在上面例子中的 fields 应该是不需要声明的。
有必要指出这并不是 bug 追踪器。我并没有添加所有我修复的 bug 。例如,如果我只是忘记在代码中添加声明,一旦发生这种情况,我就意识到问题出在哪里了,我并不会添加这条记录。仅仅当 bug 本身,或是修复,或是调试过程特别有趣,我才会添加一条新的记录。通常,是我导致了这个 bug。但是偶尔,特别是花几天时间才追踪到的困难 bug,尽管不是我造成的我也会添加一条记录。
一旦修复了一个 bug,我的第一反应是松了口气然后继续前进。我试着一更正后就写下这个记录。这时我的脑海中依然清晰保留所有的细节。拖延会让精确回忆发生的事情变得很困难(或者我压根忘记写下这条记录)。
至今,我已经有 194 条记录,平均每个月有一条新的记录。最重要的是教训部分。这里需要自我反省。是什么导致这个 bug 的特殊性?我发现经验常常来自于三个不同的方面:
编码。我在代码中犯了什么错误?我是否忘记了 else 部分代码?是否系统调用失败,但没有检查 response?我在未来应该怎么调整代码来防止这些问题?
测试。一般不包括本可以在测试中捕获的 bug 。要是这样的话,应该在哪个阶段测试——单元、功能、系统?丢失了哪个测试用例?
调试。我本可以怎样快速定位 bug?我是否使用了正确的方法?我是否假定了太多?我是否需要在代码中使用更好的日志记录?
益处
Nassim Nicholas Taleb 在 《Antifragile》中写到:“错误包含丰富的信息”。我完全同意这个观点。Bug 帮助我们更好地理解系统,告诉我们怎样提高编码、测试和调试技巧。所以我认为尽可能从 bug 中学习经验,是再正常不过的事了。
我发现为每个有趣的 bug 记录下来,让我轻易学习到很多。在记录的行为中我会对发生的事情思考得更深刻。同样,一旦记录下来,我可以在之后检查发生的事情。偶尔,我也会浏览文件,只阅读教训部分,对我认为是从 bug 中学到的最有价值的经验加强记忆。
我记录 bug 文件至今已经有 13 年了。这是一段漫长的时间,但是我坚持下来了,因为作为一名程序员,它帮助我进步。尝试一下吧,看看它是否也对你有益!
在上面,我写了我是怎样追踪这些年遇到的最有趣 bug 的。最近我重新浏览了这所有的 194 个条目(历时 13 年),看看我从这些 bug 中学到了学到了那些重要的经验教训。我分为编码、测试和调试三大类。
编码
这些都是过去给我带来棘手 bug 的问题:
1.事件顺序
当处理事件时,问以下问题富有成效:事件是否可以以不同的顺序到达?如果没收到这些事件怎么办?如果事件在同一行出现两次怎么办?即使这通常不会发生,在系统的其他部分(或交互系统)中的bug也会导致它发生。
2.处理太早
这是上述“事件顺序”中的一个特殊情况,但是它已导致了一些棘手的bug,所以它自成一派。例如,如果信令信息接收得过早,在配置和启动程序完成之前接收,许多奇怪的行为就会发生。另一个例子,当一个连接在被放入空闲列表之前就被标记为断开。当我们处理这个问题时,我们通常假设它处在空闲列表状态时被标记为断开(但是当时它为什么没有从这个列表上撤下?) 没考虑到事情有时发生过早是由于我们没有想到。
3.隐蔽故障
例如,一些最难找的的 bug 是由于出现了隐蔽故障而继续执行而不是给出错误的代码导致的。例如,系统调用(如绑定)返回未检查的错误代码。另一个例子:当遇到一个错误元素时,直接返回而不是给出错误的解析代码。调用在故障的状态下持续了一段时间,使得调试的难度加大。一旦故障被检测出,最好要及时返回这个错误。
4.If 语句
含有多个条件的If语句(if (a or b),尤其是当嵌套时,if (x) else if (y)),给我导致了许多 bug。即使If语句在概念上很简单,当它有多个条件需要追踪时,很容易出错。最近我尝试重新把代码写得简洁,避免出现复杂的If语句。
5.Else
有一些bug的产生是由于没有恰当地考虑如果条件为假,什么应该发生。在几乎所有的情况下,每个If语句都应该有个else部分。而且,如果你在If语句的一个分支中设置了一个变量,你也许应该在其他分支也设置该变量。与此相关的是标志(flag)被设定的情况。仅仅添加设定标志的条件很容易,但是容易忘了添加应该重新设定标志的条件。任由永久性设定的标志留在那里可能会在将来导致 bug。
6.改变假设
一开始最难预防的许多bug是由不断变化的假设引起的。例如,最初仅仅只有一个客户,在这个假设下写了很多代码。后来某个时候,设计发生了变化,允许每天有多个客户事件。当这种情况发生,就很难改变受到新设计影响的所有情况。很容易找到显式依赖该变化的所有项,但是难的部分是,找到隐式依赖旧设计的所有情况。例如,可能有代码读取给定某一天的所有客户事件。一个隐式的假设可能是,结果集中元素的数量绝对不会大于客户数量。我没有好的方法可以预防这类问题,欢迎读者建议。
7.日志记录
深入了解程序所做的任务是至关重要的,尤其是当逻辑复杂的时候。确保添加足够的(但也别太多)日志记录。那样你就能弄清楚为什么程序在执行它执行的任务。让一切运转良好时,它无关紧要。但是只要问题发生(这不可避免),你会很庆幸你添加了合适的日志记录。
测试
作为一名开发者,除非进行了测试,否则我不会说完成一项功能。起码这意味着每一行新代码或更改后的代码至少执行了一次。此外,单元测试或功能测试也很好,但不够。新功能还必须在类似产品的环境下进行测试和探究。唯有这样,我才可以说完成了一项功能。下面是 bug 在测试方面给予我的一些重要的经验教训:
8.零(zero)和空(null)
务必要以零和空(合适的情况下)来进行测试。对于字符串而言,这意味着既指长度为零的字符串,又指内容为空的字符串。另一个例子:在发送任何数据(零字节)之前,测试 TCP 连接的断开。没有使用这些组合来测试是 bug 悄然出现的头号原因,我在测试时是原本可以发现这些 bug 的。
9.添加和删除
新功能常常需要能够为系统添加新配置,比如说用于电话号码翻译的新配置文件。我们会自然而然的添加一个配置文件,来验证功能是否正常。然而,我发现很容易忘了还要测试配置文件的删除。
10.错误处理
处理错误的代码常常很难测试。最好由自动测试来检查错误处理代码,但有时这不可能。这种情况下,我有时采用的一招就是,临时修改代码,让错误处理代码运行。要做到这一点,最容易的方法就是反转if语句,比如说将if语句由 error_count > 0反转为 error_count == 0。另一个例子是误拼数据库列名,让所需的错误处理代码运行。
11.随机输入
另一种往往能够发现 bug 的测试方法是进行随机输入。例如,H.323 协议的 ASN.1 解码可处理二进制数据。通过发送有待解码的随机性字节,我们发现了解码器中的几个 bug。另一个例子是使用测试调用生成脚本,其中调用持续时间、回复延迟、第一方挂断等都是随机生成的内容。这些测试脚本暴露了无数 bug,尤其是接踵而至的事件引起的干扰。
12.检查什么不该发生
通常测试包括检查一些需要的行为发生。但是很容易忽略他的对立面——检查不该发生的事确实没发生。
13.自制工具
通常,我创建了自己的小工具来使测试更简易。例如,当我处理面向 VoIP 的 SIP 协议时,我写了一个小的脚本可以返回正标题和值。这个工具使得测试许多个别场景变得简单。另一个例子是可以调用 API 的命令行工具。从小的开始,逐渐添加一些需要的功能,我最终有许多有用的工具,写自己的小工具的优势是我得到我想要的功能。
在测试中要发现所有的bug几乎不可能。有一次,我在一种情况下,我对处理关联号码做了改变,包括两部分:路由地址前缀(总是相同),和从000到999的动态分配号码。问题是,当查找相关性时,动态分配的数字的第一个数字在查找之前被错误地删除。所以,不是寻找637之类的号码,你寻找的是37,而这个号码不在表中。这意味着,它一直寻找到100,所以前100个调用正常,而之余的所有900个调用失效。所以除非我在重新启动之前测试了100多次,否则在测试时发现不了这个问题。
调试
14.讨论
在过去对我帮助最大的调试方法就是与同事讨论问题。我常常只要向同事描述问题,就足以认识到问题是什么。此外,即使同事不是很熟悉相应代码,常常也能給出好主意,表明哪里可能有问题。我在处理最棘手的 bug 时,与同事讨论特别有效。
15.密切注意
往往是当调试一个问题很长时间时,是因为我做了错误的假设。例如,我认为这个问题发生在一个特定的方法中,事实上,这个问题甚至根本不会出现在这个方法中。或者抛出的异常并不是我认为的那个。或者我认为最新版的软件在运行,但它其实是较老的版本。因此,一定要验证细节,而不是假设。它使你容易看到你所期望看到的问题,而不是实际发生的问题。
16.最近的一次改动
本该运行的程序停止了,它通常是由最后的一次变动导致。有一次,最近的一次变动仅仅是日志,但是日志中的一个错误导致了更大的问题。为了让诸如此类的回归更容易找到,有必要在不同的提交代码中实行不同的变更,并且要清楚说明变更。
17.相信用户
有时当一个用户反馈问题时,我的本能反应是:这不可能,他们一定搞错了。但是我已经意识到我不应该这样做。我也不想这样,但更多次,事实证明他们报告的问题实际上发生了。所以这些天,我认真对待他们的反馈。当然,我仍然反复测试所有的一切被正确地设置了。但是我碰过好多情况下,之所以发生奇怪的问题,是由于不同寻常的配置或意料之外的使用,而我的默认假设是他们是对的,程序是错的。
18.测试修复的效果
如果你已经修复了 bug,还需要再测试。首先运行修复前的代码,然后观察 bug。然后运用修复再次测试。现在 bug 的问题应该被消除了。继续这些步骤确保它确实是一个 bug,确保你的修复已经修复这个问题。简单但很必要。
其他心得
过去13年,我一直在记录我遇到的最棘手的bug,很多事情发生了改变。从小的嵌入式系统,到大的电信系统,网页系统都做过。我使用的语言包括 C++、Ruby、Java 和 Python,若干类的 bug 在我使用 C++ 的日子里就已经不再出现了。像堆栈溢出,内存损坏,字符串的问题以及某些形式的内存泄漏。
其他的问题,像回路错误和极端案例,我见的少得多,因为我单元测试了更多逻辑,但这并不意味着那里没有 bug。这篇文章总结的经验教训,帮助我在编码、测试和调试这三个阶段尽量减小破坏。如果你发现了其他的技巧或者有用的技巧来预防或者找到 bug,请在评论区留言。
C++学习资料免费获取方法:关注程序喵大人,后台回复“程序喵”即可免费获取40万字C++进阶独家学习资料。
往期推荐