这一章聚焦如何通过断言和Java的异常处理机制这些防御式编程的方法来提高程序的健壮性和安全性,这是防御式编程技术的方面。但是健壮性和安全性到了一定的程度其实是矛盾的,健壮性意味着对于任何的输入,程序都不会终止而且都能给出返回,但代价就是牺牲返回的准确性;而正确性意味着子程序只要返回就要返回最精确的对象,要么就不返回让程序终止,这里就是能够反映防御式编程艺术的一方面。
什么是防御式编程
顾名思义,防御式编程就是以一种防御式的思想来设计子程序,其核心在于编写能够抵御错误和异常的代码,确保程序在面对不确定性和潜在错误时仍能保持稳定和可靠,它“防御”的就是各种对于子程序的潜在“攻击”,这个攻击可能是错误的输入类型、超过限制范围的输入值、外部系统、网络的异常等等,还有一类攻击来自于子程序的内部,这主要是由于程序设计问题导致的异常,包括栈溢出、无限循环等等,而这类攻击的“目的”都是使得子程序返回非预定的内容,使得整个程序奔溃(或者叫失控)。
作者重点介绍了输入检查、断言、异常处理、隔离、代码调试等防御式编程的方法。
无效输入的检测
子程序好比是一个工厂,输入是这个工厂与外部交互的第一步,我们要首先判断清楚这里输入的是正常的原料还是垃圾。
检查公共接口来源于外部的所有值:这里外部指的是示例的外部,比如文件、用户、网络或其他外部接口获取到的数据。包括数据的类型、范围、格式等等。
检查非公共接口的输入值:这里的输入值一般来自于其他子程序的输出。
一旦检测到无效参数就涉及到如何对错误进行处理,这在后面介绍,先介绍一下如何对上面的无效输入进行检测,这就涉及到断言。
断言
断言就是程序员在开发过程中用于检测某些条件是否成立的方法,一旦检测出异常程序员便有机会通过调整代码来解决相关的异常。
断言的语法:
assert num!=0:"num cannot be zero"
//assert 判断条件:如条件为假时返回的信息
由于断言的定位是给程序员一个在开发环境中用于检测异常的工具而且它对于程序有一定程度的性能损耗,所以在生产环境中会自动忽略。
断言使用的指导原则:
用断言来处理永远不应该发生的情况,而预期会发生的情况应该通过错误处理代码来处理:这里所说的不应该发生的情况主要是由于程序设计本身原因造成的异常,他们假定在进入生产环境之前就会被程序员所解决。而预期将会发生的情况主要是程序本身不可控的事项,比如网络、存储、IO系统等程序外部产生的异常,由于他们无法控制所以需要设计错误处理代码来解决相关问题。
避免将要执行的代码放到断言中:由于断言语句在生产环境中会被忽略,如果其中涉及要执行代码,则会被同时忽略。
使用断言来声明和验证子程序的前置和后置条件:前置与后置条件是“契约式设计”的重要组成部分。其中前置条件指的是子程序或者类的调用代码在调用相关子程序与类实例前需要满足的前提条件;而后置条件指的是子程序或者类的实例在返回前所需要承担的责任。这里需要强调一下这里的前置和后置条件的数据是来自于内部可信方法的,如果是来自于外部源头,则属于不可控的异常需要设计错误处理方法。
对于健壮性要求高的代码需要在使用断言的同时再处理错误:当软件比较复杂,尤其是一个功能的使用场景非常多时,程序员往往不能通过断言去检测出所有的异常(有的异常可能在单元测试当中没有被测出来),这个时候就需要通过错误处理方法再兜个底。
public class BuddleSort{public static void sort(int[] arr){assert arr!=null: "arr can not be null";if (arr==null) {throw new IllegalArgumentException("arr can not be null");}for(int i=0; i<arr.length-1;i++){for(int j=0;j<arr.length-1-i;j++){if(arr[j]>arr[j+1]){arr[j] = arr[j] + arr[j+1];arr[j+1] = arr[j]-arr[j+1];arr[j] = arr[j]-arr[j+1];}}}}
}
比如这个冒泡排序的算法,同时使用错误处理(抛出异常)和断言机制。
错误处理方法
前面说了,错误处理方法是程序员面对不可控的异常(就是文本里说的预期会发生)需要做的各种应对预案,作者列举了几种方式:
- 返回中立值:比如屏幕的背景颜色显示默认值。
- 换用下一条有用数据:比如在通过数据流读取大量数据进行统计分析的时候一两条信息读取的失败就直接忽略即可。
- 返回与上次相同的答案:比如一个一秒测量一次的温度计程序
- 换用最接近的合法值:对于一些返回超出范围的数据由范围极值取代
- 在文件中记录告警信息:这严格来说不算是处理,就是一个记录
- 返回一个错误代码:错误代码可以映射不同的处理方式
- 调用错误处理子程序或者对象:将错误处理集中在一个全局的处理中心,这个方法具有高内聚的特性,但是一旦处理中心被攻击,将会对所有程序产生影响。
- 在错误的地方显示错误消息:这里说的显示消息是将错误信息直接呈现给用户,这往往不被推荐。
- 用最妥当的方式在局部处理错误:要求子程序在产生或者catch错误以后不要再抛出,而是直接处理。
- 关闭程序:如果所有方式都不合适,那就关闭程序重新启动吧。
Java标准的异常处理机制
异常的介绍
Java通过将各种异常(Exception)封装形成不同的类来对其进行标准化的处理,下面先说一下Java异常的分类方式:
Throwable
类是所有错误和异常的基类。它定义了错误和异常的基本结构和行为,包括如何报告错误信息、如何获取错误的堆栈跟踪等。它提供的基本方法包括:
getMessage()
:返回异常的详细信息字符串。toString()
:返回异常类名和异常信息。printStackTrace()
:将异常的堆栈跟踪信息打印到标准错误流。fillInStackTrace()
:填充异常的堆栈跟踪信息。
这些方法也是Exception和Error类的基础方法,在自定义异常种类的时候无需去重构这些方法,重点只要把几个构造函数重构即可:
public class CustomException extends Exception {public CustomException() {super(); // 调用Exception的无参构造函数}public CustomException(String message) {super(message); // 调用Exception的构造函数,接收一个错误消息}public CustomException(String message, Throwable cause) {super(message, cause); // 调用Exception的构造函数,接收一个错误消息和一个引起异常的原因}public CustomException(Throwable cause) {super(cause); // 调用Exception的构造函数,仅接收一个引起异常的原因}
}
这里的cause是底层的异常,message是错误信息的描述。
异常的处理方法
对于异常(Checked Exception)的处理方式其实就两种:一是捕获后直接通过错误处理方法处理掉,另一种是抛给上层调用者(当然非受控异常也可以直接忽略掉,交给日志反映)。下面分别展示两种方式:
//这里Divide方法选择将非受控异常ArithmeticException抛出给上层调用者public class SafeDivision {public static int Divide(int a, int b){assert b!=0: "Divisor cannot be zero";if(b==0){throw new ArithmeticException("Divisor cannot be zero");}return a/b;}
}
//上层的Calculator在调用SafeDivision.Divide方法时选择捕获相关方法进行处理(这里是返回0值)public class Calculator {public static int add(int a, int b){return a+b;}public static int sub(int a, int b){return a-b;}public static int mul(int a, int b){return a*b;}public static int divide(int a, int b){try {return SafeDivision.Divide(a, b);} catch (ArithmeticException e) {return 0;}}
}
使用异常的注意事项
使用异常能够让程序的其他部分无法忽略错误的存在:对于程序的调用者来说必须明确它收到的异常要如何处理,否则程序便会直接关闭。
只有在真正可能会发生异常的情况下才抛出异常:如果其他编码实践可以解决相关的异常情况(尤其是非受控异常),那就尽量不要使用异常,因为它会使得程序调用者不得不去关注子程序会抛出的异常类型,这在一定程度上有降低封装性的可能。
不要使用异常来推卸责任:不要只做抛出,只要抽象程度一致了,就应该在本级把异常给处理掉
尽量不要在构造函数和析构函数中抛出异常:由于像C++调用析构函数的前提是构造函数被成功调用过,当构造函数抛出异常的时候可能实例已经有部分被构建,但由于构造函数未被执行完整,导致无法调用析构函数,造成潜在的资源泄漏风险。
在合适的抽象层抛出异常:这里就需要通过自定义异常来实现同级的抽象。
在异常消息中包括导致异常的所有消息:即异常的message参数要在初始化时仔细设置。
避免空的catch块:这会绕过异常处理的强制性屏障,造成危险。
了解库代码的所抛出的异常:因为库代码是子程序组成的基石,我们只有对他们的异常情况够了解才能够实现真正的防御式编程。
可以创建一个集中式的异常报告机制:标准化所有异常的规范动作以及返回信息。
隔离程序
这里说的隔离就是通过对公共接口输入数据的清洗,将输入数据转换为合适的类型,使得将子程序与外部的错误隔离开。
这里作者重点说明了隔离的使用使得断言和错误处理方法的区别变得更加清晰。对于隔离内的子程序由于已被清理过,默认不存在不可控的输入问题,所以可以通过断言来检测异常,而隔离外的程序应该使用错误处理方法。
调试辅助代码
调试辅助代码是在软件开发过程中,为了帮助开发者更好理解和定位代码中的问题而在其中插入额外代码段来辅助程序员的代码。这里有一种比较激进的辅助方式值得说一说就是“进攻式编程”。
进攻式编程
进攻式编程主张在开发阶段尽可能让所有的错误都显示出来(甚至是夸张的显示出来),使得程序员不得不进行优化,而在生产环境中将所有相关的错误都恢复。下面作者列举了进攻式编程的六种方式:
- 确保断言语句是的程序终止运行;
- 完全填充所有分配到的内存,以便检测出内存分配方面存在的错误
- 完全填充所有分配到的文件或流,以便排查任何形式的格式错误
- 确保每一个case语句的default分支或者else分支都能产生极其严重的错误
- 在删除对象之前使其填满垃圾数据
- 让程序将错误日志抄送程序员电子邮件