尽管我们希望编写完美且无错误的代码,但这几乎是不可能的任务。这就是为什么我们需要一种处理代码中不可避免问题的方法。Java选择使用异常来处理这种中断和异常的控制流条件。
异常处理可能很棘手,即使在命令式和面向对象的代码中也是如此。然而,将异常与函数式方法结合起来可能是一个真正的挑战,因为这些技术充满了考虑和要求。虽然有第三方库可以帮助处理异常,但从长远来看,您可能不希望仅仅依赖它们,因为这会因为新的依赖关系而带来技术债务,而不是全面适应更为函数式的方法。
本章将向您展示不同类型的异常及其对使用lambda进行函数式编程的影响。您将学习如何在lambda中处理异常,以及在函数式环境中处理控制流中断的替代方法。
Java异常处理简介
通常情况下,异常是程序执行过程中发生的特殊事件,它会中断正常的指令流程。这个概念存在于许多不同的编程语言中,不仅仅是Java,并且可以追溯到Lisp的起源。
异常的实际处理形式取决于编程语言的不同。
try-catch代码块
Java选择的机制是try-catch块,它是该语言的一个核心元素:
try {
return doCalculation(input);
} catch (ArithmeticException e) {
this.log.error("Calculation failed", e);
return null;
}
自它首次引入以来,这个概念的整体理念有所演变。现在,您可以使用多个异常类型之间的 |(管道)符号在一个多重捕获块中捕获多个异常,而不再需要多个catch块:
try {
return doCalculation(input);
} catch (ArithmeticException | IllegalArgumentException e) {
this.log.error("Calculation failed", e);
return null;
}
如果您需要处理资源,可以使用try-with-resources结构,它会自动关闭任何实现了AutoCloseable接口的资源:
try (var fileReader = new FileReader(file);
var bufferedReader = new BufferedReader(fileReader)) {
var firstLine = bufferedReader.readLine();
System.out.println(firstLine);
} catch (IOException e) {
System.err.println("Couldn't read first line of " + file);
}
无论您使用哪种变体,最终都会出现一个异常,它通过从抛出异常的位置跳转到调用栈上最近的catch点,或者如果没有catch点可用,则导致当前线程崩溃,从而中断了代码的执行流程。
异常和错误的不同类型
在Java中,有三种不同类型的控制流中断,它们在代码处理方面有不同的要求:已检查异常、未检查异常和错误。
已检查异常
已检查异常是在正常控制流之外预期并可能可恢复的事件。例如,您应该始终预期可能出现文件缺失(FileNotFoundException)或无效的URL(MalformedURLException)等情况。由于它们是预期的,因此必须遵守Java的捕获或指定要求,即在使用可能抛出已检查异常的方法时,必须显式地在方法签名中声明该异常或使用try-catch
块进行处理。
未检查异常
另一方面,未检查异常则是未被预期的,通常是无法恢复的,例如:
- 不支持的操作会引发UnsupportedOperationException
- 无效的数学计算会引发ArithmeticException
- 遇到空引用会引发NullPointerException
这些异常不被视为方法的公共契约的一部分,而是表示在破坏了任何假定的契约前提条件时会发生什么。因此,这些异常不受捕获或指定要求的限制,通常方法也不会使用throws关键字表示它们,即使在已知某些条件下方法会抛出这些异常。
然而,如果不想让程序崩溃,仍然需要以某种形式处理未检查异常。如果在本地未处理,异常会自动沿着当前线程的调用栈向上查找适当的处理程序。如果没有找到处理程序,线程将终止。对于单线程应用程序,运行时将终止,程序将崩溃。
错误
第三种控制流中断是错误(Errors),它指示了严重的问题,在正常情况下不应该捕获或处理。
例如,如果运行时内存耗尽,运行时会抛出OutOfMemoryError。或者,无限递归调用最终会导致StackOverflowError。当内存无法使用时,无论是堆还是栈,都无法采取任何措施。故障硬件是Java错误的另一个来源,例如磁盘错误会导致java.io.IOError。这些都是严重的、不可预测的问题,几乎没有恢复的可能性。这就是为什么错误不需要遵守捕获或指定要求的原因。
Java中的异常层次结构
一个异常属于哪个类别取决于它的基类。除了继承自java.lang.RuntimeException或java.lang.Error的类型外,所有异常都是已检查的。但它们共享一个共同的基类:java.lang.Throwable。继承自后两者的类型要么是未检查异常,要么是错误。类型层次结构如图10-1所示。
在编程语言中,拥有不同类型的异常的概念相对较少见,由于处理它们的不同要求,这是一个有争议的讨论话题。例如,Kotlin继承了处理异常的通用机制,但没有任何已检查的异常。
Lambdas中的已检查异常
Java的异常处理机制是在其诞生之时设计的,比lambda表达式引入的时间早了18年。因此,在新的函数式Java编码风格中,抛出和处理异常并不容易,除非特别考虑或完全忽略捕获或指定的要求。
让我们来看看使用java.util.Files上的一个静态方法加载文件内容,该方法的签名如下:
public static String readString(Path path) throws IOException {
// ...
}
方法的签名非常简单,表明可能会抛出已检查的IOException,因此需要使用try-catch块。这就是为什么该方法不能作为方法引用或在简单的lambda中使用的原因:
Stream.of(path1, path2, path3)
.map(Files::readString)
.forEach(System.out::println);
// Compiler Error:
// incompatible thrown types java.io.IOException in functional expression
问题源于满足map操作所需的函数式接口。JDK的函数式接口中没有一个会抛出已检查的异常,因此与任何会抛出异常的方法不兼容。
最明显的解决方案是使用try-catch块,将lambda表达式转换为基于块的形式:
Stream.of(path1, path2, path3)
.map(path -> {
try {
return Files.readString(path);
} catch (IOException e) {
return null;
}
})
.forEach(System.out::println);
满足编译器要求的代码反而违背了Stream流的lambda表达式的初衷。异常处理所需的样板代码削弱了操作的简洁性和直观性。 在lambda表达式中使用异常几乎像是一种反模式。throws声明表示调用者必须决定如何处理该异常,而lambda表达式没有专门处理异常的方式,除了预先存在的try-catch块,而这不能用于方法引用。
尽管如此,在不丢失(大部分)简洁性和清晰性的情况下,仍然有某些处理异常的方式,例如lambda表达式、方法引用和Stream流或Optional等管道提供的方式:
- 安全方法提取
- 解除异常检查
- Sneaky throws(隐藏的异常)
所有这些选项都是在函数式代码中缓解异常处理的不完美解决方案。然而,我们将对它们进行讨论,因为在某些情况下它们可能很有用,特别是当您没有内置的适当处理异常的方式时。 最后两种甚至可能具有欺骗性,或者至少在不明智地使用时可能成为代码异味。然而,了解这些"最后手段"工具可以帮助您处理更复杂的既有非函数式代码,并为您提供更具函数式的方法。
安全方法提取
在功能性代码中高效处理异常取决于谁有效地控制或拥有该代码。如果抛出异常的代码完全在您的控制之下,您应该始终正确处理异常。但通常,有问题的代码不是您的,或者您无法根据需要进行更改或重构。这时,您仍然可以将其提取到一个“更安全”的方法中,并进行适当的局部异常处理。
创建一个“安全”的方法将实际工作与处理任何异常解耦,恢复了调用者负责处理任何已检查异常的原则。任何功能性代码都可以使用安全方法,如示例10-1所示。
String safeReadString(Path path) {
try {
return Files.readString(path);
} catch (IOException e) {
return null;
}
}
Stream.of(path1, path2, path3)
.map(this::safeReadString)
.filter(Objects::nonNull)
.forEach(System.out::println);
流水线变得简洁而直观。IOException在某种意义上得到处理,不会影响流水线,但这种方法并非“一刀切”。
提取的安全方法可能比在lambda中使用try-catch块更好,因为您保留了内联lambda和方法引用的表达能力,并有机会处理任何异常。但是,处理工作被限制在对现有代码的另一个抽象层中,以恢复对破坏性控制流条件的控制。实际调用方法(流操作)无法处理异常,使得处理变得不透明且缺乏灵活性。
解除异常检查
处理受检异常的下一种方法与最初使用受检异常的根本目的相悖。而不是直接处理受检异常,您将其隐藏在一个非受检异常中,以绕过catch-or-specify的要求。这是一种荒谬但有效的方法,可以让编译器满意。
该方法使用了专门的函数式接口,这些接口使用throws关键字来包装有问题的lambda表达式或方法引用。它捕获原始异常并将其重新抛出为非受检的RuntimeException或其兄弟异常之一。这些函数式接口扩展了原始接口以确保兼容性。原始的单个抽象方法使用默认实现将其与抛出异常的方法连接起来,如示例10-2所示。
@FunctionalInterface
public interface ThrowingFunction<T, R> extends Function<T, R> {
R applyThrows(T elem) throws Exception;
@Override
default U apply(T t) {
try {
return applyThrows(t);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public static <T, R> Function<T, R> uncheck(ThrowingFunction<T, R> fn) {
return fn::apply;
}
}
ThrowingFunction类型可以通过调用uncheck方法来显式使用,也可以像示例10-3中所示那样隐式使用。
ThrowingFunction<Path, String> throwingFn = Files::readString;
Stream.of(path1, path2, path3)
.map(ThrowingFunction.uncheck(Files::readString))
.filter(Objects::nonNull)
.forEach(System.out::println);
恭喜,编译器再次满意,并且不再强制您处理异常。封装类型并没有解决可能的控制流中断的根本问题,而是将其隐藏起来。如果发生任何异常,流管道仍然会崩溃,而且没有可能进行局部异常处理。
Sneaky Throws
Sneaky throws是一种技巧,可以在方法的签名中不使用throws关键字声明的情况下抛出已检查的异常。 与在方法体中使用throw关键字抛出已检查的异常,这需要在方法签名中声明throws不同,实际的异常是由另一个方法抛出的,如下所示:
String sneakyRead(File input) {
// ...
if (fileNotFound) {
sneakyThrow(new IOException("File '" + file + "' not found."));
}
// ...
}
实际的异常抛出被委托给sneakyThrow方法。 等一下,使用抛出已检查异常的方法(如sneakyThrow)的任何人都必须遵守catch-or-specify要求吗? 好吧,规则有一个例外(双关语打算)。你可以利用Java 8中关于泛型和异常的类型推断的一项变化。简而言之,如果在带有throws E的通用方法签名中没有上界或下界,编译器将假定类型E是RuntimeException。这使您可以创建以下sneakyThrow:
<E extends Throwable> void sneakyThrow(Throwable e) throws E {
throw (E) e;
}
无论实际的参数e是什么类型,编译器都会假定throws E为RuntimeException,从而使该方法免于catch-or-specify要求。编译器可能不会抱怨,但这种方法存在很大问题。
sneakyRead的方法签名不再表示其已检查的异常。已检查的异常应该是可以预期和可恢复的,因此属于方法的公共合同。通过删除throws关键字并规避catch-or-specify要求,您减少了传递给调用者的信息量,使方法的公共合同更加不透明,以方便起见。您仍然可以(也应该)在方法的文档中列出所有异常及其原因。
该方法不再遵循“正常的推理”,因为它绕过了throws关键字和catch-or-specify要求的强制执行。阅读代码的任何人都必须知道sneakyThrow的作用。您可以在调用后添加适当的return语句,以至少表明这是一个退出点。但是,抛出关键字所表达的含义已经丢失了。
偷偷地抛出异常可能是内部代码的一个可以接受的最后手段,但您仍然需要借助上下文、方法名称和文档来传达其含义。在下一节中,我将为您展示在内部代码的专用实现中偷偷地抛出异常的一个可接受的用例。
异常的函数式方法
到目前为止,我只讨论了如何通过忽视和绕过异常的预期目的,来"强制"让Java的异常处理机制与lambda协同工作。实际上,需要找到一种合理的折衷方案,平衡函数式方法和更传统的结构之间的关系。
你可以选择设计你的代码,尽量避免抛出异常,或者模仿其他更函数式语言的异常处理方法。
不抛出异常
Checked Exceptions 是方法合约的重要组成部分,它们被设计为控制流中断的一部分。这也是处理它们变得如此困难的原因!因此,与其寻找更好的处理 checked Exceptions 和所有相关复杂性的方法,我们可以在功能性上下文中寻找另一种处理控制流中断的替代方式。
之前讨论的"安全方法提取"是一种不抛出异常的变体,它通过将抛出异常的方法包装成一个不抛出异常的"更安全"方法。这种方法在你无法控制代码且无法设计其不抛出任何异常时很有帮助。它用另一个值(Optional)代替了以异常形式出现的控制流中断事件,来表示异常状态。如果你对 API 有控制权,你可以设计合约时不使用异常,或者至少让异常更易于处理。异常是对某种非法状态的反应。避免异常处理的最佳方法是在首次出现非法状态时使其表示变得不可能。
在第9章中,我讨论了 Optional 是一个用于包装实际值的容器。它是一种特殊的类型,用于表示值的存在或不存在,而无需担心遇到 null 引用和可能产生的令人头痛的 NullPointerException。
让我们再次看一下之前的示例。这次,我们将使用 Optional 而不是抛出异常,如示例 10-4 所示。
Optional<String> safeReadString(Path path) {
try {
var content = Files.readString(path);
return Optional.of(content);
} catch (IOException e) {
return Optional.empty();
}
}
返回 Optional 相对于简单返回 String 有两个优点。首先,返回了一个有效的对象,因此不需要额外的空指针检查来安全使用它。其次,Optional 类型是处理内部值或其不存在的流畅功能性流水线的起点。
如果你的 API 不暴露任何需要控制流中断的非法状态,你或者调用这种方法的任何人都不需要处理它们。Optionals 是一个简单且易于使用的选择,尽管它们缺少一些理想的功能。新的 safeReadString 方法表明它无法读取文件,但不告诉你为什么无法读取。
错误作为值
与Optional只提供值的存在与缺失之间的差异不同,专用的结果对象传达了更多关于操作失败原因的信息。表示操作整体结果的专用类型的概念并不新鲜。它们是包装对象,指示操作是否成功,并包含一个值或者,如果操作失败,包含失败的原因。许多语言支持动态元组作为返回类型,因此你不需要像Go语言那样使用显式类型来表示你的操作:
func safeReadString(path string) (string, error) {
// ...
}
content, err := safeReadString("location/content.md")
if err != nil {
// error handling code
}
即使Java没有这样的动态元组,由于泛型的存在,我们可以创建一个多功能且面向函数的结果类型,利用本书讨论的工具和概念。 让我们一起创建一个基本的Result<V, E extends Throwable>类型。
创建框架
Result 类的主要目标是保存可能的值,或者如果操作不成功,则保存表示失败原因的异常。 传统的结果对象可以作为 Record 来实现,示例如下(Example 10-5):
public record Result<V, E extends Throwable>(V value,
E throwable,
boolean isSuccess) {
public static <V, E extends Throwable> Result<V, E> success(V value) {
return new Result<>(value, null, true);
}
public static <V, E extends Throwable> Result<V, E> failure(E throwable) {
return new Result<>(null, throwable, false);
}
}
即使这个简单的骨架已经比使用 Optionals 有了一定的改进,而且方便的工厂方法是创建适当结果的表达方式。 之前的 safeReadString 的示例可以轻松转换为使用 Result<V, E> 类型,如下所示(Example 10-6):
Result<String, IOException> safeReadString(Path path) {
try {
return Result.success(Files.readString(path));
} catch (IOException e) {
return Result.failure(e);
}
}
Stream.of(path1, path2, path3)
.map(this::safeReadString)
.filter(Result::isSuccess)
.forEach(System.out::println);
这个新类型在 Stream 流水线中的使用和 Optional 一样简单。但是,真正的力量来自于通过引入依赖于成功状态的高阶函数,为它赋予更多的函数特性。
让 Result<V, E> 变得函数式化
Optional 类型的通用特性是对如何进一步改进 Result 类型的灵感来源,包括:
- 转换其值或异常
- 对异常进行反应
- 提供备用值
要转换值或 throwable 字段,需要专门的 map 方法或一个组合方法来同时处理两种情况,如示例 10-7 所示。
public record Result<V, E extends Throwable> (V value,
E throwable,
boolean isSuccess) {
// ...
public <R> Optional<R> mapSuccess(Function<V, R> fn) {
return this.isSuccess ? Optional.ofNullable(this.value).map(fn)
: Optional.empty();
}
public <R> Optional<R> mapFailure(Function<E, R> fn) {
return this.isSuccess ? Optional.empty()
: Optional.ofNullable(this.throwable).map(fn);
}
public <R> R map(Function<V, R> successFn,
Function<E, R> failureFn) {
return this.isSuccess ? successFn.apply(this.value)
: failureFn.apply(this.throwable);
}
}
有了 mapper 方法的帮助,现在你可以直接处理一个或两个情况:
// HANDLE ONLY SUCCESS CASE
Stream.of(path1, path2, path3)
.map(this::safeReadString)
.map(result -> result.mapSuccess(String::toUpperCase))
.flatMap(Optional::stream)
.forEach(System.out::println);
// HANDLE BOTH CASES
var result = safeReadString(path).map(
success -> success.toUpperCase(),
failure -> "IO-Error: " + failure.getMessage()
);
还需要一种在不要求转换值或异常的情况下处理 Result 的方法。 为了对某种状态做出反应,让我们添加 ifSuccess、ifFailure 和 handle 方法:
public record Result<V, E extends Throwable> (V value,
E throwable,
boolean isSuccess) {
// ...
public void ifSuccess(Consumer<? super V> action) {
if (this.isSuccess) {
action.accept(this.value);
}
}
public void ifFailure(Consumer<? super E> action) {
if (!this.isSuccess) {
action.accept(this.throwable);
}
}
public void handle(Consumer<? super V> successAction,
Consumer<? super E> failureAction) {
if (this.isSuccess) {
successAction.accept(this.value);
} else {
failureAction.accept(this.throwable);
}
}
}
这些方法的实现几乎与 mapper 方法相同,只是它们使用 Consumer 而不是 Function。
接下来,让我们添加一些方便的方法来提供备用值。最明显的方法是 orElse
和 orElseGet
:
public record Result<V, E extends Throwable>(V value,
E throwable,
boolean isSuccess) {
// ...
public V orElse(V other) {
return this.isSuccess ? this.value
: other;
}
public V orElseGet(Supplier<? extends V> otherSupplier) {
return this.isSuccess ? this.value
: otherSupplier.get();
}
}
没有什么意外的地方。 然而,添加一个 orElseThrow
作为一个重新抛出内部 Throwable
的快捷方式并不那么直接,因为它仍然必须遵守 catch-or-specify 的要求。实际上,这是先前讨论的关于在“Sneaky Throws”中使用 sneaky throw 的一个可接受的用例,以规避这一要求:
public record Result<V, E extends Throwable>(V value,
E throwable,
boolean isSuccess) {
// ...
private <E extends Throwable> void sneakyThrow(Throwable e) throws E {
throw (E) e;
}
public V orElseThrow() {
if (!this.isSuccess) {
sneakyThrow(this.throwable);
return null;
}
return this.value;
}
}
在这种特定情况下,我认为使用 sneaky throw 是合理的,因为它符合 orElseThrow
的整体上下文和公共契约。就像 Optional<T>
类型一样,该方法强制解包可能的结果,并通过其名称提醒您可能发生的异常。 还有很多可以改进的地方,比如添加一个 Stream<V> stream()
方法,以更好地集成到 Stream 流水线中。然而,这个通用的方法是如何结合函数式概念,提供一种处理破坏性控制流事件的替代方案的很好的实践。本书中展示的实现非常简单,代码量很小。
如果您打算使用类似 Result<V, E>
的类型,您应该查看 Java 生态系统中的函数式库之一。像 vavr、jOOλ(发音为“JOOL”)和 Functional Java 这样的项目提供了相当全面和经过实战验证的实现,可以直接使用。
Try/Success/Failure模式
Scala可以说是Java在JVM上最接近的函数式语言,不考虑Clojure,因为Clojure具有更外来的语法和动态类型系统。它解决了Java在年轻语言中的许多被认为的缺点,并且从其核心开始就是函数式的,包括处理异常情况的绝佳方式。
尝试/成功/失败模式及其相关类型Try[+T]、Success[+T]和Failure[+T]是Scala以更函数式的方式处理异常的方式。 Optional指示值可能缺失,而Try[+T]则可以告诉您为什么缺失,并且让您能够处理发生的任何异常,类似于本章前面讨论的Result类型。如果代码成功,将返回Success[+T]对象,如果失败,错误将包含在Failure[+T]对象中。Scala还支持模式匹配,一种类似于处理不同结果的开关概念。这使得异常处理简洁而直观,避免了Java开发人员通常遇到的样板代码。
Try[+T]可以处于Success[+T]或Failure[+T]状态,后者包含一个Throwable。即使对Scala的语法不是很了解,示例10-8中的代码对于Java开发人员来说应该不会太陌生。
def readString(path: Path): Try[String] = Try {
// code that will throw an Exception
}
val path = Path.of(...);
readString(path) match {
case Success(value) => println(value.toUpperCase)
case Failure(e) => println("Couldn't read file: " + e.getMessage)
}
Try[+A]是Scala的一个出色特性,将类似于Optional和异常处理的概念结合成一个简单易用的类型和惯用法。但作为Java开发人员,这对你意味着什么呢?
Java本身并没有提供任何类似于Scala的try/success/failure模式的简洁性和语言集成性。
即使没有语言支持,你仍然可以使用自Java 8以来的新功能工具来实现try/success/failure模式的近似实现。让我们现在开始做这件事。
创建一个流水线
与流(Streams)提供函数式流水线的启动平台类似,我们要创建的 Try 类型将具有创建步骤、中间但独立的操作,以及最后的终端操作来启动流水线。
为了复制 Scala 的功能,我们需要一个接受 lambda 表达式作为起点的结构。
Try 类型的主要要求包括:
- 接受可能引发异常的 lambda 表达式
- 提供成功操作
- 提供失败操作
- 以某个值开始流水线
Try 类型可以简化为仅支持 RuntimeException,但那样的话它就不是常规 try-catch 块的灵活替代方案了。为了绕过 catch-or-specify 要求,我们使用了在“取消检查异常”中讨论过的 ThrowingFunction 接口。
下面是接受 ThrowingFunction 和一个可能处理 RuntimeException 的 Function 的最小结构示例(参见示例 10-9)。
public class Try<T, R> {
private final Function<T, R> fn;
private final Function<RuntimeException, R> failureFn;
public static <T, R> Try<T, R> of(ThrowingFunction<T, R> fn) {
Objects.requireNonNull(fn);
return new Try<>(fn, null);
}
private Try(Function<T, R> fn,
Function<RuntimeException, R> failureFn) {
this.fn = fn;
this.failureFn = failureFn;
}
}
尽管该类型目前还没有实现任何功能,但从现有的 lambda 表达式或方法引用创建一个新的流水线非常简单,如下所示:
var trySuccessFailure = Try.<Path, String> of(Files::readString);
在 of 调用前面的类型提示是必需的,因为编译器不能从周围的上下文中推断出类型。 接下来,该类型需要处理成功和失败的情况。
处理成功和失败的情况
需要两个新方法来处理Try流水线的结果,分别是success和failure,如下所示(参见示例10-10):
public class Try<T, R> {
// ...
public Try<T, R> success(Function<R, R> successFn) {
Objects.requireNonNull(successFn);
var composedFn = this.fn.andThen(successFn);
return new Try<>(composedFn, this.failureFn);
}
public Try<T, R> failure(Function<RuntimeException, R> failureFn) {
Objects.requireNonNull(failureFn);
return new Try<>(this.fn,
failureFn);
}
}
由于Try类型被设计为不可变的,因此两个处理方法都返回Try的新实例。success方法使用函数组合来创建完全所需的任务,而failure方法使用预先存在的lambda和提供的错误处理函数创建一个新的Try实例。
通过使用函数组合来处理成功操作,而不是使用额外的控制路径(例如将successFn存储在另一个字段中),在初始lambda的结果没有修改的情况下,甚至不需要使用处理程序。 使用处理方法就像你期望的那样,并且感觉类似于使用Stream的中间操作:
var trySuccessFailure = Try.<Path, String> of(Files::readString)
.success(String::toUpperCase)
.failure(str -> null);
不过,与Stream不同的是,这些操作彼此独立,而不是在顺序管道中执行。它更类似于Optionals管道,看起来是顺序的,但实际上有要遵循的轨道。要评估哪个处理操作(成功或失败)取决于Try评估的状态。
现在是启动管道的时候了。
启动管道
完成管道所需的最后一个操作是能够将值推送到管道中,并让处理程序执行它们的工作,以 apply 方法的形式呈现,如示例 10-11 所示。
public class Try<T, R> {
// ...
public Optional<R> apply(T value) {
try {
var result = this.fn.apply(value);
return Optional.ofNullable(result);
}
catch (RuntimeException e) {
if (this.failureFn != null) {
var result = this.failureFn.apply(e);
return Optional.ofNullable(result);
}
}
return Optional.empty();
}
}
返回类型 Optional 为功能管道提供了另一个起点。 现在,我们的简化版 Try 管道拥有了调用可抛出方法并处理成功和失败情况所需的所有操作:
var path = Path.of("location", "content.md");
Optional<String> content = Try.<Path, String> of(Files::readString)
.success(String::toUpperCase)
.failure(str -> null)
.apply(path);
尽管 Try 管道提供了处理可抛出 lambda 的高阶函数操作,但管道本身在外部并不是函数式的。或者说它是吗?
我选择的终端操作名称 apply 揭示了 Try 可能实现的函数式接口,以便更容易在其他函数式管道(如 Streams 或 Optionals)中使用:Function<T, Optional>。 通过实现这个函数式接口,Try 类型可以作为任何函数的即插即用替代品,而不需要实际的逻辑更改,如示例 10-12 所示。
public class Try<T, R> implements Function<T, Optional<R>> {
// ...
@Override
public Optional<R> apply(T value) {
// ...
}
}
现在,任何 Try 管道都可以轻松地在接受 Function 的任何高阶函数中使用,就像在 Stream 的 map 操作中一样:
Function<Path, Optional<String>> fileLoader =
Try.<Path, String> of(Files::readString)
.success(String::toUpperCase)
.failure(str -> null);
List<String> fileContents = Stream.of(path1, path2, path3)
.map(fileLoader)
.flatMap(Optional::stream)
.toList();
与之前的 Result 类型一样,Try 类型非常简洁,应被视为将函数式概念组合以创建新构造的练习,例如由高阶函数组成的延迟流畅管道。如果您想使用类似 Try 的类型,应考虑使用一个成熟的函数式第三方库,如 vavr,它提供了多功能的 Try 类型以及其他功能。
对函数式异常处理的最终思考
在我们的代码中,不可避免地会出现中断和异常的控制流条件,这就是为什么我们需要一种处理它们的方式。异常处理有助于提高程序的安全性。例如,捕获或指定的要求旨在让您思考预期的异常状态并相应处理它们,以提高代码质量。虽然这确实很有用,但也很棘手。
无论使用何种异常处理方法,处理异常在Java中都可能是一个痛点。无论您选择哪种异常处理方法,都存在权衡取舍,特别是如果涉及到受检异常:
- 提取不安全的方法以获得局部化的异常处理是一种很好的折中方案,但并不是一个易于使用的通用解决方案。
- 设计API以不产生任何异常状态并不像听起来那么容易。
- 取消勾选异常是一种“最后手段”,它将异常隐藏起来,无法处理,与其目的相矛盾。
那么应该怎么办呢?嗯,这要看具体情况。 没有一种解决方案是完美的。您必须在“便利性”和“可用性”之间找到平衡。异常有时被过度使用,但它们仍然是程序控制流的重要信号。从长远来看,隐藏它们可能不符合您的最佳利益,即使生成的代码更简洁合理,只要没有异常发生。 并非每个命令式或面向对象的特性/技术在Java中都可以用函数式等效物来替代。许多Java的(函数式)缺点是可以规避的,以获得其一般优势,即使生成的代码不如完全函数式编程语言那样简洁。然而,异常是那些在大多数情况下不容易替代的特性之一。它们通常表明您应该尝试重构代码使其“更具函数性”,或者函数式方法可能不是解决问题的最佳方案。
另外,有几个第三方库可供选择,例如 Vavr 项目或 jOOλ,它们允许您在使用(受检)异常的函数式Java代码中绕过或至少减轻问题。它们已经实现了所有相关的包装接口,并复制了来自其他语言(如模式匹配)的控制结构和类型。但最终,您将得到高度专门化的代码,试图让Java按照其意愿行事,而不太关注传统或常见的代码结构。对第三方库的依赖是一项长期承诺,不应轻率添加。