Java高级系列——何时使用、如何使用异常(Exceptions)
一、介绍
在Java中,异常是一个非常重要的工具,在程序流中异常可以发出一些反常的(异常的)情况并阻止程序进行更深入的执行。自然地,异常情况可能是致命的(程序不能执行任何操作并且会终止),也可能是可恢复的(程序可以继续执行,但是有些功能可能不可用)。
本文我们将会阐述一些使用异常的经典场景,讨论Checked Exceptions和Unchecked Exceptions,并且接触一些不太常见的案例以及一些有用的异常使用风格。
二、何时使用异常(Exceptions)
概括来讲,异常是在程序执行过程中的触发的一种事件(或者提示),它可以中断程序正常的执行流程。引入异常思想的诞生是为了替换过去所使用的错误码及状态检查技术。从那以后,异常的使用就越来越广泛,最终在许多的编程语言中被接受作为处理错误情况的标准方案,包括Java。
和异常处理相关的一个重要规则就是:不要忽略它们。每个异常都应该被记录而不是被忽视。但是,也有一些非常少见的情况可以安全的忽略异常,即异常根本没什么作用,不完成任何处理的情况。
异常在实践中也是非常重要的一部分,每个public方法在执行实际的逻辑之前都应该验证所有必须的先决条件并且当验证不通过时抛出合适的异常。
三、Checked Exceptions和Unchecked Exceptions
Java语言中的异常管理有别于其他的语言。最主要是因为在Java中有两种异常类:Checked Exceptions和Unchecked Exceptions。有趣的是,这两种类型的异常有些是人为抛出,而有些是受Java语言规则和编译器的强制(但是对于JVM来说它们都是并无差别)。
从经验上来讲,unchecked exceptions被用来发出和程序逻辑以及一些正在进行的假设相关的错误情况(如非法参数,空指针,不支持的操作等等)。所有的unchecked exceptions继承自RuntimeException,并且这也是Java编译器理解指定异常属于unchecked异常类的依据。
Unchecked Exception不必通过调用者捕捉或被列为方法签名的一部分(使用throws关键字)。NullPointerException是最常见的unchecked exception之一,该异常类在Java标准库中的声明如下:
public class NullPointerException extends RuntimeException {
public NullPointerException() {
super();
}
public NullPointerException(String s) {
super(s);
}
}
所以,checked exceptions所代表的就是程序能够直接控制之外的一些非法情况(比如内存、网络、文件系统等)。所有checked exception都是Exception的子类。和Unchecked Exceptions比较,checked exceptions必须被调用者所捕获,或者被列为方法签名的一部分(使用throws关键字)。IOException可能是checked exceptions中最常用的一个:
public class IOException extends Exception {
public IOException() {
super();
}
public IOException(String message) {
super(message);
}
public IOException(String message, Throwable cause) {
super(message, cause);
}
public IOException(Throwable cause) {
super(cause);
}
}
分离Checked Exceptions和Unchecked Exceptions在当时听起来是一个非常好的主意,但是多年来,它所带来的是更多的样板以及不是那么漂亮的代码模式,然而并没有解决实际的问题。在Java生态系统中出现最经典的模式(不幸的是相当繁琐)是使用unchecked exceptions隐藏(或包装)checked exception,比如:
try {
// Some I/O operation here
} catch(final IOException ex) {
throw new RuntimeException("I/O operation failed", ex);
}
在应用中这并不是一个最好的选项,然而通过我们自己的异常层次的一个详细设计可以减少很多开发者需要编写的样板代码。
值得一提的是另外一些拓展自Error类的Java异常类(如OutOfMemoryError或*Error)。这些异常通常表示一些致命的执行失败,并会导致程序立即终止并很难恢复。
四、使用 try-with-resources
抛出的任何异常都会导致一些所谓的栈展开( stack unwinding)和程序执行流程的变更。导致这种结果的原因可能是未关闭本地资源(native resources,如文件处理和network sockets)导致相关资源泄露。Java(直到版本7)中良好的I/O操作需要使用强制finally块来执行清理,代码示例如下:
public void readFile(final File file) {
InputStream in = null;
try {
in = new FileInputStream(file);
// Some implementation here
} catch(IOException ex) {
// Some implementation here
} finally {
if(in != null) {
try {
in.close();
} catch(final IOException ex) {
/* do nothing */
}
}
}
}
但是finally块看起来实在是太丑了(不幸的是,除了在input stream上调用close方法之外finally代码块并未做其他的事情,同时在input stream上调用close方法依然可能导致IOException异常),当尝试关闭输入流(背后是释放操作系统资源)时该代码块会被执行。
幸运的是,从Java 7开始一个被称为try-with-resources的新概念被引入到Java语言中,通过使用try-with-resources可以显著地简化资源管理。使用 try-with-resources重写上面的代码:
public void readFile(final File file) {
try( InputStream in = new FileInputStream(file)) {
// Some implementation here
} catch(final IOException ex) {
// Some implementation here
}
}
要在try-with-resources块中使用资源,唯一需要的是实现接口AutoCloseable。 在这个场景之后,Java编译器将这个结构扩展到更复杂的东西,但是对于开发者来说,代码看起来可读性很强并且非常简洁。 请酌情使用这个非常方便的技术。
五、Exceptions和Lambdas
在如何设计类和接口一文中,我们已经讨论过Java 8的一些最新的最好的特性,特别是Lambda表达式。然而我们并没有更深入的去讨论过一些实践案例,异常就是Lambda表达式实践案例之一。
Java Lambda功能语法不允许指定可能会抛出的checked exceptions(除非由@FunctionalInterface自己定义)异常。下面的代码不会通过编译并可能会编译错误“Unhandled exception type IOException”。
public void readFile() {
run(() -> {
Files.readAllBytes(new File("some.txt").toPath());
});
}
public void run(final Runnable runnable) {
runnable.run();
}
唯一正确的解决方案就是在Lambda函数体中捕获IOException异常并重新抛出合适的RuntimeException异常,比如:
public void readFile() {
run( () -> {
try {
Files.readAllBytes(new File( "some.txt" ).toPath());
} catch(final IOException ex) {
throw new RuntimeException("Error reading file", ex);
}
});
}
许多函数式(functional)接口被声明为能够从其实现中抛出任何异常,如果没有这么声明的话(像Runnable),那么包装(或者捕获)checked exception为unchecked exception是解决异常的唯一方式。
六、标准Java exceptions
Java标准库提供了很多异常类,这些类覆盖了程序执行过程中可能发生的大多数错误。应用最广泛的如下表。
异常类 | 设计意图 |
---|---|
NullPointerException | 当应用程序试图在需要对象的地方使用 null 时,抛出该异常 |
IllegalArgumentException | 抛出的异常表明向方法传递了一个不合法或不正确的参数。 |
IllegalStateException | 在非法或不适当的时间调用方法时产生的信号。换句话说,即 Java 环境或 Java 应用程序没有处于请求操作所要求的适当状态下。 |
IndexOutOfBoundsException | 指示某排序索引(例如对数组、字符串或向量的排序)超出范围时抛出。 |
UnsupportedOperationException | 当不支持请求的操作时,抛出该异常。 |
ArrayIndexOutOfBoundsException | 用非法索引访问数组时抛出的异常。如果索引为负或大于等于数组大小,则该索引为非法索引。 |
ClassCastException | 当试图将对象强制转换为不是实例的子类时,抛出该异常。 |
EnumConstantNotPresentException | 枚举常量不存在异常。当应用试图通过名称和枚举类型访问一个枚举对象,但该枚举对象并不包含常量时,抛出该异常。 |
NumberFormatException | 当应用程序试图将字符串转换成一种数值类型,但该字符串不能转换为适当格式时,抛出该异常。 |
StringIndexOutOfBoundsException | 此异常由 String 方法抛出,指示索引或者为负,或者超出字符串的大小。 |
IOException | 输入输出异常 |
七、定义你自己的Exceptions
Java语言中定义自己的异常类非常的简单。精心设计的异常层次结构允许执行详细和细粒度的错误条件管理和报告。因此,找到一个正确的平衡点是非常重要的,太多的异常类可能会使开发变得复杂,并影响捕获异常或将其传播到堆栈中的代码量。
强烈建议所有的用户定义的异常应该继承自RuntimeException类,并归为unchecked exceptions。比如,我们定义一个异常解决认证问题:
public class NotAuthenticatedException extends RuntimeException {
private static final long serialVersionUID = 2079235381336055509L;
public NotAuthenticatedException() {
super();
}
public NotAuthenticatedException( final String message ) {
super( message );
}
public NotAuthenticatedException( final String message, final Throwable cause ) {
super( message, cause );
}
}
这个异常的目的就是在登录过程中提示用户认证不存在或者非法,比如:
public void signin( final String username, final String password ) {
if(!exists(username, password)) {
throw new NotAuthenticatedException("User/Password combination is not recognized");
}
}
将信有益的消息和异常一起传递是一个非常好的思想,这样可以帮助解决很多生产上面的问题。如果由于另一个异常情况而重新抛出异常,则应使用cause构造函数参数保留初始异常,这将会帮你找到问题的源头。
八、注释Exceptions
在如何高效的编写方法一文中我们已经阐述过Java中方法的注释。本文我们将花一些时间去讨论如何让exception成为注释的一部分。
如果方法的实现中有一部分可能抛出checked exception,那么checked exception就必须成为方法签名的一部分(使用throws声明)。Java注释工具有一个描述异常的@throws标记。比如:
/**
* 从文件系统读取文件
* @throws IOException if an I/O error occurs.
*/
public void readFile() throws IOException {
// Some implementation here
}
在Checked和Unchecked Exceptions一节我们知道,unchecked exceptions通常是不可以声明成方法签名的一部分的。然而在方法注释中记录他们是非常好的一种想法,因为这样方法的调用者会意识到可能会抛出异常(使用相同的@thrown标记)。比如:
/**
* Parses the string representation of some concept.
* @param str String to parse
* @throws IllegalArgumentException if the specified string cannot be parsed properly
* @throws NullPointerException if the specified string is null
*/
public void parse(final String str) {
// Some implementation here
}
请记住一定要注释您的方法可能抛出的异常。它将帮助其他开发人员从一开始就实施适当的异常处理和恢复(回退)逻辑,便于从生产系统中的故障排除问题中解决异常。
九、Exceptions和Logging
日志是许多Java项目、库、框架中非常重要的一部分,它是应用中重要事件发生的通知,并且异常是流程中非常重要的一部分。在本系列文章的后续文章中我们可能会继续阐述一些Java标准库提供的日志子系统,然而请记住异常应该被优先记录以备后续在应用中分析异常从而发现问题并解决问题。
十、小结
本文我们简单阐述了异常这个Java中的重要特性。我们已经看到异常在Java中是错误管理的基础。和错误码、错误标记及状态相比,异常(Exceptions)让错误条件的处理和提示变得非常的容易,异常不能忽略。在下一部分我们将会解决一个非常热门且非常复杂的问题:Java中的并行和多线程编程。