异常处理原则

时间:2022-04-08 00:14:13

以瀑布流生命周期为例,说明在不同阶段应该使用的异常处理规则。

阶段 规则数
System Requiremets Phase 1
Architectural Design 2
Detail Design 2
Implementation 16
Testing 1

系统需求阶段

SR1:确认花费在系统健壮性上的时间

创建健壮的系统需要花费时间和资源。所以,开发者和客户需要对项目进行评估,开始就要知道健壮性对于该项目的重要程度,根据健壮性对系统的重要程度决定分配开发资源的多少。如果违背该原则,开发者可能陷入两种情况,一是客户对开发的项目满足用户需要但是用户并不满意,二是开发的项目花费了大量的时间和精力却不是用户所需。

架构设计阶段

AD1:定义风险社区和安全面

系统架构师应该定义整个系统应该如何处理异常状况。如果架构师没有做这个工作,那么这些工作将转嫁到每个程序员身上。定义风险社区和安全面能够让所有参与开发的人知道,哪个模块应该处理哪种异常(或者说那个异常应该被哪个模块处理)。

AD2:创建系统范围的用户通知系统

用户通知集中处理,否则通知用户的错误消息将分散在代码的各个角落,你将在大量代码间搜索。优秀的用户界面中传递给用户的错误信息是风格一致、信息丰富、可被用户理解的。

详细设计阶段

DD1:为每个模块,定义可能的风险

找到模块依赖于哪些外部代码,设想外部依赖终止工作时发生什么风险。风险指的是组件的程序员所不了解的情况——对于组件程序员而言,所发生的风险是无法处理的。风险各种各样,从编程错误到数据库不可访问,再到邻居系统崩溃。

DD2:定义每个模块对非预期事件如何处理

除了模块不可预知的外部依赖导致的风险,还存在模块内部可能发生的特定问题。对于这些问题,并不是所有的问题都能被所有的模块所处理,有些模块可能无法处理某些问题,有些问题甚至可能无法被任何模块处理。你必须将这些问题区分开来,清楚的定义出哪些问题是模块可能解决的,哪些是无法解决需要传递给安全面的(参考规则AD1)。确定模块能够处理哪些问题,不能处理哪些问题,非常重要。

实现阶段

IM1:不要将异常用于控制程序流程

异常处理机制可以用于程序控制流程。但是,这种做法让代码难于阅读、难以分析、运行更慢,如果出现真正的异常,可能导致无法理解的情况发生。异常应该,也只应该像他的名字一样,只用于异常情况的处理。

int data;
MyInputStream in = new
MyInputStream(“filename.ext”);
while(true){
   try{ // try...catch 多次执行,降低程序性能
     data = in.getData();
   } catch(NoMoreDataException e){
     break;
   }
}

性能提示:不要再循环中使用异常,会降低程序性能。应该在循环外添加try...catch子句。

IM2:需要使用异常时一定要使用异常

有的语言没有Java这么强大的异常处理机制,程序员不得不通过信号传递来表明异常发生。比如有些语言中通过返回空指针、-1的无符号数或者空字符串来表示异常信息。但是,Java中并不需要这么麻烦,也不要这么做。如果你使用这种方法表示异常发生,那么不了解该处理方式的方法调用者会按照自己的预期处理。记住,异常是方法接口的一部分(方法签名是接口,方法内代码是方法的实现),通过在方法签名中声明异常,就可以告知并且强制方法的调用者知道异常的发生,从而其可以考虑异常的处理。

IM3:不要通过普通异常Exception捕获异常

一个方法可能会抛出多个不同的异常,有些程序员喜欢将所有异常汇总,直接使用普通异常Exception来捕获各种异常——特别是,如果这几个异常的处理方式是相同的,他们更会这么做。一定要拒绝引诱,防止自己创建出吞下所有被抛出的异常地方怪物。这种做法会导致一些错误信息的丢失或者错误信息被放大而不能听具体的信息。

IM4:不要抛出普通异常Exception

如果你将抛出的异常汇总,直接使用Exception,方法调用者就无法知道究竟是哪里出现了问题,为什么不对了,这样他就不能知道自己应该做些什么来处理异常。此外,如果你同时违背规则IM2,也就是不在方法声明中添加异常声明,这样问题就更多了。所以,如果没有异常匹配你的标准异常库(比如语言和工具库预定义的异常),那就创建自定义异常。但是,千万不要抛出Throwable、Exception或者RuntimeException类。

IM5:抛出异常前保证对象/资源状态的正确性

在try子句后总是添加finally块来进行清理工作(比如资源释放),这样就能保证对象状态的一致(无论是否发生异常)。如果违法这条规则,比如资源没有得到释放,那么在发生异常且异常被处理后,很有可能会再次发生并抛出异常。除了资源未被正常释放的情况,另一种情况是异常发生中影响的对象被其他对象所依赖,如果该对象的没有在异常发生后恢复到预期状态,那么就会发生问题。这种情况很可能导致异常金字塔的产生。

class Foo{
   private int numElements;
   private MyList myList;
   public void add(Object o) throws SomeException{
     //…
     numElements++; //1
     if(myList.maxElements() < numElements) {
       // 增大myList的容量
       // 必要时复制元素
       // 可以抛出异常吗?
     }
     myList.addToList(o); // 这里可以抛出异常吗? 
     //2
   }
}

