Java 异常处理

最近在看Effective Java 看到了更异常相关的章节,虽然里面道理都看的懂,但是一旦要自己操作起来的时候就懵逼了,后来发现了一篇博文感觉里面写的还不错,现在翻译出来如下,附上原文的链接 Desiging with exceptions

异常的好处

异常有不少优点,首先它可以让你将异常处理的代码和正常的代码分离,在99.9%的情况下,你可以用try 代码块来包裹你想执行的代码,然后将异常处理的代码放在catch代码块中,这样的写法将避免使你的代码看起来杂乱无章。

如果你觉得一个方法无法处理一个特定的错误,那么你可以通过抛出一个异常,让其他人来处理这个问题。如果你抛出了一个编译时期的异常,那么Java编译器将迫使调用者来处理指定的异常,要么通过try catch ,要么调用者自己也声明抛出异常,Java编译器对编译期间异常的强制处理,使得Java程序更加的健壮。

什么时候抛异常

什么时候应该抛出一个异常呢? 可以用下面的一句话来总结

如果你的方法遇到了无法处理的异常条件,那么抛出一个异常

(看到这句话一定会想MDZZ….)

不幸的是,尽管这一句总结很好记忆,但是它并没有让你更清楚的明白这个问题,它实际上引出了另外一个问题:什么是异常条件?

事实上判断一个特定的事件是不是一个异常条件是一个主观上的问题,这个判断并不是总是清晰。

一个更加有用的总结应该是下面这句:

在可以正常处理逻辑的情况下,不要使用异常(原文:Avoid using exceptions to indicate conditions that can reasonably be expected as part of the typical functioning of the method.)

因此不正常的条件可以理解为一个方法中无法正常处理的部分。为了更好的理解这句话,请看下面的例子,

示例

作为示例,请看java.io中的两个类,FileInputStream 和 DataInputStream ,下面这段代码用FileOutputStream 来输出一个文本文件。

import java.io.*;
class Example9a {
    public static void main(String[] args)
        throws IOException {
        if (args.length == 0) {
            System.out.println("Must give filename as first arg.");
            return;
        }
        FileInputStream in;
        try {
            in = new FileInputStream(args[0]);
        }
        catch (FileNotFoundException e) {
            System.out.println("Can't find file: " + args[0]);
            return;
        }
        int ch;
        while ((ch = in.read()) != -1) {
            System.out.print((char) ch);
        }
        System.out.println();
        in.close();
    }
}

在这个示例中,read()方法没有通过使用异常来表示读取文件已经到达了文件的末尾,而是通过返回了一个特定的值 -1, 在这个方法中,到达文件的末尾是一种正常的使用场景。正常读取字节的方式就是一直读直到读到文件的末尾。

再来看看DataInputStream 这个类,采用不同的方式来表明到达了文件的末尾。

import java.io.*;
class Example9b {
    public static void main(String[] args)
        throws IOException {
        if (args.length == 0) {
            System.out.println("Must give filename as first arg.");
            return;
        }
        FileInputStream fin;
        try {
            fin = new FileInputStream(args[0]);
        }
        catch (FileNotFoundException e) {
            System.out.println("Can't find file: " + args[0]);
            return;
        }
        DataInputStream din = new DataInputStream(fin);
        try {
            int i;
            for (;;) {
                i = din.readInt();
                System.out.println(i);
            }
        }
        catch (EOFException e) {
        }
        fin.close();
    }
}

每次调用readInt的时候,它从流中读取四个字节,当读取到文件的末尾的时候,抛出了一个EOFException, 对于这个方法来说,抛出异常是一个合理的选择,原因如下

  • 首先,readInt()不能返回一个特定的值,因为任何一个数字都可能是一个合理的返回值,因此无法用一个特殊的数字来表明读取到了文件的末尾。
  • 第二,如果readInt读到了文件的末尾,但是只有三个字节,因此抛出一个编译期异常让调用者来处理是使用这个类的一部分。因此这个类的设计者抛出了一个编译期异常让迫使调用者来处理这个异常。

还有另外一种方法来表明”数据已经到达了结尾”,下面看StringTokenizer 和Stack 的例子。

import java.io.*;
import java.util.*;
class Example9c {
    public static void main(String[] args)
        throws IOException {
        if (args.length == 0) {
            System.out.println("Must give filename as first arg.");
            return;
        }
        FileInputStream in = null;
        try {
            in = new FileInputStream(args[0]);
        }
        catch (FileNotFoundException e) {
            System.out.println("Can't find file: " + args[0]);
            return;
        }
        // Read file into a StringBuffer
        StringBuffer buf = new StringBuffer();
        try {
            int ch;
            while ((ch = in.read()) != -1) {
                buf.append((char) ch);
            }
        }
        finally {
            in.close();
        }
        // Separate StringBuffer into tokens and
        // push each token into a Stack
        StringTokenizer tok = new StringTokenizer(buf.toString());
        Stack stack = new Stack();
        while (tok.hasMoreTokens()) {
            stack.push(tok.nextToken());
        }
        // Print out tokens in reverse order.
        while (!stack.empty()) {
            System.out.println((String) stack.pop());
        }
    }
}

在上面这个例子中,从一个文件中读取字节,然后将字节转换为char, 放到一个StringBuffer中,之后让StringTokenizer 使用默认的分隔符来分割字符串,并且将字符串放入到一个栈中, 下一步是用pop操作将栈中数据全部取出,因为栈是一个FILO结构,数据将会倒序输出。StringTokenizer 和Stack 都有一个条件来判断数据是否已经到达末尾。每次调用 nextToken的时候都会返回一个String, 当所有的String 对象都被返回的时候,StringTokenizer 必须表明所有的数据都被取完了,在这个示例中,null 本来可以被用作一个数据已经被取完的返回值,但是这个类的设计者采用了另外一种方式,用 hasMoreTokens() 这个方法来判断当前是否还有数据未被取完,在每次调用nextToken之前,你都需要调用hasMoreTokens() 方法。

