如何在Java中正确实现“Sneaky Throws”
如果你问 Java 开发者如何看待“Sneaky Throws”这个概念,我几乎可以肯定,会有几种说法,措辞大不相同,但含义相近。有些人会把它概括为:能够抛出受检异常,而不必显式声明它们;另一些人则会补充说,这意味着编写函数式风格代码(lambda),并允许调用那些抛出受检异常的方法。最有可能的是,人们肯定会提到有一个名叫 @SneakyThrows 的 Lombok 注解,只要把它放在方法上,问题就立刻解决了。最后但同样重要的是,用更务实的说法来概括,这个概念可以让 Java 编译器把受检异常当作运行时异常来处理。所有这些观点都是合理的;为了澄清这个概念,本文旨在提供一种直接但有用的方法来处理方法抛出的受检异常。
让我们直接开始,设想以下情况。团队被要求增强当前已交付的应用程序,并实现新功能。这显然是以“冲刺式”的节奏进行的。不过,这个项目已经成功开发了相当长一段时间;它还要处理遗留代码,而且,开发者还要与那些——怎么说呢——写得不太理想的代码部分交互。下面这个类就是一个例子。
正如其名,它建模的是一个两位数整数。该类的实例是不可变的;值在构造时设置,它声明了两个方法,一个用于读取值——getValue()——另一个用于校验值——isValid()。我们不会进一步讨论代码质量,因为它有助于完成本文的实验。这里的主要问题,也是本文的核心,在于这两个方法都声明了 NotSetException,因为它们在特定情况下可能抛出该异常;如果这个 Exception 不是受检异常,那倒也没什么问题。
有一种选择(而且绝对是值得考虑的选择)是借此机会重构这段“遗留”代码,至少把这个 Exception 改成运行时异常。可以编写一些单元测试(如果缺少的话),然后改进实现,再把注意力转移到新需求的功能上。不过,为了本文的实验,这里假设 TwoDigitsInteger 类保持现状,并且 Exception 仍然是受检异常。
异常函数
让我们考虑一个非常简单的场景:有一个 TwoDigitsInteger 集合,目标是创建一个字符串表达式,表示这些数字的和。
如果像下面测试中那样编写代码,
Java 编译器会报错,提示——Unhandled exception: com.hcd.utilities.NotSetException——因为 getValue() 方法声明了一个受检异常,显然它不能在流中使用。要解决这个问题,就需要 try-catch,这会让代码相当难以阅读(而且很难看)。更不用说,我们在遍历集合时还在修改 joiner 的状态。
为了克服这个问题,并在存在受检 Exception 的情况下仍能拥有流畅的 API,我们创建了下面的 ExceptionFunction 接口。
它的通用性足够强;它表示一个函数,接受一个参数(类型为 T),产生一个结果(类型为 R),并且在应用时可能抛出一个 Exception 子类(类型为 E)。实现者需要定义单个方法,用于实际应用该函数。此外,还定义了以下类。
当调用 ExceptionWrapper#apply() 方法时,如果抛出了 Exception,它会被包装成 RuntimeException 并继续抛出,而不论初始异常的类型是什么(受检 Exception 的情况显然也覆盖到了,所以没问题)。作为参数传入的 ExceptionFunction 表示被包装起来以解决这个问题的初始调用。前面讨论过的测试被修改为使用 ExceptionWrapper#apply() 方法。现在它不仅能够成功编译和运行,而且代码可读性肯定也得到了提升。
异常谓词
现在让我们考虑另一个简单的场景:我们只想统计指定范围内有效的两位数整数。同样,为了本文的实验,这里假设仍使用前面的 TwoDigitsInteger 类。和前一种情况一样,下面这段本可以完成任务的代码无法编译,原因相同——Unhandled exception: com.hcd.utilities.NotSetException——因为 isValid() 方法声明了一个受检异常,而它不能在流中使用。
同样,假设必须使用 TwoDigitsInteger,那么人们就不得不遍历这些数字,在 try-catch 中检查受检的 NotSetException,因为 isValid() 声明了它,然后把该 Exception 包装成 RuntimeException 并继续抛出,最后统计有效数字。即使只是用自然语言列举这些步骤,也已经太复杂了。为了在进行声明了受检 Exception 的检查时仍能保持 API 流畅并使用流,我们声明了下一个接口。
它表示一个谓词(返回布尔值的函数),接受一个参数,并且可能抛出一个 Exception 子类。该方法在给定参数上评估谓词,如果输入参数匹配则返回 true,否则返回 false。此外,下面这个方法被添加到 ExceptionWrapper 类中,它与 apply() 方法非常相似。
调用时,它会实际应用所提供的谓词。如果抛出了 Exception,它会被包装成 RuntimeException 并继续抛出。现在,初始代码可以改写如下,并成功编译和执行。
要点
尽管本文给出的解决方案简单直接,但它非常实用,尤其是在处理那些声明了受检 Exception 并进一步用于我们所编写代码中的函数时。当然,已经存在其他开箱即用的替代方案,例如 Lombok 的 @SneakyThrows 注解。就我个人而言,我很少在任何项目中引入 Lombok 库;而且随着 Java 引入 record,未来这样做就更不可能了。话虽如此,本文描述的结构非常有帮助、轻量,并且在需要时易于理解和使用。ExceptionWrapper、ExceptionFunction 和 ExceptionPredicate 的源代码是 asentinel-orm 开源项目的一部分。要使用它,可以在 pom.xml 文件中声明 Maven 依赖(截至本文撰写时,1.72.2 是最新版本)
或者,如果觉得引入整个库开销太大,也可以直接使用它。
资源
[1] -- asentinel-orm 开源 ORM 项目在这里 [2] -- 图片拍摄于伦敦附近的“哈利波特华纳兄弟影城”