关于异常处理,Java是孤独的。因为只有Java有Checked Exception(受检查异常)。其他语言,比如C++, Objective-C, C#, Kotlin, Scala等都没有Checked Exception的概念。
1.万恶之源:强制处理
直接上代码
private String getContent(Path targetFile) {
byte[] content = Files.readAllBytes(targetFile);//compile error: "Unhandled IOException"
return new String(content);
}
你会发现上面的代码根本编译不过,因为Files.readAllBytes()
会抛出IOException
,而IOException
是一种Checked Exception。Checked Exception会强迫调用方处理异常,不然根本编译不过。
不爽,那咋办?
- 选项A: 重新抛出异常
- 选项B: 乖乖处理异常
2.重新抛出异常
private String getContent(Path targetFile) throws IOException {
byte[] content = Files.readAllBytes(targetFile);
return new String(content);
}
很简单,我自己不想处理异常,让调用方去处理,直接在方法签名上声明throws IOException
。但是你会发现,你这样做了,卵用没有,因为这是一个private
方法,你自己在调用这个private
方法的时候,发现还是要处理异常。
如果我重新抛出异常,会有什么问题呢?
2.1.Checked Exception是方法签名的一部分
考虑一下下面的调用栈:
如果我们在方法UserRepository.getContent(Path)
中抛出IOException
,并且我们想在ContextMenu.menuClicked()
方法中处理,我们必须改变这个调用栈上的所有方法的签名。
更有甚至,如果后面我们想加一种异常,或者改变异常类型,或者完全移除异常,我们都我们必须改变所有的方法签名。而且,如果你使用的是类库中的接口(比如使用Java8流中的API Action.execute()
)的话,你没有源代码,你是没法改变方法签名的。
2.2.方法签名中暴露了实现细节
看下上面例子中的方法签名
UserRepository.getUsers(Path) throws IOException
假设UserRepository
是一个接口,那么UserRepository
接口的不同实现(比如基于文件系统的实现)都需要抛出IOException
吗?不都是这样的,一个数据库的实现可能会抛出SqlException
,或者一个WebService
可能会抛出TimeoutException
。
最重要的是:异常类型(不管他们是不是真的会抛出异常)特定于具体的实现
但是,即使他知道他使用的基于数据库的实现永远也不会抛出IOException
,他也要被迫必须要处理IOException
。更有甚者,他只是单纯地想知道出了啥问题。因此,抛出的异常应该与实现无关。
3.乖乖处理异常
那好吧,我们不往上抛异常了,我们自己处理异常吧,但是关键是怎么处理呢?
private String getContent(Path targetFile) {
try {
byte[] content = Files.readAllBytes(targetFile);
return new String(content);
} catch (IOException e){
// What to do?
}
}
3.1 没什么好的方法处理异常
getContent()
这个方法太底层了。在这个地方,我们根本不知道该怎么处理这个异常。也许,IOException
可以在比较高层的地方处理,比如可以通过在UI中给用户一个反馈,但是我们在这个方法中应该啥也不需要做。但是,编译期逼迫我们必须要处理这个异常。
3.2 也不可能恢复
一般,我们处理异常都是打印错误日志然后终止当前操作或者如果没法继续处理的话就需要终止这个应用。记录日志然后退出,这没啥问题。但是我们为什么不让异常直接抛出去呢?这其实跟上面的处理效果是一样的。如果我们不能从异常中恢复,我们又为什么要必须处理异常呢?如果我们就是想不管遇到什么异常都退出当前操作,那其实被迫处理异常是一点道理也没有的。
3.3 进一步探讨
Checked Exception会导致烦人的模板代码(try {} catch () {}
)。每次你调用一个抛出Checked Exception的方法时,你必须要写模板代码 try-catch-statement。
编译期强迫我们Catch Exception。这通常会导致混合处理主逻辑和错误。但这些都是独立的问题,应该以清晰的方式单独处理逻辑和错误。多个try-catch语句使错误处理分散在整个代码库中。
更危险的是,我们只是简单地忘记了实现catch块,因为我们确实不知道怎么处理异常或者假象根本不会发生这个异常。或者,我们只是想快速测试抛出异常的方法调用,并计划稍后实现catch块,但忘记了。一个空的catch块会吃掉异常。异常发生时,我们永远不会知道。应用程序出现错误,我们缺不知道原因。那就只能开心的debug去了。
最后,很多Checked Excpetion其实是技术性的异常(比如IOException),而且并没有提供域相关的语义来帮助你解决异常。
3.4. Java 8 Lambda和流
当涉及lambda和流时,Checked exception会很烦。假设我们想在lambda中调用一个抛出IOException的方法
List<Path> files = // ...
List<String> contents = files.stream()
.map(file -> {
try {
return new String(Files.readAllBytes(file));
} catch (IOException e) {
// Well, we can't reasonably handle the exception here...
}
})
.collect(Collectors.toList());
由于IOException
没有在lambda的函数接口(java.util.Function.apply
())中声明,因此我们必须在lambda中捕获并处理它们。这个try-catch模板代码破坏了我们的代码,降低了可读性。
4.解决方案: 使用Unchecked Exception并且包装Checked Exception
如果定义自己的异常,请始终使用Unchecked Exception(RuntimeException)。如果必须处理Checked Exception,请将它们包装在自己的域/高级异常中,然后重新抛出自己的异常。在getContent()
方法中,我们可以抛出自己的RepositoryException
,它基础了RuntimeException
。
private String getContent(Path targetFile) {
try {
byte[] content = Files.readAllBytes(targetFile);
return new String(content);
} catch (IOException e) {
throw new RepositoryException("Unable to read file content. File: " + targetFile, e);
}
}
public class RepositoryException extends RuntimeException {
//...
}
现在,getContent()
或getUsers()
的调用方可以在最合适的位置自由处理RepositoryException
。由调用方决定,而不是由编译器决定。这样就可以让统一处理异常。比如,向用户提供错误反馈。
那程序有可能从RepositoryException
中恢复吗?有的话就去处理,没有的话也没关系,你可以catch异常然后反馈给用户,或者你不catch异常并以这种方式退出当前操作。
确实,这种处理方式需要程序员更加自律。你如果想处理RepositoryException
,那么你就不应该忘记处理RepositoryException
。而且,你必须在方法的javadoc中记录该方法会抛出RepositoryException
。编译器不再为你兜底。你必须小心处理异常。但是你写代码更加灵活了,并且可以从样本代码中解脱了。
5.如何记录Unchecked Exception
但是,我怎么知道一个方法会抛出Unchecked Exception呢?编译器没告诉我。是的,你有责任仔细记录Unchecked Exception。有两种办法
- 在JavaDoc总记录
/**
* @throws RepositoryException if ...
*/
private String getContent(Path targetFile) {
- 在方法签名中声明异常
private String getContent(Path targetFile) throws RepositoryException {
有些人说JavaDoc应该用于Unchecked Exception,而throws子句只用于Checked Exception。然而,我不同意这种教条式的做法。在这一点上,我更务实:我个人总是使用throws子句,这仅仅是因为你可以根据您的IDE获得更好的工具支持(例如,在catch块中编写异常时完成代码,在catch块中选择异常时突出显示抛出方法,反之亦然)。此外,您还可以另外使用JavaDoc来获取有关异常的进一步详细说明。
6.总结
使用UnChecked Exception,它带来了自由和灵活性(我们自己决定是否以及在哪里处理异常,而不是由编译器来决定)。但这也意味着更多的责任
“With great power comes great responsibility”
能力越大,责任越大
那怎么理解Checked Exception呢?
At the beginning you feel safer with them, but later they prevent you from swimming quickly.
一开始你会觉得更加安全,但是后来他们却阻碍你游得更快