作为Java开发人员,您可能遇到过大量的NullPointerException以及其他相关问题。许多人称null引用为一个价值数十亿美元的错误。事实上,null的发明者本人最初就曾使用这个词汇:
我称之为我价值数十亿美元的错误。 这是在1965年发明的null引用。当时,我正在设计第一个完整的面向对象语言(ALGOL W)中的引用类型系统。我的目标是确保所有引用的使用都是绝对安全的,由编译器自动执行检查。但是,我无法抵制诱惑,只是因为null引用的实现非常简单,所以我加入了它。 这导致了无数的错误、漏洞和系统崩溃,可能在过去的四十年里造成了数十亿美元的损失和破坏。
——查尔斯·安东尼·理查德·霍尔爵士(2009年伦敦QCon演讲)
虽然对于如何处理这个"错误"并没有绝对的共识,但许多编程语言都有一种适当和惯用的处理null引用的方式,通常直接集成到语言本身中。 本章将向您展示Java如何处理null引用,以及如何通过Optional类型及其函数式API来改进代码,学习何时使用Optional以及何时不使用。
空引用的问题
Java对于值的缺失的处理取决于类型。所有的基本数据类型都有默认值,例如,数值类型的默认值是零,布尔类型的默认值是false。非基本类型,比如类、接口和数组,在未被赋值时使用null作为它们的默认值,意味着该变量没有引用任何对象。
一个null引用不仅仅是"空",它是一种特殊的状态,因为null是一个广义类型,可以用于任何对象引用,不管实际的类型如何。如果尝试访问一个null引用,JVM会抛出NullPointerException,如果不适当处理,当前线程将崩溃。这通常可以通过防御性编程方法来缓解,在运行时需要在每个地方进行null检查,就像示例9-1中所示。
record User(long id, String firstname, String lastname) {
String fullname() {
return String.format("%s %s",
firstname(),
lastname());
}
String initials() {
return String.format("%s%s",
firstname().substring(0, 1),
lastname().substring(0, 1));
}
}
var user = new User(42L, "Ben", null);
var fullname = user.fullname();
// => Ben null
var initials = user.initials();
// => NullPointerException
前面的示例突出了处理null时的两个主要问题。 首先,null引用对于变量、参数和返回值来说是有效的值。这并不意味着null是预期的、正确的,甚至是可接受的值,并且可能无法正确处理。例如,在上一个示例中,对user调用fullname时,对lastname使用null引用可以正常工作,但输出结果“Ben null”很可能不是预期的结果。因此,即使您的代码和数据结构在表面上可以处理null值,您仍然可能需要检查它们以确保正确的结果。
null引用的第二个问题是它们的一个主要特点:类型不确定性。它们可以表示任何类型,而实际上并不是该特定类型。这种独特的属性是必要的,因此一个关键字可以在您的代码中代表“值的缺失”的广义概念,而不需要为不同的对象类型使用不同的类型或关键字。即使null引用可以像它表示的类型一样使用,但它仍然不是该类型本身,如示例9-2所示。
// "TYPE-LESS" NULL AS AN ARGUMENT
methodAcceptingString(null);
// ACCESSING A "TYPED" NULL
String name = null;
var lowerCaseName = name.toLowerCase();
// => NullPointerException
// TEST TYPE OF NULL
var notString = name instanceof String;
// => false
var stillNotString = ((String) name) instanceof String;
// => false
这些是null的最明显的问题点。不用担心,有方法可以减轻痛苦。
如何在Java中处理null(在使用Optional之前)
在Java中处理null是每个开发人员工作的重要且必要的一部分,尽管它可能有些麻烦。遇到意外和未处理的NullPointerException是许多问题的根本原因,必须相应地处理。
其他编程语言,比如Swift,提供了专门的运算符和惯用语,如安全导航2或null合并运算符3,以便更轻松地处理null。然而,Java并没有提供这样的内置工具来处理null引用。
在引入Optional之前,有三种不同的处理null引用的方式:
- 最佳实践:遵循一些最佳实践,例如进行null检查和正确处理可能为null的引用。
- 工具辅助的null检查:使用工具来进行null检查,例如静态代码分析工具或IDE中的代码检查功能。
- 类似于Optional的专门类型:使用自定义的特殊类型来处理null引用,这些类型提供了类似Optional的功能。
正如你将在后面看到的那样,在处理null引用时不应仅依赖于Optional。Optional是JDK中提供的一种标准化和现成可用的专门类型,它是前面提到的技术的很好补充。然而,它并不是处理代码中所有null的最终思考方式,了解所有可用的技术对你的技能工具包是有价值的补充。
处理null的最佳实践
如果一种编程语言没有提供集成的null处理机制,你就必须采用最佳实践和非正式规则来使你的代码具备防空能力。这就是为什么许多公司、团队和项目会制定自己的编码风格或者根据自身需求调整现有的风格,为编写一致和更安全的代码提供指南,不仅仅是针对null。通过遵循这些自我规定的实践和规则,他们能够始终编写出更可预测、更少错误的代码。
你不必开发或者调整一份全面的代码风格指南来定义你的Java代码的每个方面。相反,遵循以下四个规则是处理null引用的一个很好的起点。在接下来的部分,我将介绍四个规则作为处理null引用的起点。
不要将变量初始化为null
变量应始终具有非空值。如果该值取决于一个决策块(比如if-else语句),你应该考虑将其重构为一个方法,或者如果是简单的决策,可以使用三元运算符:
// DON'T
String value = null;
if (condition) {
value = "Condition is true";
} else {
value = "Fallback if false";
}
// DO
String asTernary = condition ? "Condition is true"
: "Fallback if false";
String asRefactored = refactoredMethod(condition);
另一个好处是,如果你之后不重新赋值,这使得变量实际上是不可变的,因此你可以将它们作为 lambda 表达式中的外部变量使用。
不要传递、接受或返回null
由于变量不应为null,因此任何参数和返回值也应避免为null。通过重载方法或构造函数可以避免非必需参数为null的情况:
public record User(long id, String firstname, String lastname) {
// DO: Additional constructor with default values to avoid null values
public User(long id) {
this(id, "n/a", "n/a");
}
}
如果由于相同的参数类型而导致方法签名冲突,您始终可以使用更明确名称的静态方法来解决。 在为非必需值提供特定方法和构造函数之后,如果合适,您不应该在原始方法中接受null。最简单的方法是使用java.util.Objects上可用的静态requireNonNull方法:
public record User(long id, String firstname, String lastname) {
// DO: Validate arguments against null
public User {
Objects.requireNonNull(firstname);
Objects.requireNonNull(lastname);
}
}
requireNonNull调用会进行null检查,并在适当的情况下抛出NullPointerException。自Java 14起,任何NullPointerException都包含了空变量的名称,这要归功于JEP 358。如果您想包含特定的消息或针对先前的Java版本,可以将字符串作为调用的第二个参数添加进去。
检查您无法控制的一切
即使您遵守自己的规则,也不能依赖他人也这样做。使用非熟悉的代码,尤其是如果文档中没有明确说明,应始终假定可能为空,并需要进行检查。
在实现细节方面,可以接受使用 null
在代码的公共界面中避免使用 null 是很重要的,但作为实现细节仍然是合理的。在内部,一个方法可能会根据需要使用 null,只要它不会将其返回给调用者即可。
何时遵循规则,何时不遵循规则
这些规则的目的是在代码交互时尽可能减少使用 null,例如 API 表面,因为减少暴露会减少所需的空引用检查和可能的 NullPointerException。但这并不意味着您完全应该避免使用 null。例如,在局部变量或非公共 API 等隔离的上下文中,使用 null 并不是那么问题,甚至可能简化代码,只要使用得明确和谨慎。
您不能期望每个人都遵循您的规则或同样勤奋,所以在编写代码时需要保护性考虑,特别是在您无法控制的情况下。这更加需要始终坚持最佳实践,并鼓励他人也这样做。这将提高您整体的代码质量,不论是关于空引用还是其他方面。但这不是万能的解决方案,需要团队的纪律性才能获得最大的好处。手动处理 null 并添加一些空引用检查比因为假设某个东西“永远”不会为 null 而遇到 NullPointerException 更可取。即使 JIT 编译器在运行时具有更多的知识,它也会执行“+null+ check elimination”以从优化的汇编代码中删除许多显式的空引用检查。
工具辅助的空引用检查
使用第三方工具来自动执行这些最佳实践和非正式规则是一个合理的延伸。在Java中,对于空引用,一个已经确立的最佳实践是使用注解来标记变量、参数和方法返回类型,分别为@Nullable或@NonNull。
在使用这些注解之前,唯一可以记录可空性的地方是JavaDoc。通过添加这些注解到你的代码中,静态代码分析工具可以在编译时发现可能的空引用问题。更重要的是,将这些注解添加到你的方法签名和类型定义中,可以更清晰地表达如何使用它们以及对其的期望,如示例 9-3所示。
interface Example {
@NonNull List<@Nullable String> getListOfNullableStrings();
@Nullable List<@NonNull String> getNullableListOfNonNullStrings();
void doWork(@Nullable String identifier);
}
然而,JDK并没有包含这些注解,并且对应的JSR 305状态自2012年以来一直处于“休眠”状态。尽管如此,它仍然是事实上的社区标准,并且被许多库、框架和集成开发环境广泛采用。有几个库提供了缺失的注解,并且大多数工具支持多种变体。
工具辅助方法的一般问题在于对工具本身的依赖性。如果工具干扰过大,你可能会得到无法运行的代码,特别是如果该工具在“幕后”生成代码。然而,在与null相关的注解的情况下,你不必太担心。即使没有工具解释这些注解,你的代码仍然可以运行,而且你的变量和方法签名仍然可以清楚地向任何使用它们的人传达它们的要求,即使未被强制执行。
像Optional这样的专门类型
工具辅助方法可以在编译时进行空指针检查,而专门的类型可以在运行时提供更安全的空指针处理。在Java引入自己的Optional类型之前,这种缺失功能的差距由不同的库来填补,例如自2011年起由Google Guava框架提供的基本Optional类型。
尽管现在JDK中有一个集成的解决方案,但Guava在可预见的未来不计划弃用该类。然而,他们温和地建议您尽可能使用新的标准Java Optional。
Optional来拯救
Java 8的新Optional不仅仅是一个专门处理null的类型,它还是一个类似函数式的流水线,可以受益于JDK中提供的所有函数式扩展。
Optional是什么?
Optional类型的最简单的理解方式是将其视为一个包含实际值的盒子,该值可能为null。您可以使用这个盒子来传递可能为null的引用,而不是直接传递可能为null的引用,如图9-1所示。
这个盒子为其内部的值提供了一个安全的包装。不过,Optional不仅仅是简单地包装一个值。从这个盒子开始,您可以构建复杂的调用链,这些调用链依赖于值的存在或缺失。它们可以管理可能值的整个生命周期,直到这个盒子被解开,包括在这样的调用链中如果没有值的情况下提供备用方案。
然而,使用包装器的缺点是如果您想使用内部值,就必须实际查看和访问盒子内部。类似于流(Streams),额外的包装器也会在方法调用和额外的堆栈帧方面产生不可避免的开销。另一方面,这个盒子为处理可能为null的常见工作流程提供了额外的功能,使代码更加简洁和直观。
作为示例,让我们来看一下通过标识符加载内容的工作流程。图9-2中的数字对应于示例9-5中即将出现的代码。
这个工作流程简化了,并没有处理所有的边界情况,但它是将一个多步骤工作流转化为Optional调用链的一个直观示例。在示例9-4中,您首先看到了没有使用Optional的实现工作流程。
public Content loadFromDB(String contentId) {
// ...
}
public Content get(String contentId) {
if (contentId == null) {
return null;
}
if (contentId.isBlank()) {
return null;
}
var cacheKey = contentId.toLowerCase();
var content = this.cache.get(cacheKey);
if (content == null) {
content = loadFromDB(contentId);
}
if (content == null) {
return null;
}
if (!content.isPublished()) {
return null;
}
return content;
}
这个示例有些夸张,但仍然基本上反映了一种典型的防御性空值处理方法。
代码中有三个显式的空值检查,加上两个关于当前值的决策和两个临时变量。尽管代码量不多,但是整体流程由于存在许多if块和早期返回语句,难以理解。
让我们将代码转换为一个单一的Optional调用链,就像示例9-5中所示的那样。不要担心!接下来的章节将详细解释不同类型的操作。
public Optional<Content> loadFromDB(String contentId) {
// ...
}
public Optional<Content> get(String contentId) {
return Optional.ofNullable(contentId)
.filter(Predicate.not(String::isBlank))
.map(String::toLowerCase)
.map(this.cache::get);
.or(() -> loadFromDB(contentId))
.filter(Content::isPublished);
}
Optional调用链将整体代码压缩为每行一个操作,使整个流程更容易理解。它完美地凸显了使用Optional调用链和传统的空值检查方式之间的区别。
让我们来看一下创建和使用Optional流水线的步骤。
创建Optional流水线
从Java 17开始,Optional类型提供了3个静态方法和15个实例方法,它们属于四个不同的组,代表了Optional流水线的不同部分:
- 创建一个新的Optional实例。
- 检查值的存在与否,或者根据值的存在与否做出相应的反应。
- 对值进行过滤和转换。
- 获取值,或者提供备选方案。
这些操作可以构建一个流畅的流水线,类似于Streams。然而,与Streams不同的是,它们在流水线中添加类似终端操作之前并不会被惰性地连接起来,就像我在《Streams as Functional Data Pipelines》中所讨论的那样。每个操作在被添加到流畅的调用链时就会立即执行。Optional之所以看起来是惰性的,是因为它们可能返回一个空的Optional或备选值,并且完全跳过转换或过滤步骤。然而,这并不意味着调用链本身是惰性的。然而,如果遇到null值,无论操作数量如何,执行的工作都会尽可能地最小化。
您可以将Optional调用链看作是两个平行的铁轨,如图9-3所示。
在这个比喻中,我们有两条火车轨道:Optional调用链轨道,它会返回一个带有内部值的Optional,以及“空的快车轨道”,它会返回一个空的Optional。
火车始终从Optional调用火车轨道开始。当它遇到一个轨道开关(Optional操作)时,它会查找null值,如果找到,火车将切换到空的快车轨道。一旦进入快车轨道,就没有机会回到Optional调用链轨道,至少在Java 9之前是这样的,您将在《获取(备选)值》中看到。
从技术上讲,在切换到空的快车轨道后,它仍然会调用Optional调用链上的每个方法,但只会验证参数并继续执行。如果火车在到达终点之前没有遇到null值,它将返回一个非空的Optional。如果火车在路线的任何地方遇到null值,它将返回一个空的Optional。
为了让火车行驶起来,让我们创建一些Optionals。
创建Optional
Optional类型没有可用的公共构造函数。相反,它提供了三个静态工厂方法来创建新的实例。使用哪种方法取决于您的用例和对内部值的先验知识:
- Optional.ofNullable(T value):如果值可能为null
如果您知道一个值可能为null或者不关心它是否可能为空,可以使用ofNullable方法创建一个新实例,该实例可能具有内部的null值。这是创建Optional最简单、最可靠的方式。
String hasValue = "Optionals are awesome!";
Optional<String> maybeValue = Optional.ofNullable(hasValue);
String nullRef = null;
Optional<String> emptyOptional = Optional.ofNullable(nullRef);
- Optional.of(T value):如果值必须为非null
尽管Optional是处理null和防止NullPointerException的好方法,但如果您确保有一个值怎么办?例如,您已经在代码中处理了任何边界情况(返回了空的Optional),现在您肯定有一个值。of方法确保值非null,否则将抛出NullPointerException。这样,异常表示您的代码中存在真正的问题。也许您忽略了一个边界情况,或者某个外部方法调用发生了变化,现在返回null了。在这种情况下使用of方法可以使您的代码更具未来性,并且能够抵御行为不受欢迎的变化。
var value = "Optionals are awesome!";
Optional<String> mustHaveValue = Optional.of(value);
value = null;
Optional<String> emptyOptional = Optional.of(value);
// => throws NullPointerException
- Optional.empty():如果没有值
如果您已经知道根本没有值,可以使用empty方法。调用Optional.ofNullable(null)是不必要的,因为在调用empty本身之前会进行多余的null检查。
Optional<String> noValue = Optional.empty();
使用Optional.ofNullable(T value)可能是最宽容null的创建方法,但您应该努力使用最适合的方法来表示您的用例和上下文知识。代码可能会随着时间的推移进行重构或重写,如果代码在需要值的情况下突然丢失了一个值,最好让它抛出NullPointerException作为额外的安全保护,即使API本身使用Optionals。
检查值并作出反应
Optional旨在包装一个值并表示其存在或不存在。它们作为Java类型实现,因此是运行时级别的特性,并且会产生与对象创建相关的无法避免的开销。为了弥补这一点,检查值应该尽可能简单直接。
有四个方法可用于检查值并对其存在或不存在作出反应。它们的前缀为"is"表示检查,"if"表示反应式高阶函数:
- boolean isPresent()
- boolean isEmpty() (Java 11+)
仅检查值的存在有其用途,但如果您需要检查、检索和使用值,当使用"is"方法时,需要三个单独的步骤。
这就是为什么高阶的"if"方法直接消费一个值:
- void ifPresent(Consumer<? super T> action)
- void ifPresentOrElse(Consumer<? super T> action, Runnable emptyAction)
这两个方法仅在值存在时执行给定的操作。第二个方法在没有值存在时运行emptyAction。不允许使用null操作,并会抛出NullPointerException。没有相应的ifEmpty方法可用。
让我们看一下如何在示例9-6中使用这些方法。
Optional<String> maybeValue = ...;
// VERBOSE VERSION
if (maybeValue.isPresent()) {
var value = maybeValue.orElseThrow();
System.out.println(value);
} else {
System.out.println("No value found!");
}
// CONCISE VERSION
maybeValue.ifPresentOrElse(System.out::println,
() -> System.out.println("No value found!"));
由于缺乏返回类型,ifPresent方法都执行仅产生副作用的代码。尽管在函数式编程方法中,纯函数通常更可取,但Optional在接受函数式代码和适应命令式代码之间存在一定的平衡点。
Filtering 和 mapping
安全处理可能的null值已经从开发者身上减轻了相当大的负担,但是Optional不仅仅允许检查值的存在与否。
与Streams类似,您可以构建一个具有中间操作的流水线。有三个用于过滤和映射Optional的操作:
- Optional filter(Predicate<? super T> predicate)
- Optional map(Function<? super T, ? extends U> mapper)
- Optional flatMap(Function<? super T, ? extends Optional<? extends U>> mapper)
filter操作在存在值且满足给定谓词的情况下返回this。如果不存在值或者谓词不匹配该值,则返回一个空的Optional。
map操作使用提供的映射函数对存在的值进行转换,返回一个包含映射值的新的可空Optional。如果不存在值,则该操作返回一个空的Optional。
flatMap在映射函数返回Optional而不是具体类型U的情况下使用。如果在这种情况下使用map,返回值将是Optional<Optional>。因此,flatMap直接返回映射值而不是将其包装成另一个Optional。
示例9-7展示了一个Optional调用链以及用于虚构的权限容器及其子类型的非Optional等效版本。代码标注附加在两个版本上,以显示相应的操作,但它们的描述适用于Optional版本。
public record Permissions(List<String> permissions, Group group) {
public boolean isEmpty() {
return permissions.isEmpty();
}
}
public record Group(Optional<User> admin) {
// NO BODY
}
public record User(boolean isActive) {
// NO BODY
}
Permissions permissions = ...;
boolean isActiveAdmin =
Optional.ofNullable(permissions)
.filter(Predicate.not(Permissions::isEmpty))
.map(Permissions::group)
.flatMap(Group::admin)
.map(User::isActive)
.orElse(Boolean.FALSE);
需要解决的底层问题的每个步骤都以清晰、独立和直接连接的方式呈现出来。任何验证和决策,例如null或空检查,都包含在基于方法引用的专用操作中。问题的意图和流程清晰可见,容易理解。
如果没有使用Optional,进行相同操作将导致嵌套的代码混乱,如示例9-8所示。
boolean isActiveAdmin = false;
if (permissions != null && !permissions.isEmpty()) {
if (permissions.group() != null) {
var group = permissions.group();
var maybeAdmin = group.admin();
if (maybeAdmin.isPresent()) {
var admin = maybeAdmin.orElseThrow();
isActiveAdmin = admin.isActive();
}
}
}
这两个版本之间的差异非常明显。 非Optional版本无法委托任何条件或检查,并且依赖于显式的if语句。这会创建深度嵌套的流程结构,增加了代码的圈复杂度。很难理解代码块的整体意图,并且与Optional调用链相比不够简洁。
获取(备用)值
Optionals可以提供对可能为null的值的安全封装,但在某些情况下,您可能需要实际的值。有多种方法可以获取Optional的内部值,从"强制获取"到提供备用值不等。
第一种方法不涉及任何安全检查: T get() Optional会被强制解包,如果没有值存在,则会抛出NoSuchElementException异常,因此请确保在获取值之前检查值是否存在。
接下来的两种方法在没有值时提供备用值: T orElse(T other) T orElseGet(Supplier<? extends T> supplier) 基于Supplier的变体允许延迟获取备用值,如果创建备用值需要消耗资源,则非常有用。
有两种方法可以抛出异常: T orElseThrow(Supplier<? extends X> exceptionSupplier) T orElseThrow() (Java 10+) 尽管Optional的主要优势之一是防止NullPointer异常,但有时如果没有值存在,仍然需要一个特定领域的异常。通过orElseThrow操作,您可以精细地控制如何处理缺少的值以及要抛出的异常。第二种方法orElseThrow被添加为语义上正确且更受推荐的get操作的替代方法。尽管调用不如简洁,但它更适合整体命名方案,并传达了可能会抛出异常的含义。
Java 9添加了另外两种方法,用于提供另一个Optional作为备用或Stream。这些方法允许比以前更复杂的调用链: 第一个方法Optional or(Supplier<? extends Optional<? extends T>> supplier)如果没有值存在,则在延迟情况下返回另一个Optional。通过这种方式,即使在调用or之前没有值存在,您也可以继续Optional的调用链。回到“列车轨道”的类比,or操作是一种通过在Optional调用链轨道上创建一个新的起点来提供从空的快速轨道返回的轨道切换的方法。
另一个方法Stream stream()返回一个包含值作为唯一元素的Stream,如果没有值存在,则返回一个空的Stream。它通常在中间Stream操作flatMap中作为方法引用使用。Optional的stream操作在与我在第7章中讨论的Stream API的互操作性中发挥更广泛的作用。
Optionals 和 Streams
如前几章中讨论的那样,流(Streams)是将元素进行过滤和转换以得到期望结果的流水线。当作为元素使用时,Optionals可以很好地适应,并作为可能为null的引用的函数式封装,但它们必须遵守流水线的规则,并将自身的状态传递给流(Stream)。
Optionals 作为 Stream 的元素
在流(Streams)中,通过使用过滤操作来排除元素,进而将它们从进一步的处理中剔除。实质上,Optionals本身就代表了一种过滤操作,尽管与流(Streams)期望元素的行为不直接兼容。 如果一个流(Stream)元素被过滤操作排除,它将不会进一步遍历流(Stream)。可以通过将Optional::isPresent作为过滤操作的参数来实现这一点。然而,在存在内部值的情况下,得到的流(Stream),即Stream<Optional>,并不是我们想要的。
为了恢复“正常”的流(Stream)语义,需要将流(Stream)从Stream<Optional>映射为Stream,就像在示例9-9中所示。
List<Permissions> permissions = ...;
List<User> activeUsers =
permissions.stream()
.filter(Predicate.not(Permissions::isEmpty))
.map(Permissions::group)
.map(Group::admin)
.filter(Optional::isPresent)
.map(Optional::orElseThrow)
.filter(User::isActive)
.toList();
在流(Streams)中,过滤和映射Optional是Optionals的一个标准用例,以至于Java 9为Optional类型添加了stream方法。它返回一个Stream,如果存在内部值,则该流(Stream)将以其作为唯一元素,否则返回一个空的Stream。这使得通过使用流(Stream)的flatMap操作而不是专用的filter和map操作,将Optionals和流(Streams)的功能结合起来成为最简洁的方法,就像在示例9-10中所示。
List<Permissions> permissions = ...;
List<User> activeUsers =
permissions.stream()
.filter(Predicate.not(Permissions::isEmpty))
.map(Permissions::group)
.map(Group::admin)
.flatMap(Optional::stream)
.filter(User::isActive)
.toList();
一个flatMap调用取代了先前的filter和map操作。即使你只节省了一个方法调用(一个flatMap代替了filter和map操作),结果的代码更容易理解,更好地展示了所期望的工作流程。flatMap操作传达了理解流(Stream)管道所需的所有必要信息,而不需要额外的步骤增加任何复杂性。处理Optionals是必要的,应该尽可能简洁,以便整个流(Stream)管道可理解和直观。
没有理由设计API时不使用Optionals,仅为了避免在流(Streams)中使用flatMap操作。如果Group::getAdmin返回null,你仍然需要在另一个filter操作中添加空检查。用filter操作替换flatMap操作并没有带来任何好处,除了admin调用现在需要在之后明确处理空值,即使从其签名中不再明显。
终端流操作
在流中使用Optional并不局限于中间操作。流API中的五个终端操作返回一个Optional,以提供其返回值的改进表示。所有这些操作都试图查找元素或缩减流。对于空流来说,这些操作需要一个合理的缺席值的表示。Optional正是这个概念的典型,因此使用Optional而不是返回null是合乎逻辑的选择。
查找元素
在Stream API中,前缀"find"代表根据元素的存在来查找。根据其名称,可能已经猜到了,有两个具有不同语义的find操作,取决于流是并行还是串行:
- Optional findFirst()
如果流为空,则返回一个流的第一个元素的Optional,如果流为空,则返回一个空的Optional。在并行流和串行流之间没有区别。如果流缺乏相遇顺序,则可能返回任何元素。
- Optional findAny()
返回一个流的任意元素的Optional,如果流为空,则返回一个空的Optional。为了在并行流中最大化性能,返回的元素是非确定性的。在大多数情况下,将返回第一个元素,但不能保证这种行为!因此,如果需要一致的返回元素,请改用findFirst。
find操作仅在存在的概念上工作,因此您需要事先相应地过滤流元素。如果您只想知道特定元素是否存在,并且不需要元素本身,则可以使用相应的"match"方法之一:
- boolean anyMatch(Predicate<? super T> predicate)
- boolean noneMatch(Predicate<? super T> predicate)
这些终端操作包括过滤操作,并避免创建不必要的Optional实例。
将流缩减为单个值
将流通过组合或累积其元素到一个新的数据结构中进行缩减,是流的主要目的之一。就像查找操作一样,缩减操作也必须处理空流。
因此,流提供了三种终端缩减操作,其中一种返回一个Optional:Optional reduce(BinaryOperator accumulator)。
它使用提供的累加器运算符对流的元素进行缩减。返回值是缩减的结果,如果流为空,则返回一个空的Optional。 请参见官方文档中示例9-11的等效伪代码示例。
Optional<T> pseudoReduce(BinaryOperator<T> accumulator) {
boolean foundAny = false;
T result = null;
for (T element : elements]) {
if (!foundAny) {
foundAny = true;
result = element;
} else {
result = accumulator.apply(result, element);
}
}
return foundAny ? Optional.of(result)
: Optional.empty();
}
另外两个reduce方法需要一个初始值来将流的元素组合起来,以便返回一个具体的值,而不是一个Optional。有关如何在流中使用它们的更详细解释和示例,请参见"缩减元素"。
除了通用的reduce方法外,还有两种常见的缩减用例作为方法提供:
- Optional min(Comparator<? super T> comparator)
- Optional max(Comparator<? super T> comparator)
这些方法根据提供的比较器返回"最小"或"最大"的元素,如果流为空,则返回一个空的Optional。 Optional是min/max方法唯一适合返回的类型。您无论如何都必须检查操作是否有结果。如果在Stream接口中添加带有回退值的额外min/max方法,会使接口变得混乱。通过返回的Optional,您可以轻松地检查结果是否存在,或者选择使用回退值或异常。
Optional原始类型
你可能会问为什么需要基本类型的Optional,因为基本类型变量永远不会为null。如果没有初始化,任何基本类型都有一个与其相应类型的零值等价的值。
尽管从技术上讲是正确的,但Optional并不仅仅是为了防止值为null。它们还表示一种真实的"无存在"状态,即基本类型所缺少的值的缺失。
在许多情况下,基本类型的默认值是足够的,例如表示网络端口:零是一个无效的端口号,所以您无论如何都必须处理它。然而,如果零是一个有效的值,那么表达它的实际缺失变得更加困难。 直接使用基本类型与Optional类型是行不通的,因为基本类型不能是泛型类型。然而,就像Stream一样,有两种使用基本值的Optional的方法:自动装箱或专用类型。
"基本类型"一文中强调了使用对象包装类及其引入的开销问题。另一方面,自动装箱也不是免费的。 通常的基本类型在java.util包中有专用的Optional变体:
- OptionalInt
- OptionalLong
- OptionalDouble
它们的语义几乎与通用的Optional相同,但它们不继承自Optional,也没有共同的接口。它们的功能也不完全相同,例如缺少了多个操作,如filter、map或flatMap。
基本类型的Optional可以消除不必要的自动装箱,从而提高性能,但缺乏Optional提供的完整功能。此外,与"基本流"一文讨论的基本流变体不同,没有简单地在基本Optional变体和相应的Optional变体之间进行转换的方法。 即使可以轻松地创建自己的包装类型来改进Optional值的处理,特别是对于基本类型,但在大多数情况下,我不建议这样做。对于内部或私有实现,您可以使用任何您想要或需要的包装类。但是,您的代码的公共接口应始终努力坚持使用最受期待和可用的类型。通常,这意味着使用JDK已经包含的类型。
注意事项
Optional通过提供一个多用途的"盒子"来容纳可能的空值以及(部分)功能性的API来构建处理值的存在或缺失的管道,极大地改善了JDK中的null处理。尽管这些优点无疑是有用的,但它们也带来了一些值得注意的缺点,您需要了解这些缺点,以正确地使用它们,避免任何意外的惊喜。
Optionals是普通类型
使用Optional及其基本类型变体的最明显的缺点是它们是普通类型。没有更深入地集成到Java语法中,比如Lambda表达式的新语法,它们会遇到与JDK中任何其他类型相同的空引用问题。
这就是为什么您仍然必须遵循最佳实践和非正式规则,以避免抵消使用Optional的好处。如果您设计了一个API并决定将Optional作为返回类型,那么在任何情况下都不应返回null!返回一个Optional清楚地表示使用该API的任何人将至少收到一个"盒子",该盒子可能包含一个值,而不是可能的null值。如果没有可能的值,始终使用一个空的Optional或相应的基本类型替代。
然而,这个基本的设计要求必须通过约定来实施。在没有额外工具(如Sonar)的情况下,编译器不会为您提供帮助。
识别敏感的方法
尽管Optionals是普通类型,但身份敏感的方法可能与您的期望有所不同。这包括引用相等运算符==(双等号)、使用hashCode方法或在线程同步中使用实例。
行为差异在于Optional是一个基于值的类型,这意味着它的内部值是其主要关注点。equals、hashCode和toString等方法仅基于内部值,忽略了实际对象的身份。这就是为什么您应该将Optional实例视为可互换的,并且不适合进行与身份相关的操作,如同步并发代码,正如官方文档中所述。
性能开销
在使用Optional时需要考虑的另一个因素是性能影响,特别是在其主要设计目标之外作为返回类型时。 Optional很容易(误)用于简单的空检查,并在没有内部值的情况下提供回退值:
// DON'T DO THIS
String value = Optional.ofNullable(maybeNull).orElse(fallbackValue);
// DON'T DO THIS
if (Optional.ofNullable(maybeNull).isPresent()) {
// ...
}
这样简单的Optional管道需要一个新的Optional实例,并且每次方法调用都会创建一个新的栈帧,这意味着JVM无法像简单的null检查那样轻松优化你的代码。创建Optional而没有额外的操作来检查存在性或提供备选方案并不太有意义。
使用类似三元运算符或直接的null检查的替代方案应该是你首选的解决方案:
// DO THIS INSTEAD
String value = maybeNull != null ? maybeNull
: fallbackValue;
// DO THIS INSTEAD
if (maybeNull != null) {
// ...
}
使用Optional而不是三元运算符可能看起来更好,也避免了重复使用maybeNull。然而,减少实例创建和方法调用的数量通常更可取。 如果你仍然希望有一个比三元运算符更美观的替代方案,Java 9引入了两个静态辅助方法在java.util.Objects上,用于检查null并提供备选值:
- T requireNonNullElse(T obj, T defaultObj)
- T requireNonNullElseGet(T obj, Supplier<? extends T> supplier)
备选值,或者在第二个方法的情况下,Supplier的结果,也必须是非null的。 相比于因为意外的NullPointerException而导致崩溃,节省一些CPU周期并无意义。就像使用流(Streams)一样,性能和更安全、更简单的代码之间存在权衡。你需要根据你的要求找到这些之间的平衡。
集合的特殊考虑因素
null是表示值不存在的技术表示。Optional为您提供了一种工具,可以安全地使用实际对象表示这种不存在,并允许进一步的转换、过滤等操作。然而,基于集合的类型已经可以表示其内部值的缺失。
集合类型已经是一个处理值的容器,因此将其包装在Optional中会创建另一个您必须处理的层级。空集合已经表示了内部值的缺失,因此使用空集合作为null的替代方案可以消除可能的NullPointerException,并且不需要使用Optional创建额外的层级。
当然,您仍然需要处理集合本身的缺失,即空引用。如果可能的话,您不应该在集合中使用null,无论是作为参数还是返回值。将代码设计为始终使用空集合而不是null将具有与Optional相同的效果。如果您仍然需要区分null和空集合,或者相关的代码不受您控制或无法更改,那么对null进行检查可能仍然优于引入另一个层级来处理。
Optionals和序列化
Optional类型和其原始类型的变体都没有实现java.io.Serializable接口,因此它们不适用于可序列化类型中的私有字段。这个设计决策是故意做出的,因为Optional应该提供可选的返回值的可能性,而不是作为一种通用的解决方案来处理null。让Optional可序列化会鼓励使用超出其预期设计目标的用例。
为了在对象中仍然享受Optional的好处并保持可序列化性,您可以在公共API中使用Optional,但在实现细节中使用非Optional字段,如示例9-12所示。
public class User implements Serializable {
private UUID id;
private String username;
private LocalDateTime lastLogin;
// ... usual getter/setter for id and username
public Optional<LocalDateTime> getLastLogin() {
return Optional.ofNullable(this.lastLogin);
}
public void setLastLogin(LocalDateTime lastLogin) {
this.lastLogin = lastLogin;
}
}
通过仅仅依赖于一个Optional在获取lastLogin的getter方法中,类型保持可序列化,同时仍然提供了Optional的API。
对于null引用的最终思考
尽管null被称为一个价值数十亿美元的错误,但它本身并不邪恶。null的发明者Charles Antony Richard Hoare认为,编程语言设计者应对使用其语言编写的程序中的错误负责。一个语言应该提供坚实的基础和丰富的创造力和控制能力。允许使用null引用只是Java的众多设计选择之一,没有更多的含义。正如在第10章中解释的,Java的捕获或指定要求和try-catch块为您提供了针对明显错误的工具。但由于null是任何类型的有效值,每个引用都有可能导致崩溃。即使您认为某个值永远不会是null,经验告诉我们,在某个时间点可能会发生。
null引用的存在并不意味着语言设计不好。null有其存在的场所,但它要求您对代码更加注意。这并不意味着您应该用Optional替换代码中的每个变量和参数。 Optional的目的是为可选的返回值提供有限的机制,因此不要仅仅因为它看起来方便就过度使用或误用它们。在您控制的代码中,您可以对引用的可能为null性做出更多假设和保证,并相应地处理,即使没有Optional。如果您遵循本书中强调的其他原则(例如,小型的、自包含的、没有副作用的纯函数),那么确保您的代码不会意外返回null引用就更容易了。