原创

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 转成 Optional 。用户不存在时,不会调用读取昵称的方法;昵称为 null 时,map 结果也变成空 Optional。因此 u3 不会因为读取 nickname 而直接空指针。

但空字符串不会自动变为空结果,空白字符串也不会。nicknameValue 先 strip,再 filter 非空,才把“只输入空格”纳入未填写的规则。如果业务允许昵称保留首尾空格,就不能照搬这个清理步骤。

emailValue 自己已经返回 Optional 。再用 map 调它,就得到 Optional<Optional >,外层有值只代表查到了 User,不能据此外推邮箱也存在。程序故意保留 nested,实际打印出外层为 true、内层为空的情况。

换成 flatMap 后,两个步骤会连成一条链,结果仍是 Optional 。不过这会合并“没用户”和“没邮箱”两种缺失:Directory.email 对两者都返回空。如果调用方必须提示不同原因,应先 requireById 确认用户存在,再检查该对象的 emailValue,而不是在已经压平的结果里猜原因。

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,两个错误不会再混在一起。

正文到此结束
评论插件初始化中...
Loading...