Ohhnews

分类导航

$ cd ..
DZone Java原文

如何在Java中正确实现“Sneaky Throws”

#java#异常处理#sneaky throws#受检异常#函数式编程

如果你问 Java 开发者如何看待“Sneaky Throws”这个概念,我几乎可以肯定,会有几种说法,措辞大不相同,但含义相近。有些人会把它概括为:能够抛出受检异常,而不必显式声明它们;另一些人则会补充说,这意味着编写函数式风格代码(lambda),并允许调用那些抛出受检异常的方法。最有可能的是,人们肯定会提到有一个名叫 @SneakyThrowsLombok 注解,只要把它放在方法上,问题就立刻解决了。最后但同样重要的是,用更务实的说法来概括,这个概念可以让 Java 编译器把受检异常当作运行时异常来处理。所有这些观点都是合理的;为了澄清这个概念,本文旨在提供一种直接但有用的方法来处理方法抛出的受检异常。

让我们直接开始,设想以下情况。团队被要求增强当前已交付的应用程序,并实现新功能。这显然是以“冲刺式”的节奏进行的。不过,这个项目已经成功开发了相当长一段时间;它还要处理遗留代码,而且,开发者还要与那些——怎么说呢——写得不太理想的代码部分交互。下面这个类就是一个例子。

$ java
public class TwoDigitsInteger {
    private final Integer value;

    public TwoDigitsInteger(Integer value) {
        this.value = value;
    }

    public boolean isValid() throws NotSetException {
        if (value == null) {
            throw new NotSetException("Number value not set.");
        }
        return value >= 10 && value <= 99;
    }

    public Integer getValue() throws NotSetException {
        if (value == null) {
            throw new NotSetException("Number value not set.");
        }
        return value;
    }
}

正如其名,它建模的是一个两位数整数。该类的实例是不可变的;值在构造时设置,它声明了两个方法,一个用于读取值——getValue()——另一个用于校验值——isValid()。我们不会进一步讨论代码质量,因为它有助于完成本文的实验。这里的主要问题,也是本文的核心,在于这两个方法都声明了 NotSetException,因为它们在特定情况下可能抛出该异常;如果这个 Exception 不是受检异常,那倒也没什么问题。

$ java
public class NotSetException extends Exception {
    public NotSetException(String message) {
        super(message);
    }
}

有一种选择(而且绝对是值得考虑的选择)是借此机会重构这段“遗留”代码,至少把这个 Exception 改成运行时异常。可以编写一些单元测试(如果缺少的话),然后改进实现,再把注意力转移到新需求的功能上。不过,为了本文的实验,这里假设 TwoDigitsInteger 类保持现状,并且 Exception 仍然是受检异常。

异常函数

让我们考虑一个非常简单的场景:有一个 TwoDigitsInteger 集合,目标是创建一个字符串表达式,表示这些数字的和。

$ java
List<TwoDigitsInteger> numbers = List.of(new TwoDigitsInteger(10), new TwoDigitsInteger(25), new TwoDigitsInteger(37));

如果像下面测试中那样编写代码,

$ java
@Test
void sumExpression() {
    String result = numbers.stream()
            .map(TwoDigitsInteger::getValue)
            .map(String::valueOf)
            .collect(Collectors.joining("+"));
    Assertions.assertEquals("10+25+37", result);
}

Java 编译器会报错,提示——Unhandled exception: com.hcd.utilities.NotSetException——因为 getValue() 方法声明了一个受检异常,显然它不能在流中使用。要解决这个问题,就需要 try-catch,这会让代码相当难以阅读(而且很难看)。更不用说,我们在遍历集合时还在修改 joiner 的状态。

$ java
@Test
void sumExpression1() {
    StringJoiner joiner = new StringJoiner("+");
    for (TwoDigitsInteger number : numbers) {
        try {
            joiner.add(String.valueOf(number.getValue()));
        } catch (NotSetException e) {
            throw new RuntimeException(e);
        }
    }
    String result = joiner.toString();
    Assertions.assertEquals("10+25+37", result);
}

为了克服这个问题,并在存在受检 Exception 的情况下仍能拥有流畅的 API,我们创建了下面的 ExceptionFunction 接口。

$ java
@FunctionalInterface
public interface ExceptionFunction<T, R, E extends Exception> {
    R apply(T t) throws E;
}

它的通用性足够强;它表示一个函数,接受一个参数(类型为 T),产生一个结果(类型为 R),并且在应用时可能抛出一个 Exception 子类(类型为 E)。实现者需要定义单个方法,用于实际应用该函数。此外,还定义了以下类。

