本节目标:能用
Optional表达「可能找不到」,会用map、flatMap、filter和orElseGet写链式查找,能说清orElse与orElseGet的求值差异,并理解 Stream 的短路如何避免无谓计算。
适用版本:JDK 25 LTS(Temurin 25.0.5);Optional.isEmpty、ifPresentOrElse、Optional.stream在 JDK 21 上同样可用。
上一节留下的问题
Stream 的 findFirst() 返回 Optional,仓储的 findById 也返回 Optional。但 Optional 本身不解决任何问题,它只是把「可能没有值」写进了类型。如果调用方仍然这样写:
Optional<Book> found = service.findBook("978-7-000-00000-0");
Book book = found.get();
那么「没有值」的检查只是从 null 换了个地方,运行时照样炸。另一种常见写法是先判空再取值:
if (found.isPresent()) {
System.out.println(found.get().title());
}
这能工作,但又回到了命令式的 if,Optional 的链式能力完全没用上。本节要解决的就是:怎么把「有值就转换、没值就兜底」写成一条表达式,以及兜底逻辑该不该提前执行。
Optional 是什么,不是什么
Optional<T> 是一个最多装一个值的容器:要么装着 T,要么是空的。它的主要用途是方法返回值,用来明确告诉调用方「这次查找可能没有结果」。
它不适合做这些事:
- 字段:类字段用可空引用或默认值,别用
Optional包一层;Optional不可序列化,很多框架也不喜欢它。 - 方法参数:参数用重载或可空类型表达,
Optional参数会强迫调用方包装。 - 集合元素:不要写
List<Optional<Book>>,直接过滤掉不存在的元素,用空列表表示「没有」。 - 基本类型:用
OptionalInt、OptionalLong、OptionalDouble,避免装箱。
一句话:Optional 是返回类型的工具,不是「所有可能为 null 的地方都换掉」的万能替换。
创建与判断
三种创建方式:
Optional<Book> present = Optional.of(book); // 不能为 null
Optional<Book> nullable = Optional.ofNullable(maybeNull); // 可以为 null
Optional<Book> empty = Optional.empty(); // 明确为空
Optional.of(null) 会立刻抛 NullPointerException:
Exception in thread "main" java.lang.NullPointerException
at java.base/java.util.Objects.requireNonNull(Objects.java:220)
at java.base/java.util.Optional.of(Optional.java:113)
ofNullable 则把 null 转成 Optional.empty()。判断有没有值,用 isPresent() / isEmpty();如果只是想在存在时做一件事,用 ifPresent:
service.findBook(isbn).ifPresent(book -> System.out.println(book.title()));
需要同时处理「有」和「没有」两个分支时,用 ifPresentOrElse:
service.findBook(isbn).ifPresentOrElse(
book -> System.out.println("present = " + book.title()),
() -> System.out.println("present = none"));
但大多数时候,你不需要显式判断,而是继续链式转换。
链式处理:map、flatMap、filter
map 在有值时转换,空时原样返回空:
Optional<String> title = service.findBook(isbn).map(Book::title);
flatMap 用于「转换函数本身返回 Optional」的情况。比如先找在借记录,再根据记录里的会员号找会员:
public Optional<String> borrowerOf(String isbn) {
return loans.findActiveByIsbn(isbn)
.flatMap(loan -> members.findById(loan.memberId()))
.map(Member::name);
}
如果这里用 map,会得到 Optional<Optional<Member>>;flatMap 把两层压成一层。判断标准很简单:转换函数返回 Optional,就用 flatMap;返回普通值,就用 map。
filter 在值不满足条件时把它变成空:
Optional<Book> recent = service.findBook(isbn)
.filter(book -> book.year() > 2015);
这三步可以任意组合。比如「找一本书,取标题,标题长度超过 10 才保留」:
Optional<String> longTitle = service.findBook(isbn)
.map(Book::title)
.filter(title -> title.length() > 10);
整条链在空值处自动短路:findBook 返回空,后面的 map 和 filter 都不会执行。
兜底:orElse、orElseGet、orElseThrow
有值就取,没值就兜底,有三种常用写法:
String a = optional.orElse("默认标题");
String b = optional.orElseGet(() -> loadDefaultTitle());
String c = optional.orElseThrow(() -> new BookNotFoundException(isbn));
三者的区别不只在返回值,还在兜底逻辑什么时候执行:
orElse(value)的参数是普通值,无论 Optional 是否为空,参数表达式都会先求值。orElseGet(Supplier)的参数是Supplier,只有 Optional 为空时才调用。orElseThrow(Supplier)只在为空时抛异常,适合「找不到就是错误」的场景。
用计数器可以直观看到差异:
AtomicInteger calls = new AtomicInteger();
String eager = Optional.of("value").orElse(compute("orElse(present)", calls));
System.out.println("eager = " + eager + ", calls = " + calls.get());
Optional.of("value").orElseGet(() -> compute("orElseGet(present)", calls));
System.out.println("lazy present, calls = " + calls.get());
String fallback = Optional.<String>empty()
.orElseGet(() -> compute("orElseGet(empty)", calls));
System.out.println("fallback = " + fallback + ", calls = " + calls.get());
本机 JDK 25 输出:
compute orElse(present)
eager = value, calls = 1
lazy present, calls = 1
compute orElseGet(empty)
fallback = fallback-orElseGet(empty), calls = 2
第一行 orElse 在 Optional 有值时仍然执行了 compute,白白算了一次;orElseGet 在有值时一次都没调用,只有为空时才执行。如果兜底逻辑有代价(查数据库、读文件、创建大对象),用 orElseGet。
orElseThrow 适合把「缺失」转成领域异常:
Book book = service.findBook(isbn)
.orElseThrow(() -> new BookNotFoundException(isbn));
第 13 章会把 BookNotFoundException 接入统一的异常体系。
Stream 的惰性与短路
12.2 说过中间操作是惰性的。惰性还有一个更实用的表现:短路。下面这条管道从一个无限流里找第一个 7 的倍数:
long first = Stream.iterate(1, n -> n + 1)
.peek(n -> System.out.println(" visit " + n))
.filter(n -> n % 7 == 0)
.findFirst()
.orElse(-1);
System.out.println("first multiple of 7 = " + first);
本机 JDK 25 输出:
visit 1
visit 2
visit 3
visit 4
visit 5
visit 6
visit 7
first multiple of 7 = 7
Stream.iterate 没有上限,但 findFirst 在拿到第一个结果后就停止,所以只访问了 1 到 7。如果换成 count(),这个程序永远不会结束——count 需要处理所有元素。
anyMatch 同样短路。检查有没有 2019 年以后出版的书:
boolean modern = DemoData.BOOKS.stream()
.peek(b -> System.out.println(" check " + b.title()))
.anyMatch(b -> b.year() > 2018);
System.out.println("anyMatch = " + modern);
输出:
check Effective Java
check Java Puzzlers
check Java 并发编程实战
check 深入理解 Java 虚拟机
anyMatch = true
示例数据里前四本都没满足,直到第四本《深入理解 Java 虚拟机》(2019)才返回 true,后面的两本没有被检查。这不是优化技巧,而是 Stream 的语义:终止操作只需要多少元素,就只计算多少元素。
Optional.stream() 可以把「可能为空的值」融进 Stream 管道,空的 Optional 不贡献元素:
List<String> titles = Stream.of(Optional.of("A"), Optional.<String>empty(),
Optional.of("B"))
.flatMap(Optional::stream)
.toList();
System.out.println(titles);
输出 [A, B]。处理「一批 id 里能查到的那些」时,这个写法比 filter(Optional::isPresent).map(Optional::get) 干净。
在 library 里落地
LibraryService 的查询方法直接返回 Optional,把「可能没有」交给调用方:
public Optional<Book> findBook(String isbn) {
return books.findById(isbn);
}
public Optional<String> borrowerOf(String isbn) {
return loans.findActiveByIsbn(isbn)
.flatMap(loan -> members.findById(loan.memberId()))
.map(Member::name);
}
findBook 只是转发仓储;borrowerOf 展示了 flatMap 的价值:在借记录和会员是两次可能失败的查找,用 flatMap 串起来,任何一步为空,整条链就是空,不需要嵌套 if。
CLI 层不应该把 Optional 继续往上传,而应该在这里转成用户能看懂的文本:
String line = service.findBook(isbn)
.map(book -> book.title() + " / " + book.author())
.orElse("未找到 ISBN 为 " + isbn + " 的图书");
System.out.println(line);
需要报错而不是兜底时,用 orElseThrow:
Book book = service.findBook(isbn)
.orElseThrow(() -> new BookNotFoundException(isbn));
跑一遍完整例子:
LibraryService service = DemoData.service();
String isbn = "978-7-111-54742-6";
System.out.println("found = " + service.findBook(isbn).map(Book::title));
System.out.println("missing = " + service.findBook("978-7-000-00000-0").map(Book::title));
System.out.println("upper = " + service.findBook(isbn)
.map(b -> b.title().toUpperCase()));
service.borrow(isbn, "M001");
System.out.println("borrower = " + service.borrowerOf(isbn));
try {
service.findBook("978-7-000-00000-0")
.map(Book::title)
.orElseThrow(() -> new BookNotFoundException("978-7-000-00000-0"));
} catch (BookNotFoundException e) {
System.out.println("caught = " + e.getMessage());
}
本机 JDK 25 输出:
found = Optional[Effective Java]
missing = Optional.empty
upper = Optional[EFFECTIVE JAVA]
borrower = Optional[张三]
caught = 找不到 ISBN 为 978-7-000-00000-0 的图书
注意 found 和 missing 打印的是 Optional[...] 和 Optional.empty,这是 Optional.toString() 的调试格式,不适合直接展示给用户。真正面向用户的输出要走 map(...).orElse(...) 或 ifPresentOrElse。
Optional 和 11.1 的 Result<T> 分工不同:
| 类型 | 表达什么 | 典型场景 |
|---|---|---|
Optional<T> | 有值 / 无值 | 按 id 查找、findFirst |
Result<T> | 成功值 / 失败消息 | 注册图书、校验输入 |
Optional 不携带失败原因,只有「空」;Result 能带一条消息。两者都不应该被滥用成控制流的唯一手段,第 13 章会讲什么时候该用异常。
常见错误与边界
错误一:不检查就 get()
Optional<String> value = Optional.empty();
System.out.println(value.get());
Exception in thread "main" java.util.NoSuchElementException: No value present
at java.base/java.util.Optional.get(Optional.java:143)
get() 是 Optional 里最不该直接用的方法。它把「可能为空」又变回了运行时异常。用 orElse、orElseGet、orElseThrow 或 ifPresent 替代。
错误二:用 isPresent + get 代替链式调用
if (service.findBook(isbn).isPresent()) {
Book book = service.findBook(isbn).get();
System.out.println(book.title());
}
这里查了两次,而且把 Optional 当成了 null 的包装。更好的写法是:
service.findBook(isbn).ifPresent(book -> System.out.println(book.title()));
或者需要返回值时:
String title = service.findBook(isbn).map(Book::title).orElse("未知");
错误三:在 orElse 里放昂贵操作
orElse(loadFromDatabase()) 无论 Optional 是否为空都会执行 loadFromDatabase()。这类 bug 不会报错,只会在监控里表现为「明明查到了,为什么还多了一次查询」。有副作用或开销的兜底一律用 orElseGet。
边界:Optional 不是 Serializable
Optional 没有实现 Serializable。把它放进需要序列化的 DTO、缓存值或 RPC 参数里,会在运行时失败。DTO 字段用可空引用,方法返回值才用 Optional。
小结
Optional<T>表示「有值或无值」,主要用作方法返回值,不适合字段、参数和集合元素。of不接受 null,ofNullable接受,empty表示明确为空;ifPresentOrElse同时处理两个分支。map转换普通值,flatMap转换返回 Optional 的查找,filter在不满足条件时清空。orElse的参数总会求值,orElseGet只在为空时调用,orElseThrow把缺失转成异常。- Stream 的惰性配合
findFirst、anyMatch可以短路无限流或长集合,只计算必要的元素。 Optional不携带失败原因;需要错误消息时用Result<T>,需要中断流程时用异常。
到这里,正常路径和「找不到」都处理完了。但还有些失败不是「没有值」,而是「规则不允许」:书已被借走、会员达到借阅上限、命令格式不对。下一章建立 LibraryException 体系,把这些情况从返回值里分出来。
阅读导航:上一节:12.2 Stream 管道 · 下一节:13.1 受检与非受检异常 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。