Java Optional 使用教程:查找结果、映射与默认值
查询用户时,至少有三种结果:用户不存在;用户存在但没填昵称;用户存在且昵称可用。如果把三种情况都压成一个 null,页面很容易把“用户不存在”显示成“未设置昵称”,后续代码也会到处补空判断。
Optional<User> 可以明确表达“查到了一个用户,或者没有查到”。用户内部哪些字段允许缺失,仍要另定规则。下面用一份本地用户目录完成查找、显示名称、提取邮箱和必填校验,顺便跑出 map、flatMap、orElseGet 最容易误会的行为。示例使用 JDK 17,没有数据库和邮件发送操作。
一份目录,保留两层缺失含义
保存为 UserDirectoryDemo.java。邮箱只做非空白检查,不冒充完整的地址格式验证;所有地址都是演示文本。
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
public class UserDirectoryDemo {
record User(String id, String nickname, String email) {
User {
if (id == null || id.isBlank()) throw new IllegalArgumentException("id is required");
}
Optional<String> nicknameValue() {
return Optional.ofNullable(nickname).map(String::strip)
.filter(value -> !value.isEmpty());
}
Optional<String> emailValue() {
return Optional.ofNullable(email).map(String::strip)
.filter(value -> !value.isEmpty());
}
}
static class MissingUser extends RuntimeException {
MissingUser(String id) { super("user not found: " + id); }
}
static class Directory {
private final Map<String, User> users = Map.of(
"u1", new User("u1", " 林同学 ", "lin@example.test"),
"u2", new User("u2", " ", null),
"u3", new User("u3", null, ""));
Optional<User> findById(String id) {
if (id == null || id.isBlank()) throw new IllegalArgumentException("id is required");
return Optional.ofNullable(users.get(id));
}
User requireById(String id) {
return findById(id).orElseThrow(() -> new MissingUser(id));
}
String label(String id) {
return findById(id)
.map(user -> user.nicknameValue().orElse("未设置昵称"))
.orElse("用户不存在");
}
Optional<String> email(String id) {
return findById(id).flatMap(User::emailValue);
}
}
static User createGuest(AtomicInteger calls) {
calls.incrementAndGet();
return new User("guest", "访客", null);
}
static void equal(Object expected, Object actual) {
if (!Objects.equals(expected, actual)) {
throw new AssertionError("expected=" + expected + " actual=" + actual);
}
}
static void fails(Class<? extends RuntimeException> type, Runnable action) {
try { action.run(); }
catch (RuntimeException error) {
if (type.isInstance(error)) return;
throw error;
}
throw new AssertionError("expected " + type.getSimpleName());
}
public static void main(String[] args) {
Directory directory = new Directory();
List<String> labels = List.of(directory.label("u1"), directory.label("u2"),
directory.label("missing"));
equal(List.of("林同学", "未设置昵称", "用户不存在"), labels);
System.out.println("LABELS=" + labels);
equal("lin@example.test", directory.email("u1").orElseThrow());
equal(Optional.empty(), directory.email("u2"));
equal(Optional.empty(), directory.email("missing"));
System.out.println("MAIL_U1=" + directory.email("u1").orElseThrow());
System.out.println("MAIL_U2=" + directory.email("u2").orElse("EMPTY"));
Optional<Optional<String>> nested = directory.findById("u2").map(User::emailValue);
equal(true, nested.isPresent());
equal(true, nested.orElseThrow().isEmpty());
System.out.println("NESTED_OUTER_PRESENT=" + nested.isPresent());
AtomicInteger eagerCalls = new AtomicInteger();
AtomicInteger lazyCalls = new AtomicInteger();
User eager = directory.findById("u1").orElse(createGuest(eagerCalls));
User lazy = directory.findById("u1").orElseGet(() -> createGuest(lazyCalls));
equal("u1", eager.id());
equal("u1", lazy.id());
equal(1, eagerCalls.get());
equal(0, lazyCalls.get());
System.out.println("DEFAULT_CALLS=eager:" + eagerCalls.get() + ",lazy:" + lazyCalls.get());
User guest = directory.findById("missing").orElseGet(() -> createGuest(lazyCalls));
equal("guest", guest.id());
equal(1, lazyCalls.get());
System.out.println("MISSING_LAZY_CALLS=" + lazyCalls.get());
equal(Optional.empty(), directory.findById("u3").map(User::nickname));
equal(Optional.empty(), Optional.ofNullable(null));
fails(NullPointerException.class, () -> Optional.of(null));
fails(NullPointerException.class, () -> directory.findById("u1").flatMap(user -> null));
fails(NoSuchElementException.class, () -> directory.findById("missing").get());
fails(MissingUser.class, () -> directory.requireById("missing"));
fails(IllegalArgumentException.class, () -> directory.findById(" "));
System.out.println("NULL_RULES_CHECKED");
System.out.println("ALL_CHECKS_PASSED");
}
}
编译后运行:
javac -encoding UTF-8 UserDirectoryDemo.java
java UserDirectoryDemo
预期输出:
LABELS=[林同学, 未设置昵称, 用户不存在]
MAIL_U1=lin@example.test
MAIL_U2=EMPTY
NESTED_OUTER_PRESENT=true
DEFAULT_CALLS=eager:1,lazy:0
MISSING_LAZY_CALLS=1
NULL_RULES_CHECKED
ALL_CHECKS_PASSED
目录中的 u2 是真实存在的对象,所以外层 Optional 有值。它的邮箱没有填写,所以 emailValue 返回空 Optional。label 则先在用户内部补“未设置昵称”,再在外部补“用户不存在”,没有丢掉两层含义。
例如 u2 的 label:外层先查到 User,进入 map;内层空白昵称被 filter 丢弃,orElse 补成“未设置昵称”;map 得到的已经是一个非空字符串,外层最后的“用户不存在”便不会使用。若改成先 flatMap(User::nicknameValue) 再统一补默认名称,用户不存在和昵称缺失又会合并。链条短了,不代表保留的信息仍然相同。
map 做转换,flatMap 接上一个已经返回 Optional 的方法
findById("u1").map(User::nickname) 把 Optional
但空字符串不会自动变为空结果,空白字符串也不会。nicknameValue 先 strip,再 filter 非空,才把“只输入空格”纳入未填写的规则。如果业务允许昵称保留首尾空格,就不能照搬这个清理步骤。
emailValue 自己已经返回 Optional
换成 flatMap 后,两个步骤会连成一条链,结果仍是 Optional
filter 也不是参数校验异常的替代品。它只在有值时判断条件,不满足就返回空结果;如果编号格式错误需要明确报错,应像 findById 那样在查找前拒绝,而不是把错误输入过滤成“没有用户”。字段空白能否被视为未填写,是这个目录制定的规则;必填字段校验失败则可以保留单独的异常分支。
还有一个区别值得单独核对:map 的转换结果为 null,会得到空 Optional;flatMap 的转换函数必须返回一个 Optional 对象,返回 null 会抛 NullPointerException。返回 Optional.empty() 才是在这个合同下表达缺失,不能为了省一次包装直接 return null。
默认对象为什么已经创建了
有值时,orElse(createGuest(calls)) 最终仍返回 u1,但计数器已经加了 1。Java 在调用 orElse 之前就需要求出实参,创建访客的方法因而已经执行。orElse 不是把一段“将来可能要执行的代码”接收进去。
orElseGet 接受 Supplier,只有缺失时才调用。程序第一次查 u1,lazyCalls 保持 0;后来查 missing,才创建一次访客并变成 1。若默认值只是固定文本,orElse 很直接;若默认动作要查库、计算、写日志,甚至具有副作用,应明确判断它是否应该只在缺失时执行。
延迟调用也不等于缓存。对同一个空 Optional 连续调用两次 orElseGet,每次都可能执行传入的 Supplier。需要复用默认对象时,应在业务允许的范围内自己保存它,不要把 Optional 当成记忆化工具。
Supplier 自己抛出的异常仍会传出,Optional 不会替它改成空结果。类似地,map 里的转换如果失败,也不是自动跳到 orElse。这里的默认值处理的是缺失,不是把任意异常吞掉后的备用答案;默认读取若涉及外部服务,失败处理仍要按那次调用的合同设计。
缺失是正常分支,还是本次操作必须失败
列表页显示名称时,缺少昵称是正常状态;修改指定用户时,目标用户不存在通常要终止操作。requireById 用 orElseThrow 选择后一种合同,把缺失转成带用户编号的业务异常。它与先 get 再等 NoSuchElementException 不同,异常类型和上下文都是明确的。
Optional.of 适合已经保证不为 null 的值;可能为 null 的查询结果用 ofNullable。不能先 Optional.of(users.get(id)) 再考虑缺失分支,因为包装时就可能失败。方法一旦声明返回 Optional,调用者也应始终拿到非 null 的 Optional,不能遇到特殊情况又返回裸 null。
这套目录没有把 Optional 放进用户字段,也没有把它直接序列化成接口响应。它在这里负责表达查询结果,让调用方选择默认显示或必须存在的分支。若当前操作要求“用户存在,而且邮箱已填写”,可以在 main 方法末尾追加下面这段代码,沿用已经创建的 directory,重新编译运行:
String recipient = directory.requireById("u2").emailValue()
.orElseThrow(() -> new IllegalArgumentException("email is required"));
这一次程序预期以异常结束:u2 会在最后一步抛出“email is required”;换成不存在的编号,则会先抛 MissingUser,两个错误不会再混在一起。