异常处理哲学
用语 | 解释 |
---|---|
客户端 | 泛指使用 API 的地方。 |
编程语义 | 代码程序所表达的真实意图。 |
代码单元 | 泛指某种代码整体。如代码块、方法、类、包、模块等。 |
在 Java 中,异常被分为两大类:受检异常(checked exception)、未受检异常(unchecked exception)。
受检异常包含java.lang.Exception
、java.lang.Throwable
两种类型。它们会受到编译器检测。当发现客户端没有及时处理异常时,其编译就会无法通过。
其设计目的是希望客户端能够及时地给出解决方案。
比较典型的例子是java.io.IOException
。譬如当java.io.FileInputStream#FileInputStream(java.io.File)
中的file
不存在就会抛出java.io.FileNotFoundException
。这里需要理解的是,为什么FileNotFoundException
需要被及时处理。
这牵涉到代码设计层面的哲学,其背后的基础逻辑在于 API 设计者期望客户端能够及时并明确地给出异常的解决方案。这种逻辑思维通常建立在设计者的经验之上。
以FileInputStream(java.io.File)
为例,它是一个同步API。换言之,它必须正确找到目标文件,否则就无法执行后续操作。出于这一原因,客户端就有必要给出一个明确的解决方案。譬如使用必然存在的默认文件来代替。当然,客户端此时也可能出现不知道何处理的情况。此时客户端可以选择接着往外层抛出异常,以便让更外层的客户端来处理它。但这种做法只应该在迫不得已的时候才进行,否则受检异常和未受检异常将变得无本质区别。
因此,可将受检异常的编程语义理解为可预计且客户端有能力解决的问题。
未受检异常包含java.lang.Error
、java.lang.RuntimeException
两种类型。典型例子是java.lang.NullPointerException
和java.lang.IllegalArgumentException
。通常两者类型都会涉及到外部输入。如没有在数据库中找到对应的数据记录、用户提交的请求不符合规则等。这些问题 API 通常是无法控制或解决的。或者说,出现这些问题时,就应该直接被系统用户所感知,而不是被暗地里解决;因为这样可能会引发歧义。
编译器并不会检测未受检异常,所以通常的应对方案是实施防御性编程。即在相应位置增加校验逻辑或兜底操作。较为常见的例子有前置条件判定、AOP捕获、声明式处理(通常由SDK支持。在函数式/链式编程中被受青睐)这些。可将未受检异常的编程语义理解为无法预计或解决的问题。因此,当遇到无法预计、无法解决、不应由程序解决等问题时,就可以运用未受检异常来将其建模。
在进行程序设计时,不应该将所有异常都交由全局异常处理器来解决,而是应该根据实际需求来选择受检异常或未受检异常后再进行针对处理。
一个较为典型的例子是spring-tx
的声明式事务(@Transactional
)。其默认只会回滚未受检异常。因为受检异常本身就该由客户端来处理,因为技术框架并不知道具体的异常处理需求。
此外,过度依赖全局异常处理器会降低代码单元的内聚性,因为部份逻辑被转移到了代码单元外。而低内聚性代码其维护成本往往较高(,特别是对于非代码作者而言)。所以一些编程语言甚至会强制要求异常需要进行就近处理(如Golang)。而且有时候异常处理可能会涉及部份业务逻辑,此时使用全局异常处理器将会破坏业务的完整性。
总的来说,全局异常处理器用不好会不利于可持续性设计。个人更推荐将其视为一种兜底方案,只在迫不得已时才使用它。切勿因为全局异常处理器可以简化编程模型和节省些代码就牺牲内聚性或可读性。
据经验而言,这些看起来“简单和便捷”的技术手段对于需要长期维护的代码而言通常是“致命”的。往往代码变得难以维护就是因为存在各种“简单和便捷”的念头。值得注意,“代码简洁”并不意味着拥有可读性和可维护性。要想拥有高可读和可维护的代码,必须遵循高内聚,低耦合的设计原则。