16防御式编程2
一 辅助调试的代码
1. 不要自动地把产品版本的限制强加于开发版之上
程序员们常常有这样一个误区,即认为产品级软件的种种限制也适用于开发中的软件。产品级的软件要求能够快速的运行,而开发中的软件则允许运行缓慢。产品级的软件要节约适用资源,而开发中的软件在使用资源时可以比较奢侈。产品级的软件不应向用户暴露可能引起危险的操作,而开发中的软件则可以提供一些额外的、没有安全网的操作。
我曾参与编写的一个程序中大量地使用了四重链表。链表的代码是很容易出错的,链表本身的结构很容易损坏。因此我给程序加了一个菜单项来检测链表的完整性。
在调试模式下,Microsoft Word 在空闲循环中加入了一些代码,它们每隔几秒中就检查一次 Document 对象的完整性。这样既有助于快速检测到数据的损坏,也方便了对错误的诊断。
应该在开发期间牺牲一些速度和对资源的使用,来换取一些可以让开发更顺畅的内置工具。
2. 尽早引入辅助调试的代码
你越早引入辅助调试的代码,它能够提供的帮助也越大。通常,除非被某个错误反复地纠缠,否则你是不愿意花精力去编写一些调试辅助的代码的。然而,如果你一遇到问题马上就编写或使用前一个项目中用过的某个调试助手的话,它就会自始至终在整个项目中帮助你。
3. 采用进攻式编程
应该以这么一种方式来处理异常情况:在开发阶段让它显示出来,而在产品代码运行时让它能自我恢复。
下面列出一些可以让你进行进攻式编程的方法。
-
确保断言语句使程序终止运行。不要让程序员养成坏习惯,一碰到已知问题就按回车键把它跳过。让问题引起的麻烦越大越好,这样才能被修复。
-
完全填充分配到的所有内存,这样可以让你检测到内存分配错误。
-
完全填充已分配到的所有文件或流,这样可以让你排查出文件格式错误。
-
确保每一个case语句中的default分支或者else分支都能产生严重错误(比如让程序终止运行),或者至少让这些错误不会被忽视。
-
在删除一个对象之前把它填满垃圾数据。
-
让程序把它的错误日志文件用电子邮件发给你,这样你就能了解到在已发布的软件中还发生了那些错误——如果这对于你所开发的软件使用的话。
有时候,最好的防守正式大胆进攻。在开发时惨痛地失败,能让你在发布产品后不会败的太惨。
4. 计划移除调试辅助的代码
如果你是写程序给自己用,那么把调试用的代码都留在程序里可能并无大碍。但如果是商用软件,则此举会使软件的体积变大且速度变慢,从而给程序造成巨大的性能影响。要事先做好计划,避免调试用的代码和程序代码纠缠不清。下面是一些可以采用的方法。
-
使用类似ant和make这样的版本控制工具好make工具
版本控制工具可以从同一套源码编译出buto9ng版本的程序。在开发模式下,你可以让make工具把所有的调试代码都包含进来一起编译。而在产品模式下,又可以让make工具把那些你不希望包含在商用版本中的调试代码排除在外。
-
使用内置的预处理器
如果你所用的编程环境里有一个预处理器——比如C++开发环境——你可以用编译器开关来包含或排除调试用的代码。你既可以直接使用预处理器,还可以写一个能与预处理器指令同时使用的宏。下面是一个直接使用预处理器的例子:
#define DEBUG
#if defined(DEBUG)
// debugging code
#endif
这一用法可以有几种变化。比如说,除了可以直接定义DEBUG以外,还可以给他赋一个值,然后就可以判断其数值,而不仅是去判断它是否已经定义了。这么做可以让你区分不同级别的调试代码。你可能希望让某些调试代码永远留在程序里,这是你就可以用类似#if DEBUG > 0 这样的语句把这些代码括起来。另一些调试代码可能只是针对一些特定的用途,你可以用类似#if DEBUG == POINTER_ERROR这样的语句把这些代码括起来。在另外一些地方,你可能想设置调试级别,这时就可以写类似#if DEBUG > LEVEL_A 这样的语句。
如果你不喜欢让#if defined() 一类语句散布在代码的各处,那么可以写一个预处理器宏来完成同样的任务。
#define DEBUG
#if defined(DEBUG)
#define DebugCode(code_fragment){code_fragment}
#else
#define DebugCode(code_fragment)
#endif
//根据是否定义DEBUG符号,可选择是否编译此处代码
DebugCode(statemenmt 1;
statement 2;
...
statement n);
-
使用调试存根
很多情况下,你可以调用一段子程序进行调试检查。在开发阶段,该子程序可能要执行若干操作之后才能把控制权交还给其调用方代码。而在产品代码中,你可以用一个存根子程序(stub routine)来替换这个复杂的子程序,而这段stub子程序要么立即把控制权交换调用方,要么是执行几项快速的操作就返回。这种方法仅会带来很小的性能损耗,并且比自己编写预处理器要快一些。把开发版本和产品版本的stub子程序都保留起来,以便将来可以随时在两者之间来回切换。你可以先写一个检查传入指针是否有效的子程序。
void DoSomething(SOME_TYPE* pointer) { //check parameters passed in CheckPointer(pointer); }
在开发阶段,CheckPointer() 子程序会对传入的指针进行全面检查。这一检查可能相当耗时,但一定要非常有效,比如说这样:
void CheckPointer(void* pointer) { //执行第1项检查——可能是检查它不为NULL //执行第2项检查——可能是检查它的地址是否合法 //执行第3项检查——可能是检查它所指向的数据完好无损 //... //执行第n项检查——... }
当代码准备妥当,即将要编译为产品时,你可能不希望这项指针检查影响性能。这时你就可以用下面这个子程代替前面那段代码:
void CheckPointer(void* pointer) { //no code; just return to caller. }
5. 确定在产品代码中该保留多少防御式代码
防御式编程中存在这么一种矛盾的观念,即在开发阶段你希望错误能引人注意——你宁愿看它的脸色,也不想冒险去忽视他。但在产品发布阶段,你却想让错误尽可能地偃旗息鼓,让程序能十分稳妥地恢复或停止。下面给出一些指导性建议。
-
保留那些检查重要错误的代码
你需要确定程序的哪些部分可以承担未检测出错误而造成的后果,而哪些部分不能承担。
-
去掉检测细微错误的代码
如果一个错误带来的影响确实微乎其微的话,可以把检查它的代码去掉。
-
去掉可以导致程序硬性崩溃的代码
当你的程序在开发阶段检测到了错误,你肯定想让它尽可能地引人注意,以便能修复它。实现这一目的最好的方法通常是让程序在检测到错误后打印出一份调试信息,然后崩溃退出。这种方法甚至对于细微的错误也很有用。
然而在生成产品的时候,软件的用户需要在程序崩溃之前有机会保存他们的工作成果,为了让程序给他们留出足够长的保存时间,用户甚至会忍受程序表现出的一些怪异行为。相反,如果 程序中的一些代码导致了用户工作成果的丢失,那么无论这些代码对帮助调试程序并最终改善程序质量有多大的贡献,用户也不会心从存感激的。因此,如果你的程序里存在着可能导致数据丢失的调试代码,一定要把它们从最终软件产品中去掉。
-
保留可以让程序稳妥地崩溃的代码
如果你的程序里有能够检测出潜在严重错误的调试代码,那么应该保留那些能让程序稳妥地崩溃的代码。
-
为你的技术支持人员记录错误信息
可以考虑在产品代码中保留辅助调试用的代码,但要改变他们的工作方式,以便与最终产品软件相适应。
-
确认留在代码中的错误消息是友好的
如果你在程序中留下了内部错误消息,请确认这些消息的用于对用户而言是友好的。