9.2 异常的捕获

若某个方法产生一个异常,必须保证该异常能被捕获,并获得正确对待。对于Java的异常控制机制,它的一个好处就是允许我们在一个地方将精力集中在要解决的问题上,然后在另一个地方对待来自那个代码内部的错误。

为理解异常是如何捕获的,首先必须掌握“警戒区”的概念。它代表一个特殊的代码区域,有可能产生异常,并在后面跟随用于控制那些异常的代码。

9.2.1 try块

若位于一个方法内部,并“掷”出一个异常(或在这个方法内部调用的另一个方法产生了异常),那个方法就会在异常产生过程中退出。若不想一个throw离开方法,可在那个方法内部设置一个特殊的代码块,用它捕获异常。这就叫作“try块”,因为要在这个地方“尝试”各种方法调用。try块属于一种普通的作用域,用一个try关键字开头:

try {
// 可能产生异常的代码
}

若用一种不支持异常控制的编程语言全面检查错误,必须用设置和错误检测代码将每个方法都包围起来——即便多次调用相同的方法。而在使用了异常控制技术后,可将所有东西都置入一个try块内,在同一地点捕获所有异常。这样便可极大简化我们的代码,并使其更易辨读,因为代码本身要达到的目标再也不会与繁复的错误检查混淆。

9.2.2 异常控制器

当然,生成的异常必须在某个地方中止。这个“地方”便是异常控制器或者异常控制模块。而且针对想捕获的每种异常类型,都必须有一个相应的异常控制器。异常控制器紧接在try块后面,且用catch(捕获)关键字标记。如下所示:

try {
  // Code that might generate exceptions
} catch(Type1 id1) {
  // Handle exceptions of Type1
} catch(Type2 id2) {
  // Handle exceptions of Type2
} catch(Type3 id3) {
  // Handle exceptions of Type3
}
// etc...

每个catch从句——即异常控制器——都类似一个小型方法,它需要采用一个(而且只有一个)特定类型的自变量。可在控制器内部使用标识符(id1,id2等等),就象一个普通的方法自变量那样。我们有时也根本不使用标识符,因为异常类型已提供了足够的信息,可有效处理异常。但即使不用,标识符也必须就位。

控制器必须“紧接”在try块后面。若“掷”出一个异常,异常控制机制就会搜寻自变量与异常类型相符的第一个控制器。随后,它会进入那个catch从句,并认为异常已得到控制(一旦catch从句结束,对控制器的搜索也会停止)。只有相符的catch从句才会得到执行;它与switch语句不同,后者在每个case后都需要一个break命令,防止误执行其他语句。 在try块内部,请注意大量不同的方法调用可能生成相同的异常,但只需要一个控制器。

中断与恢复

异常控制理论中,共存在两种基本方法。在“中断”方法中(Java和C++提供了对这种方法的支持),我们假定错误非常关键,没有办法返回异常发生的地方。无论谁只要“掷”出一个异常,就表明没有办法补救错误,而且也不希望再回来。

另一种方法叫作“恢复”。它意味着异常控制器有责任来纠正当前的状况,然后取得出错的方法,假定下一次会成功执行。若使用恢复,意味着在异常得到控制以后仍然想继续执行。在这种情况下,我们的异常更象一个方法调用——我们用它在Java中设置各种各样特殊的环境,产生类似于“恢复”的行为(换言之,此时不是“掷”出一个异常,而是调用一个用于解决问题的方法)。另外,也可以将自己的try块置入一个while循环里,用它不断进入try块,直到结果满意时为止。

从历史的角度看,若程序员使用的操作系统支持可恢复的异常控制,最终都会用到类似于中断的代码,并跳过恢复进程。所以尽管“恢复”表面上十分不错,但在实际应用中却显得困难重重。其中决定性的原因可能是:我们的控制模块必须随时留意是否产生了异常,以及是否包含了由产生位置专用的代码。这便使代码很难编写和维护——大型系统尤其如此,因为异常可能在多个位置产生。

9.2.3 异常规范

在Java中,对那些要调用方法的客户程序员,我们要通知他们可能从自己的方法里“掷”出异常。这是一种有礼貌的做法,只有它才能使客户程序员准确地知道要编写什么代码来捕获所有潜在的异常。当然,若你同时提供了源码,客户程序员甚至能全盘检查代码,找出相应的throw语句。但尽管如此,通常并不随同源码提供库。为解决这个问题,Java提供了一种特殊的语法格式(并强迫我们采用),以便礼貌地告诉客户程序员该方法会“掷”出什么异常,令对方方便地加以控制。这便是我们在这里要讲述的“异常规范”,它属于方法声明的一部分,位于自变量(参数)列表的后面。