如果在1抛出异常,则foo对象中的numElementsmyList的值的实际情况(numElements增加1,但是对象还未添加到myList中)与异常定义的情况(myList的元素超过最大数量限制)不符。然而,在2处抛出异常,则异常定义的情况与实际情况一致,所以应该在2处抛出异常。

IM6:为相关异常集合创建抽象父类

经常的回顾代码中已近存在的异常的使用情况,为使用相同处理动作但是不同的异常子类集合引入一个抽象父类。这样,调用者就可以在 catch 代码块中使用一个单独的抽象父类而不用同时捕获所有的单独异常类。使用父类抽象抛出的特定具体异常。

IM7:不要让 catch 代码块留空

只有你能够捕获到异常的时候才去捕获,并且同时处理它。如果你没有能力处理该异常,那么久抛出它,但是千万不要忽略它。让 catch 代码块留空会让方法调用者认为不存在该异常或者程序理解为异常已经被处理过了,但是实际上该异常并没有被处理。忽略异常就意味着丢失重要信息,发生的异常你甚至都没有记录。(参考代码 7.2.4)

IM8:使用运行时异常标识编程错误

当错误和异常(Throwable)发生时,如果该错异是方法调用者无法处理的情况,应该使用运行时异常封装异常。这样,保证了这个错误会被捕获和处理,同时不强制方法调用者捕获该异常或者在方法调用者的方法声明中声明该异常。无需处理运行时异常,因为你无法从这种异常中恢复。

IM9:使用编译时检查异常来处理可恢复的异常

如果方法的调用者有着一种期望,认为你的方法能够从发生的异常中恢复过来,那么就应该捕获检查异常。检查异常也可以用于方法无法满足其定义时:方法定义(协议:方法签名定义了方法调用者与方法间的协议)包含了前置条件和后置条件,前置条件是方法调用者必须提供并满足的,后置条件是方法自身必须满足的。

IM10:在含有 finally 子句的 try 子句中要谨慎使用 retun 结构

记住,finally 代码块总会执行,无论 try 子句中是否发生异常。所以,如果在 try 子句中包含 return 结构(影响返回值的结构,比如 return、break、continue语句)时要注意,因为 finally 子句会产生一个返回值从而覆盖之前的返回值。要避免这种情况发生,就尽量不要再 try 子句中使用 return、break、continue 语句,或者确保 finally 子句不会覆盖方法的返回值。

IM11:抛出相同异常的语句不要使用同一个try代码块捕获

当一些语句抛出相同的异常时,无法知道是哪条语句抛出了异常。如果你把每条语句放到不同的try代码块中,调试起来会更加方便。当异常发生时,你就能知道在哪里以及哪条语句产生了该异常。

IM12:尽量使用Java提供的标准异常减少创建新的异常

使用标准异常会让其他人更加容易理解你的代码,因为这些异常是所有人都熟悉的。此外,标准异常还得到较好的文档化,这样你就不需要再编写文档啦。如果真的没有合适的标准异常,那时你才定义你自己的。

IM13:不要传播特殊的异常

在方法的上下文中抛出的异常是能够被理解的。假设有个方法能够抛出一组异常。把所有这些异常都抛出去是不合理的,但是如果其中抛出的一个过早的绑定到一个实现上。高层会捕获低层的异常,并会用更具解释性的内容来向更高层抛出异常。这叫做异常的翻译。

IM14:翻译异常时保留异常链

当你要翻译异常时,记得要将旧异常包含到新异常中。这样,当异常抛到最外层/高层时,就能能够通过异常链追溯到最底层的异常,第一个发生的异常所在。这使得异常的跟踪十分方便。调试起来也十分方便,因为整个追踪栈都是完整的。

IM15:当构造器失败时抛出异常

当初始化一个对象失败时,你唯一可以做的就是抛出异常。吞下构造器中的异常会导致存活的对象没有完全初始化而被误用。这些对象的行为是不可预知的。

IM16:为异常编写良好的文档说明

使用Javadoc标记@throws来表示方法声明中的每个异常。每个条目清晰精确的说明异常发生的条件。良好文档化
的异常会让异常的处理更加容易。通常只有编写该异常的开发者理解该异常,但是对异常有了良好的文档说明,其他人也能理解。

测试阶段

TE1:使用全局标识开关调试信息

许多 catch 代码块会包含调试信息,通常的调试代码以 ex.printStackTrace() 的形式给出。这些调试信息不应该出现在发布的产品中。通过使用全局标识就能将调试代码和生产代码分离开。这样,就可以在测试之后关闭调试信息开关,不用在数千行代码中删除调试代码,而且可以在之后需要的时候再开启调试信息输出。

WORDS

item meaning
user/client 用户 方法的调用者
caller 调用者 方法的调用者
callee 被调用者 被调用的方法
exception 异常 程序异常,对应Java中的Exception类
runtime exception 运行时异常 Exception类的特例,无法恢复
error 错误 系统错误,无法恢复
checked exception 检查异常 编译期间检查的异常,该类异常需要被处理
unchecked exception 未检查异常 编译期间不进行检查的异常,该类异常不强制处理
risk community 风险社区 由可能产生错误/异常的方法调用者和被调用者组成的整体,表明异常流是在社区(方法)间传递的
safety facade 安全面 系统架构阶段确定的,是整个系统最后一层异常处理防线