$ java
public final class ExceptionWrapper {
    public static <T, R> Function<T, R> apply(ExceptionFunction<T, R, Exception> function) {
        return t -> {
            try {
                return function.apply(t);
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        };
    }

    ExceptionWrapper() {
        throw new UnsupportedOperationException("No need to be called.");
    }
}

当调用 ExceptionWrapper#apply() 方法时,如果抛出了 Exception,它会被包装成 RuntimeException 并继续抛出,而不论初始异常的类型是什么(受检 Exception 的情况显然也覆盖到了,所以没问题)。作为参数传入的 ExceptionFunction 表示被包装起来以解决这个问题的初始调用。前面讨论过的测试被修改为使用 ExceptionWrapper#apply() 方法。现在它不仅能够成功编译和运行,而且代码可读性肯定也得到了提升。

$ java
@Test
void sumExpression() {
    String result = numbers.stream()
            .map(ExceptionWrapper.apply(TwoDigitsInteger::getValue))
            .map(String::valueOf)
            .collect(Collectors.joining("+"));
    Assertions.assertEquals("10+25+37", result);
}

异常谓词

现在让我们考虑另一个简单的场景:我们只想统计指定范围内有效的两位数整数。同样,为了本文的实验,这里假设仍使用前面的 TwoDigitsInteger 类。和前一种情况一样,下面这段本可以完成任务的代码无法编译,原因相同——Unhandled exception: com.hcd.utilities.NotSetException——因为 isValid() 方法声明了一个受检异常,而它不能在流中使用。

$ java
long count = IntStream.range(0, 150)
        .mapToObj(TwoDigitsInteger::new)
        .filter(TwoDigitsInteger::isValid)
        .count();

同样,假设必须使用 TwoDigitsInteger,那么人们就不得不遍历这些数字,在 try-catch 中检查受检的 NotSetException,因为 isValid() 声明了它,然后把该 Exception 包装成 RuntimeException 并继续抛出,最后统计有效数字。即使只是用自然语言列举这些步骤,也已经太复杂了。为了在进行声明了受检 Exception 的检查时仍能保持 API 流畅并使用流,我们声明了下一个接口。

$ java
@FunctionalInterface
public interface ExceptionPredicate<T, E extends Exception> {
    boolean test(T t) throws E;
}

它表示一个谓词(返回布尔值的函数),接受一个参数,并且可能抛出一个 Exception 子类。该方法在给定参数上评估谓词,如果输入参数匹配则返回 true,否则返回 false。此外,下面这个方法被添加到 ExceptionWrapper 类中,它与 apply() 方法非常相似。

$ java
public static <T> Predicate<T> test(ExceptionPredicate<T, Exception> predicate) {
    return t -> {
        try {
            return predicate.test(t);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    };
}

调用时,它会实际应用所提供的谓词。如果抛出了 Exception,它会被包装成 RuntimeException 并继续抛出。现在,初始代码可以改写如下,并成功编译和执行。

$ java
@Test
void count() {
    long count = IntStream.range(0, 150)
            .mapToObj(TwoDigitsInteger::new)
            .filter(ExceptionWrapper.test(TwoDigitsInteger::isValid))
            .count();
    Assertions.assertEquals(90, count);
}

要点

尽管本文给出的解决方案简单直接,但它非常实用,尤其是在处理那些声明了受检 Exception 并进一步用于我们所编写代码中的函数时。当然,已经存在其他开箱即用的替代方案,例如 Lombok 的 @SneakyThrows 注解。就我个人而言,我很少在任何项目中引入 Lombok 库;而且随着 Java 引入 record,未来这样做就更不可能了。话虽如此,本文描述的结构非常有帮助、轻量,并且在需要时易于理解和使用。ExceptionWrapperExceptionFunctionExceptionPredicate 的源代码是 asentinel-orm 开源项目的一部分。要使用它,可以在 pom.xml 文件中声明 Maven 依赖(截至本文撰写时,1.72.2 是最新版本)

$ xml
<dependency>
    <groupId>com.asentinel.common</groupId>
    <artifactId>asentinel-common</artifactId>
    <version>1.72.2</version>
</dependency>

或者,如果觉得引入整个库开销太大,也可以直接使用它。

资源

[1] -- asentinel-orm 开源 ORM 项目在这里 [2] -- 图片拍摄于伦敦附近的“哈利波特华纳兄弟影城”