首页 > 代码库 > 【译】异常处理的最佳实践

【译】异常处理的最佳实践

译注:这是一篇2003年的文章,因为时间久远,可能有些观点已经过时,但里面讨论的大部分方法如今仍能适用。

 

Best Practices for Exception Handling

 

异常处理的重要一点就在于知道何时处理异常以及如何使用异常。在这篇文章里,我会提到一些异常处理的最佳实践,我也会总结checked exception的用法。

我们程序员都想写出高质量的代码来解决问题。不幸的是,异常会给我们的代码带来副作用。没有人喜欢副作用,所以我们很快找到了方法来改善它们。我看见过许多聪明的程序员这样来处理异常:

?
1
2
3
4
5
6
7
public void consumeAndForgetAllExceptions(){
    try {
        ...some code that throws exceptions
    } catch (Exception ex){
        ex.printStacktrace();
    }
}<br><br>

上面的代码有什么错误?

当异常被抛出后,正常的程序执行过程中断,控制权交给catch块。catch块会捕获异常,然后抑制异常的进一步扩大。接着catch块之后的程序继续执行,就好像什么都没发生过一样。

下面的代码呢?

?
1
2
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的继承级别。

 

 

NullpointerException继承自RuntimeException,所以它是个unchecked exception。

 

我看到人们大量使用checked exception的,而很少看到unchecked exception的使用。近来,在Java社区里对checked exception和它的真正价值的争论愈演愈烈。这主要因为Java是第一个使用checked exception的主流面向对象语言。C++和C#都没有checked exception,所有的异常都是unchecked。

低层次抛出的checked exception,调用层必须要catch或者throw它们。如果客户端代码不能有效处理异常的话,API和代码之间的checked exception很快变成一种负担。客户端代码程序员可能开始写一些空的catch代码块,或者仅仅抛出异常,实际上给客户端的调用者增加了负担。

 

Checked exception也被诟病破坏了封装性。看看下面的代码:

?
1
2
3
4
public List getAllAccounts() throws
    FileNotFoundException, SQLException{
    ...
}

getAllAccounts()抛出了两个checked exception。这个方法的调用者就必须处理这两个异常,尽管它也不知道在getAllAccounts中什么文件找不到以及什么数据库语句失败,也不知道该提供什么文件系统或者数据库的事务层逻辑。这样,异常处理就在方法调用者和方法之间形成了一个不恰当的紧耦合。

 

设计API的最佳实践

说了这么多,让我们来说说如何设计一个好的API,能够正确抛出异常的。

1. 当决定使用checked exception还是unchecked exception时,首先问问自己,"当异常发生时客户端会怎么应对?"

如果客户端可以从异常中采取行动进行恢复的,就使用checked exception。如果客户端不能采取任何有效措施,就用unchecked exception。有效措施指的是,不仅仅是记录异常日志,还要采取措施来恢复,总结图如下:

Client‘s reaction when exception happens Exception type
Client code cannot do anything Make it an unchecked exception
Client code will take some useful recovery action based on information in exception Make it a checked 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吧。来看下面的代码:

?
1
2
3
4
5
6
7
public void dataAccessCode(){
    try{
        ..some code that throws SQLException
    }catch(SQLException ex){
        ex.printStacktrace();
    }
}

上面的catch段仅仅抑制了异常,什么也没做。这是因为客户针对SQLException无计可施。何不使用下面的方法呢?

?
1
2
3
4
5
6
7
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. 如果自定义的异常没有提供有用的信息的话,请不要创建它们。

下面的代码有什么错误?

?
1
2
public class DuplicateUsernameException
    extends Exception {}

它没有给出任何有效的信息,除了提供一个异常名字意外。不要忘了Java异常类就像其他的类一样,当你在其中增加方法时,你也可以调用这些方法来获得更多信息。

我们可以在DuplicateUsernameException中增加有效的方法,例如:

?
1
2
3
4
5
6
7
public class DuplicateUsernameException
    extends Exception {
    public DuplicateUsernameException
        (String username){....}
    public String requestedUsername(){...}
    public String[] availableNames(){...}
}

新版本的DuplicateUsernameException提供两个方法:requestedUsername()返回请求的姓名,availableNames()返回与请求姓名相类似的所有姓名的一个数组。客户端代码可以知道被请求的姓名已经不可用了,以及其他可用的姓名。如果你不想获得其他的信息,仅仅抛出一个标准的异常即可:

?
1
throw new Exception("Username already taken");

更好的做法是,如果你认为客户端代码不会采取任何措施,仅仅只是写日志说明用户名已存在的话,抛出一个unchecked exception:

?
1
throw new RuntimeException("Username already taken");

另外,你甚至可以写一个判断用户名是否已经存在的方法。

还是要重复一遍,当客户端的API可以根据异常的信息采取有效措施的话,我们可以使用checked exception。但对于所有的编程错误,我更倾向于unchecked exception,它们让你的代码可读性更高。

4. 将异常文档化

你可以采用Javadoc’s @throws标签将你的API抛出的checked exception和unchecked exception都文档化,不过我更喜欢写单元测试,单元测试可看作可执行的文档,允许我看到执行中的异常。无论你选择哪一种方式,都要让客户端使用你的API时清楚知道你的API抛出哪些异常。下面是针对IndexOutOfBoundsException的单元测试:

?
1
2
3
4
5
6
7
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。

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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,用来控制流程。

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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。

?
1
2
3
4
try{
..
}catch(Exception ex){
}

上面的代码会也会忽略掉unchecked exception。

5. 日志记录exception一次

对同一个错误的栈回溯(stack trace)记录多次的话,会让程序员搞不清楚错误的原始来源。所以仅仅记录一次就够了。

总结

这里是我总结出的一些异常处理最佳实施方法。我并不想引起关于checked exception和unchecked exception的激烈争论。你可以根据你的需要来设计代码。我相信,随着时间的推移,我们会找到些更好的异常处理的方法的。

 

Related Resources

  • "Does Java need Checked Exceptions?" by Bruce Eckel
  • "Exceptional Java," by Alan Griffiths
  • "The trouble with checked exceptions: A conversation with Anders Hejlsberg, Part II" on Artima.com
  • "Checked exceptions are of dubious value," on C2.com
  • Conversation with James Gosling by Bill Venners