异常规范采用了一个额外的关键字:throws;后面跟随全部潜在的异常类型。因此,我们的方法定义看起来应象下面这个样子:

void f() throws tooBig, tooSmall, divZero { //...

若使用下述代码:

void f() [ // ...

它意味着不会从方法里“掷”出异常(除类型为RuntimeException的异常以外,它可能从任何地方掷出——稍后还会详细讲述)。 但不能完全依赖异常规范——假若方法造成了一个异常,但没有对其进行控制,编译器会侦测到这个情况,并告诉我们必须控制异常,或者指出应该从方法里“掷”出一个异常规范。通过坚持从顶部到底部排列异常规范,Java可在编译期保证异常的正确性(注释②)。

②:这是在C++异常控制基础上一个显著的进步,后者除非到运行期,否则不会捕获不符合异常规范的错误。这使得C++的异常控制机制显得用处不大。

我们在这个地方可采取欺骗手段:要求“掷”出一个并没有发生的异常。编译器能理解我们的要求,并强迫使用这个方法的用户当作真的产生了那个异常处理。在实际应用中,可将其作为那个异常的一个“占位符”使用。这样一来,以后可以方便地产生实际的异常,毋需修改现有的代码。

9.2.4 捕获所有异常

我们可创建一个控制器,令其捕获所有类型的异常。具体的做法是捕获基础类异常类型Exception(也存在其他类型的基础异常,但Exception是适用于几乎所有编程活动的基础)。如下所示:

catch(Exception e) {
System.out.println("caught an exception");
}

这段代码能捕获任何异常,所以在实际使用时最好将其置于控制器列表的末尾,防止跟随在后面的任何特殊异常控制器失效。 对于程序员常用的所有异常类来说,由于Exception类是它们的基础,所以我们不会获得关于异常太多的信息,但可调用来自它的基础类Throwable的方法:

String getMessage()

获得详细的消息。

String toString()

返回对Throwable的一段简要说明,其中包括详细的消息(如果有的话)。

void printStackTrace()
void printStackTrace(PrintStream)

打印出Throwable和Throwable的调用堆栈路径。调用堆栈显示出将我们带到异常发生地点的方法调用的顺序。

第一个版本会打印出标准错误,第二个则打印出我们的选择流程。若在Windows下工作,就不能重定向标准错误。因此,我们一般愿意使用第二个版本,并将结果送给System.out;这样一来,输出就可重定向到我们希望的任何路径。

除此以外,我们还可从Throwable的基础类Object(所有对象的基础类型)获得另外一些方法。对于异常控制来说,其中一个可能有用的是getClass(),它的作用是返回一个对象,用它代表这个对象的类。我们可依次用getName()或toString()查询这个Class类的名字。亦可对Class对象进行一些复杂的操作,尽管那些操作在异常控制中是不必要的。本章稍后还会详细讲述Class对象。

下面是一个特殊的例子,它展示了Exception方法的使用(若执行该程序遇到困难,请参考 3.1.2 赋值 ):

//: ExceptionMethods.java
// Demonstrating the Exception Methods
package c09;
public class ExceptionMethods {
  public static void main(String[] args) {
    try {
      throw new Exception("Here's my Exception");
    } catch(Exception e) {
      System.out.println("Caught Exception");
      System.out.println(
        "e.getMessage(): " + e.getMessage());
      System.out.println(
        "e.toString(): " + e.toString());
      System.out.println("e.printStackTrace():");
      e.printStackTrace();
    }
  }
} ///:~

该程序输出如下:

Caught Exception
e.getMessage(): Here's my Exception
e.toString(): java.lang.Exception: Here's my Exception
e.printStackTrace():
java.lang.Exception: Here's my Exception
        at ExceptionMethods.main

可以看到,该方法连续提供了大量信息——每类信息都是前一类信息的一个子集。

9.2.5 重新“掷”出异常

在某些情况下,我们想重新掷出刚才产生过的异常,特别是在用Exception捕获所有可能的异常时。由于我们已拥有当前异常的句柄,所以只需简单地重新掷出那个句柄即可。下面是一个例子:

catch(Exception e) {
System.out.println("一个异常已经产生");
throw e;
}

重新“掷”出一个异常导致异常进入更高一级环境的异常控制器中。用于同一个try块的任何更进一步的catch从句仍然会被忽略。此外,与异常对象有关的所有东西都会得到保留,所以用于捕获特定异常类型的更高一级的控制器可以从那个对象里提取出所有信息。 若只是简单地重新掷出当前异常,我们打印出来的、与printStackTrace()内的那个异常有关的信息会与异常的起源地对应,而不是与重新掷出它的地点对应。若想安装新的堆栈跟踪信息,可调用fillInStackTrace(),它会返回一个特殊的异常对象。这个异常的创建过程如下:将当前堆栈的信息填充到原来的异常对象里。下面列出它的形式:

//: Rethrowing.java
// Demonstrating fillInStackTrace()
public class Rethrowing {
  public static void f() throws Exception {
    System.out.println(
      "originating the exception in f()");
    throw new Exception("thrown from f()");
  }
  public static void g() throws Throwable {
    try {
      f();
    } catch(Exception e) {
      System.out.println(
        "Inside g(), e.printStackTrace()");
      e.printStackTrace();
      throw e; // 17
      // throw e.fillInStackTrace(); // 18
    }
  }
  public static void
  main(String[] args) throws Throwable {
    try {
      g();
    } catch(Exception e) {
      System.out.println(
        "Caught in main, e.printStackTrace()");
      e.printStackTrace();
    }
  }
} ///:~

其中最重要的行号在注释内标记出来。注意第17行没有设为注释行。它的输出结果如下:

originating the exception in f()
Inside g(), e.printStackTrace()
java.lang.Exception: thrown from f()
        at Rethrowing.f(Rethrowing.java:8)
        at Rethrowing.g(Rethrowing.java:12)
        at Rethrowing.main(Rethrowing.java:24)
Caught in main, e.printStackTrace()
java.lang.Exception: thrown from f()
        at Rethrowing.f(Rethrowing.java:8)
        at Rethrowing.g(Rethrowing.java:12)
        at Rethrowing.main(Rethrowing.java:24)

因此,异常堆栈路径无论如何都会记住它的真正起点,无论自己被重复“掷”了好几次。 若将第17行标注(变成注释行),而撤消对第18行的标注,就会换用fillInStackTrace(),结果如下:

originating the exception in f()
Inside g(), e.printStackTrace()
java.lang.Exception: thrown from f()
        at Rethrowing.f(Rethrowing.java:8)
        at Rethrowing.g(Rethrowing.java:12)
        at Rethrowing.main(Rethrowing.java:24)
Caught in main, e.printStackTrace()
java.lang.Exception: thrown from f()
        at Rethrowing.g(Rethrowing.java:18)
        at Rethrowing.main(Rethrowing.java:24)

由于使用的是fillInStackTrace(),第18行成为异常的新起点。

针对g()和main(),Throwable类必须在异常规格中出现,因为fillInStackTrace()会生成一个Throwable对象的句柄。由于Throwable是Exception的一个基础类,所以有可能获得一个能够“掷”出的对象(具有Throwable属性),但却并非一个Exception(异常)。因此,在main()中用于Exception的句柄可能丢失自己的目标。为保证所有东西均井然有序,编译器强制Throwable使用一个异常规范。举个例子来说,下述程序的异常便不会在main()中被捕获到:

//: ThrowOut.java
public class ThrowOut {
  public static void
  main(String[] args) throws Throwable {
    try {
      throw new Throwable(); 
    } catch(Exception e) {
      System.out.println("Caught in main()");
    }
  }
} ///:~

也有可能从一个已经捕获的异常重新“掷”出一个不同的异常。但假如这样做,会得到与使用fillInStackTrace()类似的效果:与异常起源地有关的信息会全部丢失,我们留下的是与新的throw有关的信息。如下所示:

//: RethrowNew.java
// Rethrow a different object from the one that
// was caught
public class RethrowNew {
  public static void f() throws Exception {
    System.out.println(
      "originating the exception in f()");
    throw new Exception("thrown from f()");
  }
  public static void main(String[] args) {
    try {
      f();
    } catch(Exception e) {
      System.out.println(
        "Caught in main, e.printStackTrace()");
      e.printStackTrace();
      throw new NullPointerException("from main");
    }
  }
} ///:~

输出如下:

originating the exception in f()
Caught in main, e.printStackTrace()
java.lang.Exception: thrown from f()
        at RethrowNew.f(RethrowNew.java:8)
        at RethrowNew.main(RethrowNew.java:13)
java.lang.NullPointerException: from main
        at RethrowNew.main(RethrowNew.java:18)

最后一个异常只知道自己来自main(),而非来自f()。注意Throwable在任何异常规范中都不是必需的。

永远不必关心如何清除前一个异常,或者与之有关的其他任何异常。它们都属于用new创建的、以内存堆为基础的对象,所以垃圾收集器会自动将其清除。

下一节:Java包含了一个名为Throwable的类,它对可以作为异常“掷”出的所有东西进行了描述。Throwable对象有两种常规类型(亦即“从Throwable继承”)。其中,Error代表编译期和系统错误,我们一般不必特意捕获它们(除在特殊情况以外)。Exception是可以从任何标准Java库的类方法中“掷”出的基本类型。此外,它们亦可从我们自己的方法以及运行期偶发事件中“掷”出。