15防御式编程1
一. 防御式编程概述
防御式编程,这一概念来自防御式驾驶。在防御式驾驶中要建立这样一种思维,那就是你永远也不能确定另一位司机将要做什么。这样才能确保在其他人做出危险动作时你也不会受到伤害。你要承担起保护自己的责任,那怕是其他司机犯的错误。
防御式编程的主要思想是:子程序应该不因传入错误数据而被破坏。哪怕是由其他子程序产生的错误数据。更一般的说,其核心想法是要承认程序都会有问题,都需要被修改,聪明的程序员应该根据这一点来编程。
本章就是要讲述如何面度严酷的非法数据的世界、在遇到“绝不会发生”的事件以及其他程序员犯下的错误时保护你自己。
防御式编程是本书所介绍的其他提高软件质量技术的有益辅助手段。防御式编程的最佳方式就是在一开始不要在代码中引入错误。使用迭代式设计、编码前先写伪代码、写代码前先写测试用例、低层设计检查等活动,都有助于防止引入错误。因此,要在防御式编程之前优先运用这些技术。
二. 保护程序免遭非法输入数据的破坏
通常有三种方法来处理进来垃圾的情况。
1. 检查所有来源于外部的数据的值
当从文件、用户、网络或其他外部接口中获取数据时,应检查所获得的数据值,以确保它在允许的范围内。对于数值,要要确保它在可接受的取值范围内;对于字符串,要确保其不超长。如果字符串代表的是某个特定范围内的数据(如金融交易ID或其他类似数据),那么要确认其取值呵护用途,否则就应该拒绝接受。如果你在开发需要确保安全的应用程序,还有格外注意哪些狡猾的可能是攻击你的系统的数据,包括企图令缓存区溢出的数据、注入的SQL命令、注入的HTNL或XML代码、整数溢出以及传递给系统调用的数据,等等。
2. 检查子程序所有输入参数的值
检查子程序输入参数的值,事实上和检查来源于外部的数值一样,只不过数据是来自于其他子程序而非外部接口。下面的章节“隔离程序,使之包容由错误造成的损害”阐述了一种实用方法可用于确定哪些子程序需要检查其输入数据。
3. 决定如何处理错误的输入数据
一旦检测到非法的参数,你该如何处理它呢?根据情况不同,你可以从十几种不同的方案中选择其一,后面的章节“错误处理技术”中会详细描述这些技术。
三. 断言
1. 断言概述
断言(assertion)是指在开发期间使用的、让程序在运行时进行自检的代码(通常是一个子程序或宏)。断言为真,则表明程序运行正常,而断言为假,则则意味着它已经在代码中发现了意料之外的错误。
断言对于大型的复杂程序或可靠性要求极高的程序来说尤其有用。通过使用断言,程序员能更快速地排查出因修改代码或者别的原因,而弄进程序里的不匹配的接口假定和错误等。
一个断言通常含有两个参数:一个描述假设为真时的情况的布尔表达式,和一个断言为假时需要显示的信息。下面试假定变量 denominator(分母)的值应该为非零值时Java断言的写法。
assert denominator != 0 : "denominator si unexpectedly equal to 0.";
这个断言声明 denominator 不会等于 0 。 其中第一个参数,denominator != 0, 是个布尔表达式,其结果为true或false。第二个参数是当第一个参数为fasle时——即断言为假时——要打印的消息。
断言可以用于在代码中说明各种假定,澄清各种不希望的情形。可以用断言检查如下这类假定:
-
输入参数或输出参数的取值处于预期的范围内。
-
子程序开始(或者结束)执行时文件或流是处于打开(或关闭)的状态。
-
子程序开始(或结束)执行时,文件或流的读写位置处于开头(或结尾)处。
-
文件或流已用只读、只写或可读可写方式打开。
-
仅用于输入的变量的值没有被子程序所修改;
-
指针非空
-
传入子程序的数组或其他 容器至少能容纳 X 个数据元素。
-
表已初始化,存储着真实的数值。
-
子程序开始(或结束)执行时,某个容器是空的(或满的)。
-
一个经过高度优化的复杂子程序的运算结果和相对缓慢但代码清晰地子程序的运算结果相一致。
当然,这里列出的知识一些基本假定,你在子程序中还可以包含更多可以用断言来说明的假定。
正常情况下,你并不希望用户看到产品代码中的断言信息;断言主要是用于开发和维护阶段。通常,断言只是在开发阶段被编译到目标代码中,而在生成产品代码时并不编译进去。在开发阶段,断言可以帮助查清相互矛盾的假定、预料之外的情况以及传给子程序的错误数据等。在生成产品代码中,可以不把断言编译进目标代码里去,以免降低系统的性能。
2. 建立自己的断言机制
包括 C++、Java等在内的很多语言都支持断言。如果你用的语言不支持断言语句,自己写也是很容易的。C++ 中标准的 assert 宏并不支持文本信息。下面的例子给出了一个使用 C++ 宏改进的 ASSERT 实现:
#define ASSERT( condition, message){
if(!(condition))
{
LogError("Assertion failed: ", #conditionm message);
exit(EXIT_FAILURE);
}
}
3. 使用断言的指导意见
3.1 用错误处理代码来处理预期会发生的状况,用断言来处理觉不应该发生的状况
断言是用来检查永远不该发生的情况,而错误处理代码是用来检查不太可能经常发生的非正常情况,这些情况是能在写代码时就预料到的,且在产品代码中也要处理这些情况。错误处理通常用来检查有害的输入数据,而断言是用于检查代码中的bug。
用错误处理代码来处理反常情况,程序就能够很从容地对错误做出反应。如果在发生异常情况的时候触发了断言,那么要采取的更正的措施就不仅仅是对错误做出恰当的反映了——而是应该修改程序的源代码并重新编译,然后发布软件的新版本。
有种方式可以让你更好地理解断言,那就是把断言看做是可执行的注解——你不能依赖它来让代码正常工作,但与编程语言中的注释相比,他能更主动地对程序中的假定做出说明。
3.2. 避免把需要执行的代码放到断言中
如果把代码写在断言里,那么当你关闭断言功能时,编译器很可能就把这些代码排除在外了。应该把需要执行的语句提取出来,并把其运行结果赋给状态变量,再对这些状态变量进行判断。下面这样使用断言就更安全:
actionPerformed = PerformAction()
Debug.Assert(actionPerformed) 'Couldn't perform action
3.3. 用断言来注解并验证前条件和后条件
前条件和后条件是一种名为“契约式设计”的程序设计和开发方法的一部分。使用前条件和后条件时,每个子程序或类与程序的其余部分都形成了一份契约。
前条件是子程序或类的调用方代码在调用子程序或实例化对象之前要确保为真的属性。前条件时调用方代码对其所调用的代码要承担的义务。
后条件时子程序或类在执行结束后要确保为真的属性。后置条件时子程序或类对调用方代码所承担的责任。
断言是用来说明前置条件和后置条件的有力工具。也可以用注释来说明前条件和后条件,但断言却能动态地判断前条件和后条件是否为真。
3.4. 对于高健壮性的代码,应该先使用断言再处理错误
对于每种可能出错的条件,通常子程序要么使用断言,要么使用错误处理代码来进行处理,但是不会同时使用二者。
四. 错误处理技术
1 概述
断言可以用于处理代码中不应该发生的错误。那么又该如何处理那些预料之中可能发生的错误呢?根据所处情形的不同,你可以返回中立值、换用下一个正确数据、返回与前次相同的值、换用最接近的有效值、在日志文件中记录警告信息、返回一个错误码、调用错误处理子程序或对象、显示出错信息或者关闭程序——或把这些技术结合起来使用。
1.1 返回中立值
1.2 换用下一个正确的数据
1.3 返回与前次相同的数据
1.4 换用最接近的合法值
1.5 把警告信息记录到日志中
1.6 返回一个错误码
1.7 调用错误处理子程序或对象
1.8 当错误发生时显示出错误
1.9 用最妥当的方式在局部处理错误
1.10 关闭程序
正如前面视频游戏和 X 光机的例子告诉我们的,处理错误最恰当的方式要根据出现错误的软件的类别而定。这两个例子还表明,错误处理的方式有时更侧重于正确性,而有时更侧重于健壮性。开发人员倾向于非形式地使用这两个术语,但严格来说,这两个术语在程度上是截然相反的。正确性意味着永不返回不准确的结果。哪怕不返回结果也比返回不准确的结果好。然而,健壮性则意味着要不断尝试采取某些措施,以保证软件可以持续地运转下去,哪怕有时做出一些不够准确的结果。
人生安全攸关的软件往往更倾向于正确性而非健壮性。不返回结果也比返回错误的结果要好。放射线治疗仪就是体现这一原则的好例子。
消费类应用软件往往更注重健壮性而非正确性。通常只要返回一些结果就比软件停止运行要强。我所用的字处理软件有时会在屏幕下方显示半行文字。如果它检测到这一情况,难道我期望字处理软件退出吗?当然不。
2. 高层次设计对错误处理方式的影响
既然有这么多的选择,你就必须注意,应该在整个程序里采用一致地方式处理非法的参数。对错误进行处理的方式会直接关系到软件能否满足在正确性、健壮性和其他非功能性指标方面的要求。确定一种通用的处理错误参数的方法,是架构层次(或称高层次)的设计决策,需要在那里的某个层次上解决。
一旦确定了某种方法,就要确保始终如一地贯彻这一方法。如果你决定让高层次的代码处理错误,而低层次的代码只需简单地报告错误,那么就要确保高层次的代码真的处理了错误!有些语言允许你忽略“函数返回的是错误码”这一事实——在C++中,你无须对函数的返回值做任何处理——但千万不要忽略错误信息!检查函数的返回值。即使你认定某个函数不会出错,也无论如何要去检查一下。防御式编程全部的重点就在于防御那些你未曾预料到的错误。
这些指导建议对于系统函数和你自己写的函数都是成立的。除非你已确立了一套不对系统调用进行错误检查的架构性指导建议,否则请在每个系统调用后检查错误码。一旦检测到错误,就记下错误代号和它的描述信息。
五. 异常
1. 概述
异常是把代码中的错误或异常事件传递给调用方代码的一种特殊手段。如果在一个子程序中遇到了预料之外的情况,但不知道该如何处理的haul,它就可以跑出一个异常,就好比是举起双说“我不知道该怎么处理它——我真希望有谁知道该怎么办!”一样。对出错的前因后果不甚了解的代码,可以把控制权转交给系统中其他能更好地解释错误并采取措施的部分。
异常和继承有一点是相同的,即:审慎明智地使用时,它们都可以降低复杂度;而草率粗心地使用时,只会让代码变得几乎无法理解。下面给出的一些建议可以让你在使用异常时扬长避短,并避免一直相关的一些难题。
1.1 用异常通知程序的其他部分,发生了不可葫芦哦的错误
异常机制的优越之处在于它能提供一种无法被忽略的错误通知机制。其他的错误处理机制有可能会导致错误在不知不觉中向外扩散,而异常则消除了这种可能性。
1.2 只在真正例外的情况下才抛出异常
仅在真正例外的情况下才使用异常——换句话说,就是仅在其他编程实践方法无法解决的情况下才使用异常。异常的应用情形和断言相似——都是用来处理那些不仅罕见甚至永远不该发生的情况。
异常需要你做出一个取舍:一方面他是一种强大的用来处理预料之外的情况的途径,另一方面程序的复杂度会因此增加。由于调用子程序的diamante需要了解别调用代码中可能会抛出的异常,因此异常弱化了封装性。同时,代码的复杂度也会有所增加。
1.3 不能用异常来推卸责任
如果某种的错误情况可以在局部处理,那就应该在局部处理掉它。不要把本来可以在局部处理的错误当成一个未被捕获的异常抛出去。
1.4 避免在构造函数和析构函数中抛出异常,除非你在同一个地方把他们捕获
当从构造函数和析构函数里抛出异常时,处理异常的规则马上就会变得非常负责。比如说在C++里,只有在对象已完全构造之后才可能调用析构函数,也就是说,如果在构造函数的代码中抛出异常,就不会调用析构函数,从而造成潜在的资源泄漏。在析构函数中抛出异常也有类似复杂的规则。
1.5 在恰当的抽象层次抛出异常
1.6 在异常消息中加入关于导致异常发生的全部信息
每一个异常都是发生在代码抛出异常时所遇到的特殊情况下。这一信息对于读取异常消息的人们来说是很有价值的,因此要确保该消息中含有为理解异常抛出原因所需要的信息。如果异常时因为一个数组下标错误而抛出的,就应该在异常消息中包含数组的上界、下界以及非法的下标值等信息。
1.7 避免使用空的 catch 语句
1.8 了解所用函数库可能抛出的异常
1.9 考虑创建一个集中的异常报告机制
1.10 把项目中对异常的使用标准化
如果你在使用一种像C++一样的语言,其中允许抛出多种多样的对象、数据及指针的话,那么久应该为到底可以抛出哪些种类的异常建立一个标准。为了与其他语言相兼容,可以考虑只抛出从 std::exception 积累派生出的对象。
- 考虑创建项目的特定异常类,它可用作项目中所有可能抛出的异常的基类。这样就能把记录日志、报告错误等操作集中起来并标准化。
- 规定在何种场合允许代码使用 throw-catch 语句在局部对错误进行处理。
- 规定在何种场合允许代码抛出不在局部进行处理的异常。
- 确定是否要使用集中的异常报告机制。
- 规定是否允许在构造函数和析构函数中使用异常。
1.11 考虑异常的替换方案
有些程序员用异常来处理错误,只是因为他所用的编程语言提供了这种特殊的错误处理机制。你心里应该至始至终考虑各种各样的错误处理机制:在局部处理错误、使用错误码来传递错误、在日志文件中记录调试信息、关闭系统或其他的一些方式等。仅仅因为编程语言提供了异常处理机制而使用异常,是典型的“为用而用”;这也是典型的“在一种语言上编程”而非“深入一种语言去编程”的例子。
最后,请考虑你的程序是否真的需要处理异常。就像 Bjarne Stroustruop所指出的,应对程序运行时发生的严重错误的最佳做法,有时就是释放所有已获得的资源并终止程序执行,而让用户去重新用正确的输入数据再次运行程序。
六. 隔离程序
1. 隔离程序,使之包容由错误造成的损失
以防御式编程为目的而进行隔离的一种方法,是把某些接口选定为“安全”区域的边界。对穿越安全区域边界的数据进行合法性校验,并当数据非法时做出敏锐的反应。
也可以同样在类的层次采用这种方法。类的公用方法可以假设数据是不安全的,它们要负责检查数据并进行清理。一旦类的公用方法接收了数据,那么类的私用方法就可以假定数据都是安全的了。
也可以把这种方法看做是手术室里使用的一种技术。任何东西在允许进入手术室之前都要经过消毒处理。因此手术室内内的任何东西都可以认为是安全的。这其中最核心的设计决策就是规定什么可以进入手术室,什么不可以进入,还有把手术室的门设在哪里——在编程中也就是规定,哪些子程序可认为是在安全区域里的,哪些又是在安全区域外的,哪些负责清理数据。完成这一工作最简单的方法是在得到外部数据时立即进行清理,不过数据往往需要经过一层以上的清理,因此多层清理有时也是必要的。
2. 在输入数据时将其转换为恰当的类型
输入的数据通常都是字符串或数字的形式。这些数据有时要被映射为”是“或”否“这样的布尔类型,有时要被映射为像 Color_Red、Color_Green 和 Color_Blue 这样的枚举类型。在程序中长时间传递类型不明的数据,会增加程序的复杂度和崩溃的可能性——比如说有人在需要输入颜色枚举值的地方输入了”是“。因此,应该在输入数据后立即将其转换到恰当的类型。
3. 隔离与断言的关系
隔离的使用使断言和错误处理有了清晰地区分。隔栏外部的程序应使用错误处理技术,在那里对数据做的任何假定都是不安全的。而隔栏内部的程序里应使用断言技术,因为传进来的数据应该已在通过隔栏时被清理过了。如果隔栏内部的某个子程序检测到错误的数据,那么这应该是程序里的错误而不是数据里的错误。
隔栏的使用还展示了“在架构层次上规定应该如何处理错误”的价值。规定隔栏内外的代码是一个架构层次上的决策。