12 面向正确性与健壮性的软件构造
12.1 什么是健壮性和正确性
12.1.1 健壮性:
系统在不正常输入或不正常外部环境下仍能够表现正常的程度
面向健壮性的编程要求:
- 处理为期望的行为和错误终止
- 即使终止执行,也要准确/无歧义的向用户展示全面的错误信息
- 错误信息有助于debug
健壮性定律:(Postel’s Law)
- 程序员应总是假定用户恶意、假定自己的代码可能失败
- 把用户想象成白痴,可能输入任何东西(因此要向用户返回明确错误信息)
- 对别人宽容点,对自己狠一点(保证自己的代码必须全部安全、放宽用户的输入要求)
如何实现健壮性:
- 封闭实现细节,限定用户的恶意行为(用户不应该访问库、数据结构、指针等)
- 考虑极端情况,没有“不可能”:开发人员考虑如何处理极不可能发生的情况,并相应地执行处理。
12.1.2 正确性:
正确性定义为软件按照其规范执行的能力,是最重要的质量指标。
和健壮性的区别:
- 正确性:永不给用户错误的结果,没有结果比不准确的结果要好;倾向于直接报错。有利于生成更简洁、更容易理解和维护的代码
- 让开发者变得更容易:用户输入错误,直接结束
- 健壮性:尽可能保持软件运行而不是总是退出,即使这会导致有时不准确的结果;倾向于容错
- 让用户变得更容易:出错也可以容忍,程序内部已有容错机制
使用方式: 对外的接口,倾向于健壮性;对内的实现,倾向于正确性。在内外部之间做好隔离,防止“错误”扩散
可靠性:系统在规定条件下执行所需功能的能力
可靠性=稳健性+正确性
12.1.3 改善健壮性和正确性的步骤
- 使用断言、防御性编程、代码审查、形式验证等,以健壮性和正确性为目标对代码进行编程
- 观察故障症状(内存转储、堆栈跟踪、执行日志、测试)
- 识别潜在故障(bug本地化、调试)
- 修复错误(代码修正)
12.2 如何衡量稳健性和正确性?
12.2.1 外部观察角度
MTBF指平均故障间隔时间(平均无故障运行时间),是指相邻两次故障之间的平均工作时间,计算公式为 MTBF = 总运行时间/总的故障次数。
MTBF的计算依赖于对系统故障的定义:对于可修复系统,故障是指导致系统不可使用并进入维修状态的那些;在本定义下,如果发生的故障可以保持或维持在未修复状态,并且不会使系统停止运行,则不视为故障。
MTBF用于描述可修复系统的平均无故障运行时间,MTTF(故障前平均时间)描述不可修复系统的故障前平均时间
12.2.2 内部观察角度(间接)
残余缺陷率:每千行代码中遗留中的bug的数量
12.3 Java中的错误和异常——如何保证健壮性
error:
Error类描述Java运行时系统内部很少发生的系统错误和资源耗尽情况(例如,VirtualMachineError、LinkageError)。
error的种类:用户输入错误、设备错误、物理限制
在大多数时候,程序员不需要实例化Error
内部错误:程序员通常无能为力,一旦发生,想办法让程序优雅的结束,不要抛出error对象
exception:
Exception类描述由程序引起的错误(例如FileNotFoundException、IOException)。
异常:你自己程序导致的问题,可以捕获、可以处理
12.4 异常处理
12.4.1 什么是异常
异常:程序执行中的非正常事件,导致程序无法再按预想的流程执行
异常是一种特定的方法,通过这种方法,代码可以将错误或异常事件传递给调用它的代码。
return之外的第二种退出途径
异常的优点:
- 显式的错误提醒机制(相较于返回错误信息)
- 异常是一种高层机制(相较于C语言的转储内存)
- 改善代码结构(使用try catch语句将正常代码和错误处理分开)
12.4.2 异常分类
由引起原因不同将异常被分为运行时异常和其他异常。运行时异常由程序员在代码里处理不当造成;其他异常由外部原因造成
运行时异常有:类型强转异常、数组越界异常、空指针异常等。是程序源代码中引入的故障所造成的,如果在代码中提前进行验证,这些故障就可以避免。
其他异常有:EOF、尝试打开不存在的文件、尝试找到不存在的类等。是程序员无法完全控制的外在问题所导致的。即使在代码中提前加以验证(如:文件是否存在),也无法完全避免其发生。
12.4.3 Checked and unchecked exceptions
这是从异常处理机制角度所做的分类
Java中的异常结构层次:(红色编译时不报错,蓝色编译时报错)
异常如何被处理:
当异常发生时,需要处理异常或者抛出异常,编译器在编译时就可以帮助检查你的程序是否抛出或处理了可能会出现的异常。
编译器不会检查error和运行时异常,这两类统称为Unchecked exceptions,此外的异常统称为Checked exceptions。因为即使出现也解决不了,只有在运行时才会报错,此时需要重新编写代码。
对于Unchecked exceptions而言,不需要在编译时使用try…catch等机制处理。可以不处理,编译没问题,但执行时出现就导致程序失败,代表程序中有潜在的bug。类似于编程语言中的动态类型检查。
eg:
常见的Unchecked Exception :
- ArrayIndexOutOfBoundsException:当代码使用数组索引时,由JVM抛出,该索引超出了数组的界限
- NullPointerException:当代码试图在需要对象引用的地方使用null引用时,由JVM抛出。
- NumberFormatException:当试图将字符串转换为数字类型,但字符串没有适当的格式时,以编程方式(例如,通过Integer.parseInt())引发。
- ClassCastException:当试图强制转换对象引用失败时由JVM引发
对于Checked exceptions而言,必须捕获或抛出,否则程序将无法编译(编译器检查您是否为所有Checked exceptions提供了异常处理程序)。类似于编程语言中的静态类型检查。
Checked exceptions的处理方式:
- Declaring exceptions (throws) 声明“本方法可能会发生XX异常”,需要在规约中写出
- Throwing an exception (throw) 抛出XX异常
- Catching an exception (try, catch, finally) 捕获并处理XX异常
12.4.4 通过throws声明已检查的异常
如果遇到无法处理的情况,Java方法可以throw一个异常。一个方法不仅会告诉Java编译器它可以返回什么值,还将告诉编译器可能抛出的异常类型。这样的话,需要在规约的前置条件中写出。
eg:
程序员必须在方法的spec中明确写清本方法会抛出的所有checked exception(之间用逗号隔开),以便于调用该方法的client加以处理
至于unchecked exception,表示存在bug(代码实现或者客户端调用中),不用在规约中声明。
eg:
对于子类型:
- 如果子类型中override了父类型中的函数,那么子类型中方法抛出的异常不能比父类型抛出的异常类型更宽泛
- 子类型方法可以抛出更具体的异常,也可以不抛出任何异常
- 如果父类型的方法未抛出异常,那么子类型的方法也不能抛出异常
12.4.5 如何抛出异常
- 找到一个能表达错误的Exception类,或者构造一个新的Exception类
- 构造Exception类的实例,将错误信息写入
- 抛出
eg:
如果没有符合要求的异常类,则需要自己创建异常
12.4.6 创建异常类
如果JDK提供的exception类无法充分描述你的程序发生的错误,可以创建自己的异常类
可以创建checked exception,也可以创建unchecked exception。前者需要在方法签名中声明继承自exception类或者其子类,并在方法体中捕获或抛出;后者需要继承运行时异常类,不需要捕获,也不需要声明。
12.4.7 捕获异常
异常发生后,如果找不到处理器,就终止执行程序,在控制台打印出栈轨迹。
要捕获异常,请设置try/catch块
如果try块中的任何代码引发catch子句中指定的类的异常,则try中后面的代码不会再执行,执行捕获异常的catch代码;如果try中没有抛出异常,则catch中代码不会被执行
catch中也可以继续向上抛出异常,不在本方法内处理,而是传递给调用方,由client处理(“推卸责任”)
原则:
-
尽量在自己这里处理,实在不行就往上传——要承担责任;但有些时候自己不知道如何处理,那么提醒上家,由client自己处理。
-
如果父类型中的方法没有抛出异常,那么子类型中的方法必须捕获所有的checked exception,因为子类型方法中不能抛出比父类型方法更多的异常。
可以try一个,catch多个
eg:
从异常中获得更多细节信息:
- 要了解有关该对象的更多信息,使用e.getMessage()获取详细的错误消息(如果有的话)
- 使用e.getClass().getName()获取异常对象的实际类型。
12.4.8 转发(re throw)和链接(chaining)异常
本来catch语句下面是用来做exception handling的,但也可以在catch里抛出异常,这么做的目的是:更改exception的类型,更方便client端获取错误信息并处理
eg:
但这么做的时候最好保留“根本原因”:
捕获异常时,可以这样检索原始异常:Throwable e = se.getCause();
public Throwable getCause()返回此 throwable 的 cause;如果 cause 不存在或未知,则返回 null。(该 Cause 是导致抛出此 throwable 的throwable。) 此实现返回由一个需要 Throwable 的构造方法提供的 cause,或者在创建之后通过 initCause(Throwable) 方法进行设置的 cause。
强烈建议使用这种包装技术。它允许您在子系统中抛出高级异常,而不会丢失原始故障的详细信息。
eg:
12.4.9 finally条款
当异常抛出时,方法中正常执行的代码被终止。如果异常发生前曾申请过某
些资源,那么异常发生后这些资源要被恰当的清理,此时需要使用finally语句。
finally部分的代码,是否捕获异常都会被执行,可以使用finally子句而不使用catch子句。
eg:
- 如果没有抛出异常:1、2、5、6
- 如果抛出异常:1、3、4、5、6
- 如果抛出异常但是没有被catch: 1、5(5直接结束,不执行6,之后将异常抛出给上层)
eg:
结果:return false
12.4.10 分析调用栈轨迹
堆栈跟踪是程序执行过程中特定点上所有挂起的方法调用的列表,您可以通过调用Throwable类的printStackTrace方法来访问堆栈跟踪的文本描述。
eg:
12.5 断言——如何保证正确性
最好的防御就是不要引入bug,可以使用静态/动态检查、不可变数据类型、不可变引用等实现。
其次是本地化bug:如果无法避免bug,则尝试将bug限制在最小的范围内,以便快速失败,有利于查找和修改。另外可以使用断言机制。
断言机制:当前提条件不满足时,代码通过断言引发AssertionError异常来终止程序,阻止调用方错误的影响传播。检查前置条件是防御式编程的一种典型形式。
12.5.1 断言
断言:在开发阶段的代码中嵌入,检验某些“假设”是否成立。若成立,表明程序运行正常,否则表明存在错误
每个断言都包含一个您认为在程序执行时为真的布尔表达式。如果断言不为真,则JVM 会抛出一个断言异常,这意味着代码中有错误,需要被修复;如果断言为真,则表示程序员代码编写没问题,可以继续下一步操作。
断言的两种形式:
assert condition;
assert condition : message;//message在发生错误时显示给用户,便于快速发现错误所在
eg:
断言即是对代码中程序员所做假设的文档化,也不会影响运行时性能(在实际使用时,assertion都会被disabled)
12.5.2 什么时候用断言
1.断言主要用于开发和维护阶段,避免引入和帮助发现bug; 实际运行阶段,不再使用断言,避免降低性能
如果条件语句或switch语句不能覆盖所有可能的情况,那么最好使用断言来阻止非法情况。
2.断言中不要包含业务逻辑,因为在正式运行时可能被关闭。
eg:
3.由于断言可能被禁用,因此程序的正确性永远不应取决于是否执行断言表达式。
4.特别是,断言的表达式不应有副作用:
例如,如果要断言需要从列表中删除的元素确实在列表中找到了,请不要这样写:
assert list.remove(x);
如果禁用断言,则会跳过整个表达式,并且永远不会从列表中删除x。
所以应该改为这样写:
boolean found = list.remove(x);
assert found;
5.不要使用断言判断不受程序控制的事如文件/网络/用户输入等。因为断言一旦false,程序就会停止执行;而你的代码无法保证不出现此类外部错误。外部错误要使用Exception机制去处理。
6.断言可以打开或者关闭:断言会影响程序性能,因此在运行时可以关闭来提高程序性能
12.5.3 断言使用指南
断言通常涉及程序的正确性问题; 使用断言处理“绝不应该发生”的情况
异常通常涉及程序的健壮性问题;使用异常来处理“预料到可以发生”的不正常情况
如果参数来自于外部(不可控),则使用异常处理;如果参数来自于自己写的其他代码,则可以使用断言来帮助发现错误
要想对前置条件下断言,需要在非public方法中进行;对后置条件随便下断言(是否public都无所谓)。
eg:
将断言和异常处理结合起来以实现健壮性:
- 断言和异常处理都可以处理同样的错误,如在超大型程序中,先使用断言,失败则执行错误处理代码。这样有助于消除尽可能多的错误
- 开发阶段用断言尽可能消除bugs;在发行版本里用异常处理机制处理漏掉的错误
12.6 防御式编程
防御性编程是一种防御性设计,旨在确保软件在不可预见的情况下继续运行。
防御性编程技术:
- 对于无效输入的保护机制
- 断言
- 异常
- 特殊的错误处理机制
- 对外隔离(设置路障)
- 调试辅助手段
12.6.1 对于无效输入的保护机制
对来自外部的数据源要仔细检查,例如:文件、网络数据、用户输入等; 对每个函数的输入参数合法性要做仔细检查,并决定如何处理非法输入
12.6.2 设置路障(Barricade )
路障是一种损失控制策略
设置路障的一种方法是将某些接口指定为“安全”区域的边界,检查跨越安全区域边界的数据的有效性,如果数据无效,则做出相应响应。
设置路障的方法:
-
使用public方法充当隔离舱
类中的public方法会假定数据不安全,它们负责检查数据并对其进行清理;一旦数据被类的公共方法接受,类的私有方法就可以假定数据是安全的。
-
操作间技术
路障的使用使得断言和错误处理之间有了明显的区别:
- 路障之外的例程应该使用异常处理函数,因为对数据进行任何假设都是不安全的;
- 路障内的例程应该使用断言,因为传递给它们的数据已经经过清理,如果路障内的某个例程检测到错误数据,则这是程序中的错误,而不是数据中的错误。
而决定哪些代码在路障内部,哪些代码在路障外部是体系结构级别的决定。
12.7 SpotBugs工具
早期版本:FindBugs:是一个使用静态分析来查找Java代码中错误的程序,它可以作为独立的GUI,也可以为IDE提供插件。
SpotBugs是一个使用静态分析来查找Java代码中错误的程序,它是FindBugs的精神继承者,在其社区的支持下,从停止的地方继续前进。它能检查出400多个错误模式;可以作为独立的GUI,也可以为IDE提供插件。