防御式编程的主要思想是:子程序应该不因传入错误数据而被破坏,哪怕是由其他子程序产生的错误数据。更一般地说,其核心思想是要承认程序会有问题,都需要被修改,聪明的程序员应该根据这一点来编程序。
保护程序免遭非法输入数据的破坏
通常有三种方法来处理进来垃圾数据:
- 检查所有来源于外部数据的值;
- 检查所有子程序输入参数的值;
- 决定如何处理错误的输入参数。
断言
断言是指在开发期间使用的、让程序在运行时进行自检的代码。断言为真,则表明程序运行正常,断言为假,则意味着已经在代码中发现了错误。
断言可以用于在代码中说明各种假定,澄清各种不希望的情形。可用断言检查如下这类假定:
- 输入参数或输出参数的取值处于预期的范围内;
- 在程序开始(或者结束)执行时文件或流是处于打开(或关闭)的状态;
- 在程序开始(或者结束)执行时,文件或流的读写位置处于开头(或结尾)处;
- 文件或流已用只读、只写或者可读可写方式打开;
- 仅用于输入的变量的值没有被子程序所修改;
- 指针非空;
- 传入子程序的数组或其他容器至少能容纳X个数据元素;
- 表已初始化,存储着真实数据;
- 在程序开始(或者结束)执行时,某个容器是空的(或满的);
- 一个经过高度优化的复杂子程序的运算结果和相对缓慢但代码清晰的子程序的运算结果相一致。
建立自己的断言机制
如果你用的语言不支持断言语句,自己写也是很容易的。下面的例子给出了一个使用C++宏改进的ASSERT实现:
#define ASSERT( condition, message ) {
if( !(condition) ) {
LogError( "Assertion failed: ",
#condition, message );
exit( EXIT_FAILURE );
}
}
使用断言的指导建议
下面是关于使用断言的一些指导性建议:
- 用错误处理代码来处理预期会发生的状况,用断言来处理绝不应该发生的状况;
- 避免把需要执行的代码放到断言中;
- 用断言来注解并验证前条件和后条件;
- 对于高健壮性的代码,应该先使用断言再处理错误。
错误处理技术
断言用于处理代码中不应该发生的错误。那么又该如何处理那些预料中可能要发生的错误呢?
- 返回中立值;
- 换用下一个正确的数据;
- 返回与前次相同的数据;
- 换用最接近的合法值;
- 把警告信息记录到日志文件中;
- 返回一个错误码;
- 调用错误处理子程序或对象;
- 当错误发生时显示错误信息;
- 用最妥当的方式在局部处理错误;
- 关闭程序。
健壮性与正确性
正确性意味着永不返回不准确的结果。健壮性意味着要不断尝试采取某些措施,以保证程序可以持续地运转下去,哪怕有时做出一些不够准确的结果。
高层次设计对错误处理方式的影响
应该在整个程序里采用一致的方式处理非法的参数。如果你决定让高层的代码来处理错误,而底层的代码只需简单地报告错误,那么就要确保高层的代码真的处理了错误。
异常
异常是把代码中的错误或异常事件传递给调用方代码的一种特殊手段。下面给出一些使用异常的建议:
- 用异常通知程序的其他部分,发生了不可忽略的错误;
- 只有在真正例外的情况下才抛出异常;
- 不能用异常来推卸责任;
- 避免在构造函数和析构函数中抛出异常,除非你在同一地方把它们捕获;
- 在恰当的抽象层次抛出异常;
- 在异常消息中加入关于导致一场发生的全部信息;
- 避免使用空的
catch
语句; - 了解所用库函数可能抛出的异常;
- 考虑创建一个集中的异常报告机制;
- 把项目中对异常的使用标准化;
- 考虑异常的替换方案。
隔离程序,使之包容由错误造成的伤害
隔离是一种容损策略。以防御式编程为目的而进行隔离的一种方法,是把某些接口选定为“安全”区域的边界。对穿越安全区域边界的数据进行合法性校验,并当数据非法时做出敏锐的反应,在输入数据时将其转换为恰当的类型。
辅助调试代码
防御式编程的另一重要方面是使用辅助调试的代码,调试助手非常强大,可以帮助快速地检测错误。
- 不要自动地把产品版的限制强加于开发版之上;
- 尽早引入辅助调试代码;
- 越早引入辅助调试的代码,它能提供的帮助也越大。
- 采取进攻式编程;
- 确保断言语句使程序终止运行;
- 完全填充分配到的所有内存,这样可以让你检测到内存分配错误;
- 完全填充所有分配到的文件或流,这样可以排查出文件格式错误;
- 确保每一个case语句中的default分支或else分支都能产生严重错误,或者至少让这些错误不会被忽视;
- 在删除一个对象之前把她填满垃圾数据;
- 让程序把它的错误日志文件用电子邮件发给你,这样你就能了解到在已发布的软件中还发生了哪些错误——如果这对于你所开发的软件适用的话。
- 计划移除调试辅助代码;
- 使用类似ant和make这样的版本控制工具;
- 使用内置的预处理器;
- 编写你自己的预处理器;
- 使用调试存根。
确定在产品代码中该保留多少防御式代码
下面是一些使用防御式编程的建议:
- 保留那些检查重要错误的代码;
- 去掉检查细微错误的代码;
- 去掉可以导致程序硬性崩溃的代码;
- 保留可以让程序稳妥地崩溃的代码;
- 为你的技术支持人员记录错误信息;
- 确认留在代码中的错误信息是有好的;
对防御式编程采取防御式姿态
过多的防御式编程会导致程序变得臃肿而缓慢,防御式编程引入的额外代码增加了软件的额复杂度。
核对表:防御式编程
一般事宜
- [ ] 子程序是否保护自己免遭有害数据的破坏?
- [ ] 你用断言来说明编程假定吗?其中包括了前条件和后条件吗?
- [ ] 断言是否只是用来说明从不应该发生的情况?
- [ ] 你是否在架构或高层设计重规定了一组特定的错误处理技术?
- [ ] 你是否在架构或高层设计中规定了是让错误处理更倾向于健壮性还是正确性?
- [ ] 你是否建立了隔栏来遏制错误可能造成的破坏?是否减少了其他需要关注错误处理的代码量?
- [ ] 代码中用到了辅助调试的代码了吗?
- [ ] 如果需要启用或禁用添加的辅助助手的话,是否无需大动干戈?
- [ ] 在防御式编程时引入的代码量是否适宜——既不过多,也不过少?
- [ ] 你在开发阶段是否采用了进攻式编程来使错误难以被忽视?
异常
- [ ] 你在项目中定义了一套标准化的异常处理方案吗?
- [ ] 是否考虑过一场之外的其他替代方案?
- [ ] 如果可能的话,是否在局部处理了错误而不是把它当成一个异常抛到外部?
- [ ] 代码中是否避免了在构造函数和析构函数中抛出异常?
- [ ] 所有的异常是否都与抛出它们的子程序处于同一抽象层次上?
- [ ] 每个异常是否都包含了关于异常发生的所有背景信息?
- [ ] 代码中是否没有使用空的catch语句?
安全事宜
- [ ] 检查有害输入数据的代码是否也检查了故意的缓冲区溢出、SQL注入、HTML注入、整数溢出以及其他恶意输入数据?
- [ ] 是否检查了所有错误返回码?
- [ ] 是否捕获了所有异常?
- [ ] 出错消息中是否避免出现有助于攻击者攻入系统所需的信息?
更多资源
安全
Howard《Writing Solid Code》
断言
Maguire《Writing Solid Code》
Stroustrup《The C++ Programming Language》
Meyer《Object-Oriented Software Construction》
异常
Meyer《Object-Oriented Software Construction》
Stroustrup《The C++ Programming Language》
Meyers《More Effective C++》
Arnold《The Java Programming Language》
Bloch《Effective Java Programming Language Guild》
Foxall《Practical Standards for Microsoft Visual Basic .NET》
要点
- 最终产品代码中对错误的处理方式要比“垃圾进,垃圾出”复杂得多;
- 防御式编程技术可以让错误更容易被发现、修改,减少错误对产品代码的破坏;
- 断言可以帮助人尽早发现错误,尤其在大型系统和高可靠性的系统中,以及快速变化的代码中;
- 关于如何处理错误输入的决策是一项关键的错误处理决策,也是一项关键的高层设计决策;
- 异常提供了一种与代码正常流程角度不同的错误处理手段。如果留心使用异常,它可以成为程序员们知识工具箱中的一项有益补充,同时也应该在异常和其他错误处理手段之间进行权衡比较;
- 针对产品代码的限制并不适用于开发中的软件。你可以利用这一优势在开发中添加有助于更快地排查错误的代码。