Programming - Error and Exception Handling
Table of Contents
Writing clean code (2009.08.17)
今年的RoboCup比赛惨败而归,除了我们项目管理混乱、研究不够深入,在代码的 实现也存在不少问题,实际上我们没有能小组出线完全是代码中bug造成的:当我方 是红色的时候,机器人会认为自己的肩膀是球,然后调用踢球函数,由于参数越 界,导致程序死循环,所以机器人就会一直单脚站立!这个问题值得我们反思: 如何能迅速地定位bug的位置?除了没有对代码进行充分测试之外,有没有其他办 法减少bug,也就是编写代码的时候尽可能少地留下bug?在阅读了Steve Maguire的《Writing clean code》之后,发现在我们的项目中存在着一些常见的 问题,而这些完全是可以是可以避免的。
在《Wirting clean code》中列出了很多原则,可以总结为一下几个方面:
使用断言
导致程序崩溃最常见的原因就是调用函数的时候非法参数输入,比如除0,对负 数求平方根,数组越界等等。所以在Steve建议
- 要使用断言对函数参数进行确认
- 使用断言来检查出无定义特性的非法使用
- 利用断言来检查不可能发生的情况
- 消除所做的隐式假设,或者利用断言检查其正确性
Assert的优点是能够捕获出错的位置,并且与使用异常相比十分简单(只要一行 代码);缺点是增加了额外开销、影响性能,所以Assert通常以宏的方式实现, 在Release版本中可以消除断言,以提高效率。断言可以用在对函数的输入进行确 认或者对函数的输出进行确认:我们应该 在函数的开头使用断言对输入的参数值 进行确认,如求平方根可以写成
template <typename T> T Math::sqrt(T in) { assert(in>=0); return std::sqrt(in); }
*对于有防错设计的函数,在防错处理中设置断言*,求平方根函数应写成
template <typename T> T Math::sqrt(T in) { if(in >=0) { return std::sqrt(in); } else // Error: no root, return 0 { assert(in>=0); return 0; } }
在使用assert的时候还要注意以下几点:
- 每个assert只检查一个条件,否则无法知道哪个条件失效;
- 为了保持Debug版本和Release版本的一致性,所以不能在断言中使用改变环境 的语句,如i++;
- 对于断言的意义进行注释说明。
调试方法
相对编写程序来说,调试程序有些枯燥,所以很多时候我们并没有花足够的时间 调试,问题越积越多,以至于后来调试的时候发现一个又一个的bug,最后迷失 在bug中。
单步跟踪
老实说本人以前不喜欢单步调试,认为效率太低,只有新手才会使用单步调试, 而且在有些环境下设置单步调试的环境比较复杂。但是Steve的一段话彻底颠覆 了我的看法:
要知道,当实现一个新函数时,你必须为其设计出函数的接口、勾画出相应的算 法、并把源程序全部输入到计算机中。与此相比,在你第一次运行程序时,为其 设置一个断点,按下“步进”键检查每行代码又能花多少时间呢?
是啊,不进行单步跟踪你怎么知道程序是怎么运行的呢?只能 *想当然地认为程序 应当像期望的那样运行*,但事实却恰恰相反。所以我决定恪守以下原则:
- 不要等到出了错误再对程序进行单步跟踪
- 对每一条代码进行单步跟踪
- 对代码进行单步跟踪时,要密切注视数据流
- 最好对优化的代码进行单步跟踪,以免编译器优化带来的差别
同时,习惯于对代码进行逐条跟踪会产生一个有趣的负反馈回路:对代码进行逐 条跟踪的程序员很快就会学会编写较小的容易测试的函数,因为对于大函数进行 逐条跟踪是非常痛苦的。
测试
即使单步跟踪没有发现问题,代码也不一定按照期望的方式工作,因为在采用的 算法不合适、进行的假设不合理,所以系统地对代码测试还是必须的。首先,为 了能够对程序进行“解耦”,应当
- 如果有单元测试,就进行单元测试
- 建立详尽的子系统检查,并经常地进行这些检查
一个程序出问题了,单元测试能够帮助定位错误。同时测试的时候需要
- 要消除随机特性——使错误可再现
- 如果某件事很少发生的话,设法使其经常发生
如果大部分测试都通过了,但只是某一次测试出现问题,千万不能掉以轻心,因 为 错误不会自己消失! 所以,对于一些随机出现的错误需要想办法再现,如果能 够再现出错的状态,离解决问题的方法也就不远了。此外,
- 要利用不同的算法对程序的结果进行确认
对于求解复杂的问题,可以先实现简单的、直观的算法,也许算法效率不是那么 高,但是编写、调试起来比较容易;之后再实现复杂的、效率高的算法,这时就 可以比较两种算法的结果以确认程序的正确性,同时也可以比较复杂的算法是否 真的就高效。
显式设计
显式设计越来越得到大家的认可,但是编译器有时还是会自作聪明地猜测程序员 的意图,而程序员往往偷懒而让编译器去猜。虽然很久之前就知道显式设计的重 要性,但是以下几点还是值得注意的:
- 在进行防错性设计时,不要隐瞒错误
例如前面求平方根的例子,如果没有断言,防错之后虽然程序不会崩溃,但是却 隐瞒了错误的存在,导致得到的结果并不正确。
- 编写功能单一的函数
其他程序员调用你所写的函数的时候,如果函数功能单一,则意图明显得多,就 不会出现误用函数的现象。
- 使函数调用明了易懂,要避免使用布尔参数
说来惭愧,很多时候当函数的某个参数只有两种状态的时候我喜欢使用布尔参 数,但这样带来的问题往往是参数的意义不明确,很容易导致传入错误的值。比 如在对机器人求解逆运动学的时候,我写的函数使用布尔值来表示左腿还是右 腿,这就导致每次调用的时候必须小心地确保传入的参数是否为对应的值。一个 较好的方法是使用枚举,参数的意义一目了然,而且便于以后扩展参数的意义, 比如对胳膊求运动学逆解。这一点一定要在代码中更正过来。
- 不要在正常的返回值中隐藏错误代码
在返回值中隐藏错误代码我以前也干过,还是求平方根的例子:在输入小于零的 时候返回-1表示错误,这看起来是很聪明的做法:在求平方根之后可以对结果进 行检查,然而这却违背了显式设计的原则!所有的调用者必须知道这个错误代码 的确切含义!
Debug版本与Release版本
几乎所有的程序都有Debug版本和Release版本,虽然两者的运行结果应该一致, 但是两者的区别也是非常明显的:Debug应该尽可能多地当机,这样程序员才能得 到反馈,而Release版本则不管发生什么都不能崩溃,程序崩溃会导致用户的数 据丢失、比赛中机器人当机……这些都是不能接受的。所以
- 既要维护Release版本,又要维护Debug版本
- 不要把Release版本的约束应用到Debug版本上
- 要用大小和速度来换取错误检查能力
- 保存调试信息,以便进行更强的错误检查
当然,Debug期间尽可能地当机是为了Release版本尽可能地不当机。
除了以上四个方面,以下两点也很有意义:
- 使用编译器所有的可选警告功能
我想大家都会讨厌编译器给出的警告,可惜的是在我们的项目中我们并没有花力 气去消除这些警告,而是听之任之,最后警告太多了谁也不会注意到新引入的警 告…形成恶性循环,所以在我们的项目中消除所有的警告也是必须的。
- 在找到正确的解法之前,不要一味地试,要花时间寻求正确的解
不管是在调用库函数还是别的程序员写的代码的时候,大家往往喜欢猜出函数的 用法(当然猜对了说明函数命名和接口做得比较好),不过相对试或者猜的方 法,花点时间去阅读文档更有效率,而且可以学到更多,不仅做到知其然,还要 知其所以然。
总结
Bug的产生很多时候往往是计算机和程序员的理解不一致造成的,造成互相不理解 的原因其实就是一个字:猜!程序员猜测程序会怎样运行,猜测库函数的接口是 怎么样的;一个程序员猜测另一个程序员设计的函数时的想法;而编译器也会猜 测程序员的意图。为了消除这种猜的局面,程序员所需要的就是使用确着的证据 说明程序是正常地运行的,所设计的程序应该很方便地给程序员足够的反馈信息。 前面列出的种种方法都是为了建立程序员与程序,程序员与其他程序员之间可靠 的信道。
实践出真知,希望我们的项目能够吸取教训,利用前文所述的方法来构建clean code。