断点单步跟踪是一种低效的调试方法
断点单步跟踪的交互式调试器是软件开发史上的一项重大发明。但我认为,它和图形交互界面一样,都是用牺牲效率来降低学习门槛。本质上是一种极其低效的调试方法。
我在年少的时候( 2005 年以前的十多年开发经历)都极度依赖这类调试器,从 Turbo C 到 Visual C++ ,各个版本都仔细用过。任何工具用上十年后熟能生巧是很自然的事。我认为自己已经可以随心所欲用这类工具高效的定位出 bug 了。但在 2005 年之后转向跨平台开发后,或许是因为一开始没能找到 Linux 平台上合适的图形工具,我有了一些时间反思调试方法的问题。GDB 固然强大,但当时的图形交互外壳并不像今天的版本这么完善。当时比较主流的 insight ddd 都有些小问题,用起来不是十分顺手。我开始转换自己平时做开发的方式。除了尽量提高自己的代码质量:写简洁的、明显没有问题的代码之外,多采用不断的代码复核(Code Review),有意识地增加日志输出,来定位 Bug 。
后来开发重心从客户端图形开发逐步转向服务器,更加显露出用调试器中断程序运行的劣势来。对于 C/S 结构的软件,中断一边的代码运行,用人的交互频率单步跟踪运行,而另一边是以机器的交互频率运作,像让软件运行流程保持正常是非常困难的。
这些年的工作中又慢慢加入一些 Windows 下的开发工作。我发现经过了再一个十年的训练,即使偶尔用上交互式调试器,也体会不到什么优势了。往往手指按在跟踪调试按键上机械的操作,脑子里想的却不是眼前看到的屏幕上的代码。往往都没执行到触发 Bug 的位置,已经恍然大悟发现写错的地方了。这种事情多了,自然会对过去的方法质疑,是什么导致了调试器的低效。
有时和人聊天,谈及该怎么定位 Bug 。我总是半开玩笑的说,你就打开编辑器,盯着代码看啊。盯久了,Bug 自然就高亮出来了。这固然是玩笑,但我的理念中,一切调试方法都比不上 Code Review 。无论是自己写的代码,还是半途介入的别人的代码。第一要务就是要先理解程序的总体结构。
程序总是由一段段顺序执行的小片代码段辅以分支结构构成。顺序执行的代码段是很稳定的,它的代码段入口的输入状态决定了输出结果。我们关心的是输入状态是什么,多半可以跳过过程,直接看结果。因为这样一段代码无论多长,都有唯一的执行流程。而分支结构的存在会让执行流依据不同的中间状态做不同的数据处理。考虑代码的正确性时,所有的分支点都需要考虑。是什么条件导致代码会走向这条分支,什么条件导致代码走向那条分支。可以说分支的多少决定了代码的复杂度。现在比较主流的衡量代码复杂度的方法 McCabe 代码复杂度大致就是这样。
一个软件的整体 McCabe 复杂度一定远超人脑可以一次处理的极限。但通常我们可以对软件进行模块划分,高内聚低耦合的结构能减少软件复杂度。一个高内聚的模块,可以和外部隔离,方便我们聚焦到模块内部来分析。当焦点代码的规模足够小的时候,包含一切分支结构的所有流程就能一次性的被大脑处理了。对于用调试器辅助观察程序的执行流程来说,每次用真实的输入数据驱动的执行过程一定是沿唯一的路径运行的。为了定位 Bug ,我们需要设计出可以触发 Bug 的输入状态。对于一个局部模块来说,这并不总是容易的事。但靠大脑分析一个模块则不同,在 McCabe 复杂度不高时,几乎是可以并行的处理所有的执行路径的。也就是说,你在扫描代码的同时,大脑其实是在同时分析所有可能的情况,同时还能对不太重要的分支做剪枝。当然,和所有技能一样,分析速度和能分析的宽度(复杂度)以及剪枝的正确性是需要反复训练才能拓展的。过于依赖交互式调试工具会影响这种训练,大脑受工具的影响,会更关心眼下的状态:目前运行到哪里了,(为了提高调试效率)下个断点设到哪里去,现在这组变量的值是什么…… 而不太关心:如果输入是另外一种情况,程序将怎么运行。因为工具已经把这些没有发生的过程剪掉了,等着你设计另一组输入下次再展示给你。
交互调试工具通常缺乏回溯能力,也就是它们通常反应当下的状态,而不记录过去的。这有些可以通过改进工具来完善,有些则不能。一个常见的场景是,你定下了下一个断点的位置,当调试器停下来的时候,发现状态异常,只能确定问题出在上次断点到当前的位置之间,但想回溯到底发生了什么,某个中间状态是什么,工具却无能为力。而靠大脑推演程序的运行过程的话,一切都是静态图谱,回溯和前行并无太大区别,只是聚焦到时间轴上某个位置而已。这就是为什么受过良好训练的程序员可以一眼看出 Bug 在哪里,而调试器运用高手却需要反复运行两三次才能找到 Bug 的缘故。
在大脑中正确运行程序当然需要足够的训练,比训练使用调试器难的多,但却是值得的。不知道其它同学有没有类似经历:我在中学时代参加信息学竞赛的时候,考卷并不全是编程题,尤其是初赛阶段,一般是纸面考卷,有很多题目都是给出程序和输入,写出输出结果。感谢这段经历,我不得不在初学编程的时候就进行这类训练。初中的时候,每天可以摸到真机的时间是按小时计的,大部分时间还是在传统的学业上。为了编写自己玩的游戏程序,我只能在上课的时候偷偷的在本子上手写代码。写完了后如果没有下课,我会在大脑中模拟运行一下,看看有没有 bug ,能在上机前改过来,就可以更有效的利用每天有限的上机时间。这些经历让我觉得读代码其实没那么枯燥,是提高效率的一种方法。
用 Code Review 作为主要的定位 Bug 的手段,可以促进你写出复杂度更小(更不容易出错)的程序。因为知道以你目前的能力大脑能一次处理的复杂极限在哪。在减少分支方面,我看过 Linus 的一个访谈节目。他谈及代码品位,举了一个很小的例子:一段对链表的处理程序。链表的头部通常和中间的结构不同,头部之外的节点都有一个 next 指针引用下一个节点,而头节点是个例外,是由不同的数据结构引用的。再 Linus 列出的反面例子中,代码判断了头指针是否为空;而在正面例子中,next 指针是用一个指针引用变量实现的,对于头节点,它引用在不同的数据结构变量上,这样就回避了多一次的例外(对于头节点)判断。代码可以一致的处理。在那个只有 5,6 行代码的小片段中,似乎判断语义非常清晰,多一次判断微不足道,但 Linus 强调这是品位选择的问题。我认为,这其实就是将减少代码复杂度提升到书写代码的本能中。
对于中途介入的他人的项目,你无法控制代码的质量。但长期的 Code Review 训练可以帮助你快速切分软件的模块。通常,你需要运用你对相关领域的知识,和同类软件通常的设计模式,预设软件可能的模块划分方式。这个过程需要对领域的理解,不应过度陷入代码实现细节。一上手就开调试器先跑跑软件的大致运行流程是我不太推荐的方法。这样视野太狭窄了,花了不少时间只观察到了局部。其实不必执着于从顶向下还是从下置上。可以先大致看看源代码的文件结构做个模块划分猜测,然后随便挑选一个模块,找到关联的部分再顺藤摸瓜。对于需要构建的项目,摸清程序脉络的时间甚至可以在第一次等待编译构建的时间同步完成,而不需要等待构建完毕在一步步跟踪运行,甚至不需要下载代码到本地,github 这种友好的 web 界面已经可以舒适的在浏览器里阅读了,有个 ipad 就可以舒服的躺在床上进行。
我不太喜欢 C++ 的一个原因是:C++ 代码从一个局部去阅读,很难有唯一的解释。它的代码字面意思很可能对应有多种实际操作含义,确定性不足。函数名重载、操作符重载都是隐藏在局部代码之外的。甚至你看到一个变量名,不去同时翻阅上下文及头文件的话,都很难确定这是一个局部变量还是一个类成员变量(前者的影响范围和后者大为不同,大脑在做分析的时候剪枝的策略完全不同);看到一个变量,原本以为是一个输入值,直到看到最后,发现它还可以做输出,回头一看函数声明,其实它是一个引用量。如果用到模板泛型就更可怕,连数据类型都不确定。只从局部代码无法得知模板实例化之后那些关联的操作到底做了些什么。阅读 C++ 项目往往需要在代码间相互参考,增加了大脑太多的负担。
那么,光靠大脑 Code Review 是不是就够了呢?如果自身能力无限提高,我认为有可能。通过积累经验,我这些年能直接分度阅读的代码复杂程度明显超过往年。但总有人力所不及的时候。这时候最好的方法是加入日志输出作为辅助手段。
试想我们在用交互调试工具时,其实是想知道些什么?无非是程序的运行路径,是不是真的走到了这里,以及程序运行到这里的时候,变量的状态是怎样的,有没有异常情况。日志输出其实在做同样的工作。关键路径上输出一行日志,可以表达程序的运行路径。把重要的变量输出在日志里,可以查询当时的程序运行状态。怎样有效的输出日志自然也是需要训练的技能。不要过于担心日志输出对性能的影响,最终软件有 20% 上下的性能波动对于软件的可维护性来说是微不足道的。
和外挂的调试工具相比,日志具备良好的回溯查询能力。作为 Code Review 的一个辅助,我们大脑其实需要的只是对判断的一个修正:确认程序是否是沿着脑中模拟的路线在行进,内部状态是否一致正常。和调试工具不同,日志不会打断运行过程,对多个程序并行运行的软件,例如 C/S 结构的系统就更为重要了。
其实保留状态信息在交互调试工具中也是非常重要的技巧。我相信很多人和我一样,在调试程序时有时会增加一些临时的全局变量,把一些中间状态写到这些变量中。在交互调试过程中偶尔需要去查看这些状态值。这种临时状态暂存变量,其实也充当了日志的功能。
文本日志的好处是可以利用文本处理工具做信息二次提取。grep awk vim python lua 都是分析日志的好手段。如果日志巨大,且存在在远程机器上,你很可能找不到更有效快捷的手段。很多时候,不断的重新运行有 bug 的程序的代价,是远超一次运行得到详细日志后再对日志做分析的。
那么,学会使用交互调试工具重要吗?我认为依然重要。偶尔用之,也能起到奇效。尤其是程序崩溃的时候,attach 到进程中观察崩溃时的状态。操作系统大多也能 dump 出崩溃时的进程状态供事后分析。这些都需要你会用调试工具。但通过静态状态的草灰蛇线反推出崩溃前到底发生了些什么,却也更需要对代码本身有足够的理解。因为用的时机不多,我认为命令行的 gdb 就足够用了。在分析损坏的栈帧、编写脚本分析一些复杂数据结构方面,命令行版本更具灵活性,应用范围也较广。而交互上的不便,增加的学习成本,都是可以接受的。