在这个设计之中,类的设计者认为到达数据结构的末尾是一种正常的情况(原文:This approach shows that the designer did not consider reaching the end of tokens an abnormal condition, 个人理解:数据结构为空实际上是一种正常的情况,但是从一个为空的数据结构中获取数据则是处于不正常的条件,在后面会马上提到已经实际上是违反了使用这个方法的约定),如果你在在使用nextToken之前没有检查hasMoreTokens(), 你会遇到 NoSuchElementException,尽管这个是在已经到达数据结构末尾这种情况下抛出的,它更多的是表明了你使用这个类的方式不对, 而不是表示没有更多数据了。

类似的,Stack 同样有一个方法 empty(), 它表示栈中是否还有其他的元素,在你使用pop方法之前,你都需要使用empty 来判断栈中是否还有元素,如果你在使用pop()之前没有调用empty(), 如果栈中没有了其他的元素,那么你将遇到 EmptyStackException(), 尽管这个异常只会在栈为空的情况下发生,但是这个运行时异常更多的是用来表明客户端代码中出了问题,而不是提示你这个栈为空。

异常实际上表明违反了使用约定

上面的例子可能会让你觉得,你应该使用异常来传递一个信息而不是用其他的方式。如果从另外一个角度来思考异常,可能会让你更加清楚什么时候应该使用他们

异常实际上表明了违反了使用的约定 (原文:Exceptions indicate a broken contract)

在面向对象编程中,我们经常谈到的一点是Design by Contract approach. 在软件设计中,一个方法实际上代表了调用者和设计者 之间的一种约定,这种约定包括调用者必须满足前置条件,而方法本身必须满足后置条件(个人理解:调用者使用方法的时机应该正确,方法的参数应该传对,而方法本身应该能按照约定正常的处理输入然后给出输出)

前置条件

以String对象的charAt(int index) 为例,这个方法要求index 必须在 0 和 length -1 之间。 例如string的长度为5, 那么你的输入应该是 0 - 4.

后置条件(Postcondition)
String chatAt方法的后置条件是 返回index指定位置的字符,并且string本身保持不变。

如果调用者传入了 -1 或者大于等于 length 的参数,那么实际上是调用者 违反了约定,那么这个时候会抛出一个 StringIndexOutOfBoundsException , 这个异常表明调用者代码有bug。

如果charAt 方法本身接收到了一个合法的输入,但是由于这个方法本身无法返回指定位置的字符(无法满足后置条件),同样应该抛出异常,这个异常表明了这个方法有bug或者无法处理运行时资源。

What to Throw

一旦你决定要抛出一个异常,那么你需要选择应该抛出哪一种异常。

Exceptions VS errors

一般说来你应该要使用异常而不是Error, Error 也是 Throwable的子类。有时表明JVM出了某些问题例如 OOM Error,有使用JAVA API会抛出错误,例如 java.awt.AWTError, 但是在你的代码中,应该只是用异常。

编译期异常 VS 运行期异常

这个问题才是一个大问题,到底是编译期异常还是运行时异常,编译期是Exception 及其子类,但是子类部分中要把RuntimeException 和 其子类排除,Error 及其子类同样也不是编译期异常。

如果你抛出了一个编译期异常但是并没有捕获,那么你需要在你的方法声明中加上throws, 这样调用者在使用你的代码时必须使用try catch 来处理异常或者将他自己的方法添加异常的声明,使用编译期异常将迫使调用者来处理方法可能抛出的异常。

如果你抛出的是一个运行期异常,那么调用者可以使用try catch 来捕获异常或者直接忽略掉异常。编译期将不会迫使调用者去处理这个异常,实际上调用者可能都不知道这个方法会抛出哪些异常。

简单的区别方式如下:

如果你抛出了一个异常,并且你觉得客户端程序员可以有意识的处理这个异常,那么应该使用编译期异常

一般来说,如果异常是想表明类的使用方式出了问题,那么这个异常因该是运行期异常。String charAt 抛出的StringIndexOutOfBoundsException 是运行期异常。String 的设计者不希望迫使调用者每次使用charAt的时候都去处理可能的错误。

但是 java.io.FileInputStream 的 read方法 抛出一个编译期异常,这个异常表明在读取文件的期间出现了某些问题,这个问题并不是由于客户端使用的方式不对而引起,它只是表明这个方法本身无法按照约定从文件中读取字节,类的设计者认为这种这种不正常的条件是十分普遍的。并且住够重要让调用者去处理这种情况。

通过上面的对比,基本上可以得出以下的结论,如果异常条件的发生是方法本身无法满足约定并且重要程度足够到需要调用者去处理,那么这个时候需要抛出一个编译器异常,迫使调用者去处理,否则抛出一个运行期异常。

定义具体的异常

最终你需要决定使用哪个异常类,在这里规则非常的具体,不要直接使用Exception,你应该选择现有的已经存在的可以说明你异常情况的异常类。这样调用者可以有选择的处理各种异常情况。

同时你可以在这些异常中嵌入你想添加的信息,但是不要靠这些信息来区分发生了什么异常。

总结

这篇文章最重要的一点在于,异常是用来处理不正常的条件的,当程序可以用一个正常的返回值来标志时,就不应该使用异常。 尽管异常可以可以帮助你去将异常处理代码和处理正常逻辑的代码区分开,但是不恰当的使用只会让你的代码更加难以阅读。