本章概要
- Try-With-Resources 用法
- 揭示细节
- 异常匹配
Try-With-Resources 用法
在考虑所有可能失败的方法时,找出放置所有 try-catch-finally 块的位置变得令人生畏。确保没有任何故障路径,使系统远离不稳定状态,这非常具有挑战性。
InputFile.java
是一个特别棘手的情况,因为文件被打开(伴随所有可能因此产生的异常),然后它在对象的生命周期中保持打开状态。每次调用 getLine()
都可能导致异常,而且 dispose()
也是这种情况。这个例子只是好在它显示了事情可以混乱到什么地步。它还表明了你应该尽量不要那样设计代码(当然,你经常会遇到这种无法选择的代码设计的情况,因此你仍然必须要理解它)。
InputFile.java 一个更好的实现方式是如果构造函数读取文件并在内部缓冲它 —— 这样,文件的打开,读取和关闭都发生在构造函数中。或者,如果读取和存储文件不切实际,你可以改为生成 Stream。理想情况下,你可以设计成如下的样子:
package base;// exceptions/InputFile2.java
import java.io.*;
import java.nio.file.*;
import java.util.stream.*;
public class InputFile2 {
private String fname;
public InputFile2(String fname) {
this.fname = fname;
}
public Stream<String> getLines() throws IOException {
return Files.lines(Paths.get(fname));
}
public static void main(String[] args) throws IOException {
new InputFile2("D:\\onJava\\myTest\\base\\InputFile2.java").getLines()
.skip(17)
.limit(1)
.forEach(System.out::println);
}
}
输出为:
现在,getLines() 全权负责打开文件并创建 Stream。
你不能总是轻易地回避这个问题。有时会有以下问题:
- 需要资源清理
- 需要在特定的时刻进行资源清理,比如你离开作用域的时候(在通常情况下意味着通过异常进行清理)。
一个常见的例子是 java.io.FileInputStream
。要正确使用它,你必须编写一些棘手的样板代码:
import java.io.*;
public class MessyExceptions {
public static void main(String[] args) {
InputStream in = null;
try {
in = new FileInputStream(new File("MessyExceptions.java"));
int contents = in.read();
// Process contents
} catch (IOException e) {
// Handle the error
} finally {
if (in != null) {
try {
in.close();
} catch (IOException e) {
// Handle the close() error
}
}
}
}
}
当 finally 子句有自己的 try 块时,感觉事情变得过于复杂。
幸运的是,Java 7 引入了 try-with-resources 语法,它可以非常清楚地简化上面的代码:
import java.io.*;
public class TryWithResources {
public static void main(String[] args) {
try (
InputStream in = new FileInputStream(new File("TryWithResources.java"))
) {
int contents = in.read();
// Process contents
} catch (IOException e) {
// Handle the error
}
}
}
在 Java 7 之前,try 后面总是跟着一个 {,但是现在可以跟一个带括号的定义 ——这里是我们创建的 FileInputStream 对象。括号内的部分称为资源规范头(resource specification header)。现在 in
在整个 try 块的其余部分都是可用的。更重要的是,无论你如何退出 try 块(正常或通过异常),和以前的 finally 子句等价的代码都会被执行,并且不用编写那些杂乱而棘手的代码。这是一项重要的改进。
它是如何工作的? try-with-resources 定义子句中创建的对象(在括号内)必须实现 java.lang.AutoCloseable
接口,这个接口只有一个方法:close()
。当在 Java 7 中引入 AutoCloseable
时,许多接口和类被修改以实现它;查看 Javadocs 中的 AutoCloseable,可以找到所有实现该接口的类列表,其中包括 Stream
对象:
StreamsAreAutoCloseable.java
import java.io.*;
import java.nio.file.*;
import java.util.stream.*;
public class StreamsAreAutoCloseable {
public static void main(String[] args) throws IOException {
try (
Stream<String> in = Files.lines(Paths.get("D:\\onJava\\myTest\\base\\StreamsAreAutoCloseable.java"));
PrintWriter outfile = new PrintWriter("D:\\onJava\\myTest\\base\\Results.txt"); // [1]
) {
in.skip(5)
.limit(1)
.map(String::toLowerCase)
.forEachOrdered(outfile::println);
} // [2]
}
}
Results.txt
import java.nio.file.*;
- [1] 你在这里可以看到其他的特性:资源规范头中可以包含多个定义,并且通过分号进行分割(最后一个分号是可选的)。规范头中定义的每个对象都会在 try 语句块运行结束之后调用 close() 方法。
- [2] try-with-resources 里面的 try 语句块可以不包含 catch 或者 finally 语句而独立存在。在这里,IOException 被 main() 方法抛出,所以这里并不需要在 try 后面跟着一个 catch 语句块。
Java 5 中的 Closeable 已经被修改,修改之后的接口继承了 AutoCloseable 接口。所以所有实现了 Closeable 接口的对象,都支持了 try-with-resources 特性。
揭示细节
为了研究 try-with-resources 的基本机制,我们将创建自己的 AutoCloseable 类:
// exceptions/AutoCloseableDetails.java
class Reporter implements AutoCloseable {
String name = getClass().getSimpleName();
Reporter() {
System.out.println("Creating " + name);
}
@Override
public void close() {
System.out.println("Closing " + name);
}
}
class First extends Reporter {
}
class Second extends Reporter {
}
public class AutoCloseableDetails {
public static void main(String[] args) {
try (
First f = new First();
Second s = new Second()
) {
}
}
}
输出为:
退出 try 块会调用两个对象的 close() 方法,并以与创建顺序相反的顺序关闭它们。顺序很重要,因为在这种情况下,Second 对象可能依赖于 First 对象,因此如果 First 在第 Second 关闭时已经关闭。 Second 的 close() 方法可能会尝试访问 First 中不再可用的某些功能。
假设我们在资源规范头中定义了一个不是 AutoCloseable 的对象
// exceptions/TryAnything.java
// {WillNotCompile}
class Anything {
}
public class TryAnything {
public static void main(String[] args) {
try (
Anything a = new Anything()
) {
}
}
}
正如我们所希望和期望的那样,Java 不会让我们这样做,并且出现编译时错误。
如果其中一个构造函数抛出异常怎么办?
// exceptions/ConstructorException.java
class CE extends Exception {
}
class SecondExcept extends Reporter {
SecondExcept() throws CE {
super();
throw new CE();
}
}
public class ConstructorException {
public static void main(String[] args) {
try (
First f = new First();
SecondExcept s = new SecondExcept();
Second s2 = new Second()
) {
System.out.println("In body");
} catch (CE e) {
System.out.println("Caught: " + e);
}
}
}
输出为:
现在资源规范头中定义了 3 个对象,中间的对象抛出异常。因此,编译器强制我们使用 catch 子句来捕获构造函数异常。这意味着资源规范头实际上被 try 块包围。
正如预期的那样,First 创建时没有发生意外,SecondExcept 在创建期间抛出异常。请注意,不会为 SecondExcept 调用 close(),因为如果构造函数失败,则无法假设你可以安全地对该对象执行任何操作,包括关闭它。由于 SecondExcept 的异常,Second 对象实例 s2 不会被创建,因此也不会有清除事件发生。
如果没有构造函数抛出异常,但在 try 的主体中可能抛出异常,那么你将再次被强制要求提供一个catch 子句:
// exceptions/BodyException.java
class Third extends Reporter {
}
public class BodyException {
public static void main(String[] args) {
try (
First f = new First();
Second s2 = new Second()
) {
System.out.println("In body");
Third t = new Third();
new SecondExcept();
System.out.println("End of body");
} catch (CE e) {
System.out.println("Caught: " + e);
}
}
}
输出为:
请注意,第 3 个对象永远不会被清除。那是因为它不是在资源规范头中创建的,所以它没有被保护。这很重要,因为 Java 在这里没有以警告或错误的形式提供指导,因此像这样的错误很容易漏掉。实际上,如果依赖某些集成开发环境来自动重写代码,以使用 try-with-resources 特性,那么它们(在撰写本文时)通常只会保护它们遇到的第一个对象,而忽略其余的对象。
最后,让我们看一下抛出异常的 close() 方法:
// exceptions/CloseExceptions.java
class CloseException extends Exception {
}
class Reporter2 implements AutoCloseable {
String name = getClass().getSimpleName();
Reporter2() {
System.out.println("Creating " + name);
}
@Override
public void close() throws CloseException {
System.out.println("Closing " + name);
}
}
class Closer extends Reporter2 {
@Override
public void close() throws CloseException {
super.close();
throw new CloseException();
}
}
public class CloseExceptions {
public static void main(String[] args) {
try (
First f = new First();
Closer c = new Closer();
Second s = new Second()
) {
System.out.println("In body");
} catch (CloseException e) {
System.out.println("Caught: " + e);
}
}
}
输出为:
从技术上讲,我们并没有被迫在这里提供一个 catch 子句;你可以通过 main() throws CloseException 的方式来报告异常。但 catch 子句是放置错误处理代码的典型位置。
请注意,因为所有三个对象都已创建,所以它们都以相反的顺序关闭 - 即使 Closer.close() 抛出异常也是如此。仔细想想,这就是你想要的结果。但如果你必须亲手编写所有的逻辑,或许会丢失一些东西并使得逻辑出错。想想那些程序员没有考虑 Clean up 的所有影响并且出错的代码。因此,如果可以,你应当始终使用 try-with-resources。这个特性有助于生成更简洁,更易于理解的代码。
异常匹配
抛出异常的时候,异常处理系统会按照代码的书写顺序找出“最近”的处理程序。找到匹配的处理程序之后,它就认为异常将得到处理,然后就不再继续查找。
查找的时候并不要求抛出的异常同处理程序所声明的异常完全匹配。派生类的对象也可以匹配其基类的处理程序,就像这样:
// exceptions/Human.java
// Catching exception hierarchies
class Annoyance extends Exception {
}
class Sneeze extends Annoyance {
}
public class Human {
public static void main(String[] args) {
// Catch the exact type:
try {
throw new Sneeze();
} catch (Sneeze s) {
System.out.println("Caught Sneeze");
} catch (Annoyance a) {
System.out.println("Caught Annoyance");
}
// Catch the base type:
try {
throw new Sneeze();
} catch (Annoyance a) {
System.out.println("Caught Annoyance");
}
}
}
输出为:
Sneeze 异常会被第一个匹配的 catch 子句捕获,也就是程序里的第一个。然而如果将这个 catch 子句删掉,只留下 Annoyance 的 catch 子句,该程序仍然能运行,因为这次捕获的是 Sneeze 的基类。换句话说,catch(Annoyance a)会捕获 Annoyance 以及所有从它派生的异常。这一点非常有用,因为如果决定在方法里加上更多派生异常的话,只要客户程序员捕获的是基类异常,那么它们的代码就无需更改。
如果把捕获基类的 catch 子句放在最前面,以此想把派生类的异常全给“屏蔽”掉,就像这样:
try {
throw new Sneeze();
} catch(Annoyance a) {
// ...
} catch(Sneeze s) {
// ...
}
此时,编译器会发现 Sneeze 的 catch 子句永远得不到执行,因此它会向你报告错误。