Java 异常处理的最佳实践
译者注:这是一篇 2003 年的文章,因为时间久远,可能有些观点已经过时,但里面讨论的大部分方法如今仍能适用。如若有其他好的错误处理的方法,欢迎留言。
异常处理的关键就在于知道何时处理异常以及如何使用异常。这篇文章,我会提到一些最佳的异常处理方法。我也会总结 checked exception 的用法。
我们程序员都想写出高质量的代码来解决问题。但是,异常有时会给我们的代码带来副作用。没有人喜欢副作用,所以我们很快找到了方法来改善它们。我看见过许多聪明的程序员这样来处理异常:
public void consumeAndForgetAllExceptions (){ try { ...some code that throws exceptions } catch (Exception ex){ ex.printStacktrace (); } }
上面的代码有什么错误?
当异常被抛出后,正常的程序执行过程中断,控制权交给 catch 段,catch 段会 catch 异常,然后抑制异常的进一步扩大。然后接着 catch 段之后程序继续执行,好像什么都没发生过一样。
下面的代码呢?
public void someMethod () throws Exception{ }
这个方法内没有代码,是个空方法。一个空方法怎么能抛出异常呢?Java 并没有说不让这么做。最近,我遇到过类似的代码,方法抛出了异常,而其中的代码实际上并不产生那个异常。当我问这个程序员为何要这么做,他回答道“我知 道,虽然这样做破坏了 API,但我习惯这么做,而且这样也可行。”
C++社区用了许多年才确定如何使用异常机制。这个争论刚刚在 Java 社区展开。我见到一些 Java 程序员正在和异常进行顽强抗争。如果用法不当的话,会拖慢程序,因为创建、抛出和接住异常都会占用内存。如果过多的使用异常的话,代码会变得很难阅读,对 要使用 API 的程序员来说无疑会增加挫败感。我们知道挫败感会令我们写出很烂的代码。有的程序员会刻意回避这个问题,忽略异常或随意抛出异常,就像上面的两个例子一 样。
异常的本质
广义的讲,抛出异常分三种不同的情况:
- 编程错误导致的异常:在这个类别里,异常的出现是由于代码的错误(譬如 NullPointerException 和 IllegalArgumentException)。代码通常对编程错误没有什么对策。
- 客户端的错误导致的异常:客户端代码试图违背制定的规则,调用 API 不支持的资源。如果在异常中显示有效信息的话,客户端可以采取其他的补救方法。例如:解析一个格式不正确的 XML 文档时会抛出异常,异常中含有有效的信息。客户端可以利用这个有效信息来采取恢复的步骤。
- 资源错误导致的异常:当获取资源错误时引发的异常。例如,系统内存不足,或者网络连接失败。客户端对于资源错误的反应是视情况而定的。客户端可能一段时间之后重试或者仅仅记录失败然后将程序挂起
Java 异常的类型
Java 定义了两种异常
- Checked exception: 继承自 Exception 类是 checked exception。代码需要处理 API 抛出的 checked exception,要么用 catch 语句,要么直接用 throws 语句抛出去。
- Unchecked exception: 也称 RuntimeException,它也是继承自 Exception。但所有 RuntimeException 的子类都有个特点,就是代码不需要处理它们的异常也能通过编译,所以它们称作 unchecked exception。
图 1 显示了 NullpointerException 的继承级别。
图 1 异常等级实例
NullpointerException 继承自 RuntimeException,所以它是个 unchecked exception。
我看到人们大量使用 checked exception 的,而很少看到 unchecked exception 的使用。近来,在 Java 社区里对 checked exception 和它的真正价值的争论愈演愈烈。这主要因为 Java 是第一个使用 checked exception 的主流面向对象语言。C++和 C# 都没有 checked exception,所有的异常都是 unchecked。
低层次抛出的 checked exception 对高层次来说,必须要 catch 或者 throw 它们。这样如果不能有效处理异常的话,checked exception 就在 API 和代码之间造成了一直负担。程序员就开始写一些空的 catch 代码段,或者仅仅抛出异常,实际上,给客户端的触发者来说增加了负担。
Checked exception 也被诟病破坏了封装性。看看下面的代码:
public List getAllAccounts () throws
FileNotFoundException, SQLException{
...
}
getAllAccounts ()抛出了两个 checked exception。这个方法的调用者就必须处理这两个异常,尽管它也不知道在 getAllAccounts 中什么文件找不到以及什么数据库语句失败,也不知道该提供什么文件系统或者数据库的事务层逻辑。这样,异常处理就在方法调用者和方法之间形成了一个不恰当 的紧耦合。
设计 API 的最佳实践
说了这么多,让我们来说说如何设计一个好的 API,能够正确抛出异常的。
1. 当要确定是使用 checked exception 还是 unchecked exception 时,首先问问自己,当异常发生时客户端如何应对?
如果客户端可以从异常中采取行动进行恢复的,就使用 checked exception,如果客户什么也做不了,就用 unchecked exception。我指的是,不仅仅是记录异常,还要采取措施来恢复。
还有,我更喜欢 unchecked exception,因为不需要强迫客户端 API 必须处理它们。它们会进一步扩散,直到你想 catch 它们,或者它们会继续扩散爆出。Java API 有许多 unchecked exception 如 NullPointerException, IllegalArgumentException 和 IllegalStateException。我更愿意用这些 Java 定义好的异常类,而非我们自己创建的异常类。它们使我们的代码易读,也避免代码消耗更多内存。
2. 保持封装性
不要将针对某特定实现的 checked exception 用到更高的层次中去。例如,不要让 SQLException 扩散到逻辑层去。因为逻辑层是不需要知道 SQLException。你有两种选择:
- 如果你的客户端有应对措施的话,将 SQLException 转化成另一个 checked exception。
- 如果你的客户端什么也做不了的话,将 SQLException 转化成一个 unchecked exception。
但大部分情况是,客户端对 SQLException 无能为力。那请将 SQLException 转换成 unchecked exception 吧。来看下面的代码:
public void dataAccessCode (){ try{ ..some code that throws SQLException }catch(SQLException ex){ ex.printStacktrace (); } }
上面的 catch 段仅仅抑制了异常,什么也没做。这是因为客户针对 SQLException 无计可施。何不使用下面的方法呢?
public void dataAccessCode (){ try{ ..some code that throws SQLException }catch(SQLException ex){ throw new RuntimeException (ex); } }
将 SQLException 转换成 RuntimeException。如果 SQLException 发生时,catch 语句抛出一个新的 RuntimeException 异常。正在执行的线程会挂起,异常爆出来。然而,我并没有破坏逻辑层,因为它不需要进行不必要的异常处理,尤其是它根本不知道怎么处理 SQLException。如果 catch 语句需要知道异常发生的根源,我可以用 getCause ()方法,这个方法在 JDK1.4 中所有异常类中都有。
如果你确信逻辑层可以采取某些恢复措施来应对 SQLException 时,你可以将它转换成更有意义的 checked exception。但我发现仅仅抛出 RuntimeException,大部分时间里都管用。
3. 如果自定义的异常没有提供有用的信息的话,请不要创建它们。
下面的代码有什么错误?
public class DuplicateUsernameException extends Exception {}
它没有给出任何有效的信息,除了提供一个异常名字意外。不要忘了 Java 异常类就像其他的类一样,当你在其中增加方法时,你也可以调用这些方法来获得更多信息。
我们可以在 DuplicateUsernameException 中增加有效的方法,例如:
public class DuplicateUsernameException extends Exception { public DuplicateUsernameException (String username){....} public String requestedUsername (){...} public String[] availableNames (){...} }
新版本的 DuplicateUsernameException 提供两个方法:requestedUsername ()返回请求的姓名,availableNames ()返回与请求姓名相类似的所有姓名的一个数组。客户端可以知道被请求的姓名已经不可用了,以及其他可用的姓名。如果你不想获得其他的信息,仅仅抛出一个 标准的异常即可:
throw new Exception ("Username already taken");
如果你认为客户端不会采取任何措施,仅仅只是写日志说明用户名已存在的话,抛出一个 unchecked exception:
throw new RuntimeException ("Username already taken");
另外,你甚至可以写一个判断用户名是否已经存在的方法。
还是要重复一遍,当客户端的 API 可以根据异常的信息采取有效措施的话,我们可以使用 checked exception。但对于所有的编程错误,我更倾向于 unchecked exception。它们让你的代码可读性更高。
4. 将异常文档化
你可以采用 Javadoc’s @throws 标签将你的 API 抛出的 checked 和 unchecked exception 都文档化。然而,我更喜欢写单元测试。单元测试可看作可执行的文档。无论你选择哪一种方式,都要让客户端使用你的 API 时清楚知道你的 API 抛出哪些异常。下面是针对 IndexOutOfBoundsException 的单元测试:
public void testIndexOutOfBoundsException () { ArrayList blankList = new ArrayList (); try { blankList.get(10); fail ("Should raise an IndexOutOfBoundsException"); } catch (IndexOutOfBoundsException success) {} }
当调用 blankList.get (10)时,上面的代码会抛出 IndexOutOfBoundsException。如果不是如此的话,fail (“Should raise an IndexOutOfBoundsException”)会显式的让测试失败。通过写单元测试,你不仅记录了异常如何运作,也让你的代码变得更健壮。
使用异常的最佳实践
下面的部分我们列出了客户端代码处理 API 抛出异常的一些最佳实现方法。
1. 记得释放资源
如果你正在用数据库或网络连接的资源,要记得释放它们。如果你使用的 API 仅仅使用 unchecked exception,你应该用完后释放它们,使用 try-final。
public void dataAccessCode (){ Connection conn = null; try{ conn = getConnection (); ..some code that throws SQLException }catch(SQLException ex){ ex.printStacktrace (); } finally{ DBUtil.closeConnection (conn); } } class DBUtil{ public static void closeConnection (Connection conn){ try{ conn.close (); } catch(SQLException ex){ logger.error ("Cannot close connection"); throw new RuntimeException (ex); } } }
DBUtil 是一个关闭连接的工具类。最重要的部分在于 finally,无论异常发不发生都会执行。在这个例子中,finally 关闭了连接,如果关闭过程中有问题发生的话,会抛出一个 RuntimeException。
2. 不要使用异常作控制流程之用
生成栈回溯是非常昂贵的,栈回溯的价值是在于调试。在流程控制中,栈回溯是应该避免的,因为客户端仅仅想知道如何继续。
下面的代码,一个自定义的异常 MaximumCountReachedException,用来控制流程。
public void useExceptionsForFlowControl () { try { while (true) { increaseCount (); } } catch (MaximumCountReachedException ex) { } //Continue execution } public void increaseCount () throws MaximumCountReachedException { if (count >= 5000) throw new MaximumCountReachedException (); }
useExceptionsForFlowControl()使用了一个无限的循环来递增计数器,直至异常被抛出。这样写不仅降低了代码的可读性,也让代码变得很慢。记住异常仅用在有异常发生的情况。
3. 不要忽略异常
当一个 API 方法抛出 checked exception 时,它是要试图告诉你你需要采取某些行动处理它。如果它对你来说没什么意义,不要犹豫,直接转换成 unchecked exception 抛出,千万不要仅仅用空的{}catch 它,然后当没事发生一样忽略它。
4. 不要 catch 最高层次的 exception
Unchecked exception 是继承自 RuntimeException 类的,而 RuntimeException 继承自 Exception。如果 catch Exception 的话,你也会 catch RuntimeException。
try{ .. }catch(Exception ex){ }
上面的代码会忽略掉 unchecked exception。
5. 仅记录 exception 一次
对同一个错误的栈回溯(stack trace)记录多次的话,会让程序员搞不清楚错误的原始来源。所以仅仅记录一次就够了。
总结
这里是我总结出的一些异常处理最佳实施方法。我并不想引起关于 checked exception 和 unchecked exception 的激烈争论。你可以根据你的需要来设计代码。我相信,随着时间的推移,我们会找到些更好的异常处理的方法的。
原文: onjava.com 编译:伯乐在线 – 唐小娟