qinghuan
← Java SE / 全部笔记

单元 25 / Java SE

P0 · 必会主线L1 · 基础

Lambda 与 Optional:传入一段行为,表达没有结果

把判断或转换行为传给方法,用方法引用简化合适的写法,再用 Optional 明确表达可能没有结果。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能把判断和转换作为参数传递,并显式处理可缺失结果。

学习理由、准备要求与知识点

为什么学它

理解行为参数与空结果,是正确使用 Stream 和异步 API 的必要基础。

先读这些内容

知识点清单

  • 函数式接口单抽象方法
  • Predicate/Function 等职责
  • Lambda 目标类型
  • 方法引用匹配
  • effectively final 捕获
  • Optional 存在与缺失
  • map/filter/orElseGet
  • 副作用与共享对象

运行前先确认Java 17;源码在本机 Microsoft OpenJDK 17.0.20.1 src.zip 核验,在线链接为上游 jdk-17.0.20-ga。深入模块可在掌握基础后回读。

01先弄懂它是什么

想把“怎样判断”或“怎样转换”作为参数传给方法,可以使用 Lambda。它为一个函数式接口提供实现;这种接口只有一个需要实现的抽象方法,也可以有默认或静态方法,某些 Object 公有方法签名不额外计数。Predicate 用来判断,Function 用来转换,Consumer 接收并处理值,Supplier 提供值。先看接口的方法参数和返回值,再读 Lambda,会更容易理解。

方法引用把部分 Lambda 写得更短,例如 String::length 可以表示把字符串转换成长度,是否适用仍取决于目标接口类型。Lambda 可以使用外部局部变量,但变量必须是 final 或初始化后不再赋值。这个限制只管变量本身:它指向的数组或对象仍可能被修改,也不会因此自动变得线程安全。

Optional 常用于明确表达一个方法可能没有结果。of 要求非空,ofNullable 把 null 转成空,map 转换存在值,flatMap 用于转换函数本身返回 Optional 的场景。它不表示所有异常都应该变成空,也不要求所有字段和参数都套一层 Optional。先说明“没有结果”是不是正常业务状态,再决定接口如何表达。

文中这几个词,先认一认

目标类型
Lambda 要交给哪一种函数式接口,哪一种就是它的目标类型;例如交给 Predicate<String> 时,它应接收 String 并返回 boolean。编译器按这个接口的要求核对参数和返回值。
事实上的 final
局部变量虽没写 final,但初始化后从未重新赋值,因此可以被 Lambda 使用。
惰性求值
先提供计算方式,等确实需要结果时才运行它,避免提前做无用计算。
Optional 空值
表示这个方法按约定没有结果;它本身并不说明是查无数据、输入错误还是其他原因。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

Lambda 创建与 Lambda 执行是两个时刻。把读取文件的行为放进 Supplier,并不会在声明 Supplier 那一行就必然读取;调用 get 才执行相应逻辑。但普通方法参数表达式仍会先求值,所以 optional.orElse(expensive()) 会先调用 expensive,即使 Optional 已有值。orElseGet 接收行为,只有缺失时才调用它。

Optional.map 的转换结果如果是 null,会得到空 Optional。flatMap 的转换方法则必须返回一个 Optional,不能返回 null,但可以返回 Optional.empty();这样就不会套成 Optional<Optional<T>>。

没有值时直接调用 get 会抛异常,通常可以用 map、filter、orElseGet 或 orElseThrow 把处理方式写清楚。如果默认值计算很慢,或计算时会产生其他影响,还要考虑是否只在缺少值时才计算它。

把行为写成函数,不代表它每次都只计算一个值:Predicate 可以读当前时间,Function 可以写数据库,Supplier 可以每次返回不同结果。所以要说清它会被调用几次、遇到异常怎么办,以及何时提前停止。图中的提供者链按顺序尝试,找到结果后就结束;排在后面的提供者连检查和调用都不会执行。这就是这里的惰性规则:暂时不需要的步骤,留到真正需要时才做。

正在加载流程图…

把行为作为参数才能决定何时执行;存在结果后停止后续行为。
查看流程图源码(Mermaid)
flowchart TD
 A["按优先级排列 Supplier"] --> B["调用当前提供者"]
 B --> C{"Optional 是否存在值"}
 C -->|"是"| D["返回并停止"]
 C -->|"否"| E{"还有提供者"}
 E -->|"是"| B
 E -->|"否"| F["返回空 Optional"]
 B --> G["异常按契约传播"]

按顺序看,每一步做了什么

  1. 从函数式接口签名判断 Lambda 类型。
  2. 区分创建行为与执行行为。
  3. 明确捕获变量和对象可变性。
  4. 用 Optional 表达正常缺失。
  5. 测试默认计算次数、短路和异常传播。

03对照实现代码

惰性默认值为何只在缺失分支调用 (新标签页)↗

Microsoft OpenJDK 17.0.20.1;java.base/java/util/Optional.java · java.util.Optional#orElseGet(Supplier)

以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。

核心机制代码 · java
import java.util.function.Supplier;
public final class SourceModel {
    public static <T> T choose(T present, Supplier<? extends T> fallback) {
        if (present != null) return present; // 存在时不调用备用行为。
        return fallback.get();
    }
}

真实 Optional.orElseGet 检查内部是否存在值,只有缺失分支才调用 supplier.get。模型用一个可空引用代表内部状态,把三元表达式展开成分支,不模拟 Optional 的对象表示和其它操作。它展示的重点是调用位置,默认结果不是在传参时就已经计算好。

如果调用方写 orElse(buildDefault()),buildDefault 作为普通实参表达式会先执行。即使已有值最终被返回,默认构建的耗时或副作用也已经发生。改成 orElseGet(() -> buildDefault()) 才把行为延后;这不是 Optional 特有魔法,而是参数从已计算值变成了可调用对象。

真实惰性方法在已有值时不需要调用 supplier,因此不能为了方便在模型入口无条件执行它。空 Optional 遇到 null supplier 会失败,已有值则不会走到调用点。接口设计若希望无条件拒绝 null 提供者,可以另行声明更严格前提,但必须承认与真实方法的分支行为不同。

Supplier 只说明“无参数调用并返回结果”,不保证非空、没有副作用或永远只运行一次。orElseGet 在一次缺失调用中执行它一次,再调用还可能再执行。想复用计算结果要另建缓存,不能把延后执行当成自动缓存,也不能假定 Lambda 捕获的对象可被多个线程安全共用。

代码先调用谁,再调用谁

  1. 调用者先创建 Supplier 行为对象
  2. orElseGet 检查 Optional 内部值
  3. 存在则直接返回
  4. 缺失才执行 supplier.get 并返回其结果

04动手写一个例子

按优先级惰性查找首个可用结果

输入按优先级排列的字符串提供者,每个返回 Optional<String>。依次执行直到首个存在值;全部缺失或列表为空返回空。只检查实际执行的提供者,异常直接传播,不吞成缺失。

输入 1
[空,值A,值B]
输出 1
A,仅调用前两项
输入 2
[值A,会抛异常的提供者]
输出 2
A,后者不执行
输入 3
[]
输出 3
空

查找状态只有当前位置与当前返回值,不需要把所有提供者先执行再过滤。逐个执行后立即检查 isPresent,命中就返回,保证后续昂贵或有副作用的调用不会发生。这个短路规则不仅影响性能,也是外部行为数量的承诺。

提供者返回 null 容器属于接口实现错误,与返回 Optional.empty 的正常缺失不同,必须明确拒绝。提供者自身为 null 也只在轮到它时拒绝;成功之后的未访问项不会被检查。若调用者希望预先校验整个列表,可在配置加载阶段另做结构验证,不能在查找阶段悄悄破坏惰性。

测试使用调用计数器验证执行次数,不能只比较最终字符串。一个错误实现可能执行全部提供者却仍返回正确第一值,普通输出断言发现不了额外副作用。再加入抛异常提供者验证错误不会被吞掉,避免把数据库失败与确实查无数据混成同一个空结果。

为什么这样写是对的执行第 i 项前,前面所有已执行提供者都返回正常缺失;一旦得到存在值就终止。

时间 O(k + ΣCi),k 为实际执行提供者数,Ci 为各调用成本。额外空间 O(1) 额外空间。

每项只检查和执行一次,命中后不再遍历;调用内部可能包含昂贵工作,因此总成本必须累计提供者真实耗时。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 45 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.function.Supplier;
public class Main {
    static Optional<String> first(List<Supplier<Optional<String>>> providers) {
        if (providers == null) throw new IllegalArgumentException("providers");
        for (Supplier<Optional<String>> provider : providers) {
            if (provider == null) throw new IllegalArgumentException("provider");
            Optional<String> value = provider.get();
            // null 容器是实现错误,empty 才是正常缺失。
            if (value == null) throw new IllegalStateException("null Optional");
            if (value.isPresent()) return value; // 命中立即短路。
        }
        return Optional.empty();
    }
    static Supplier<Optional<String>> source(int[] calls, String value) {
        return () -> { calls[0]++; return Optional.ofNullable(value); };
    }
    static void require(boolean condition) {
        if (!condition) throw new AssertionError();
    }
    public static void main(String[] args) {
        // 计数器只供单线程测试,验证结果之外的执行次数。
        int[] calls = {0};
        require(first(List.of(source(calls,"A"),source(calls,"B"))).equals(Optional.of("A")));
        require(calls[0] == 1);
        calls[0] = 0;
        require(first(List.of(source(calls,null),source(calls,"B"))).equals(Optional.of("B")));
        require(calls[0] == 2);
        calls[0] = 0;
        require(first(List.of(source(calls,null),source(calls,null))).isEmpty());
        require(calls[0] == 2);
        require(first(List.of()).isEmpty());
        Supplier<Optional<String>> broken = () -> { throw new IllegalArgumentException("failure"); };
        require(first(List.of(() -> Optional.of("A"),broken)).isPresent());
        boolean propagated = false;
        try { first(List.of(broken)); } catch (IllegalArgumentException e) { propagated = true; }
        require(propagated);
        boolean rejected = false;
        try { first(List.of(() -> null)); } catch (IllegalStateException e) { rejected = true; }
        require(rejected);
        System.out.println("PASS 7");
    }
}

这些特殊情况也要试一试

  • 空列表返回正常缺失。
  • 已有结果时不执行后续错误提供者。
  • null Optional 不等于 empty。
  • 提供者异常直接传播。
进阶选读 · 可选深入:并行竞速与结果缓存

场景多个来源延迟不同,希望最快可用结果或复用上次结果。

做法先决定优先级是否仍然严格,再设计并发竞速、取消、超时和缓存失效;记忆化还需区分空结果与错误是否缓存。

复杂度并行可降低等待但增加同时调用数量,缓存查询与刷新成本需分别计算。

好处与代价最先完成不等于最高优先级,取消也不保证外部副作用没有发生。

05项目里怎么用

Optional 适合表示方法可能没有结果;是否放进字段、参数或序列化对象,要看接口和框架支持。不要只是为了消除所有 null 到处加包装,也不要把空结果一概当成正常。查无数据、权限拒绝、网络故障和数据损坏应能区分,否则调用方不知道该提示、重试还是停止。

只有缺少结果时才计算备用值,适合缓存未命中后查原始数据、找备用配置等场景。但读取多次空结果,可能就会调用多次 Supplier。如果备用动作会发通知或创建记录,要决定是否只允许调用一次,或者怎样让重复调用不产生重复效果,这就是幂等问题。长期把 Supplier 存在字段里,也可能把它用到的大对象一直留在内存中;要检查它究竟保留了哪些对象。

函数式链条过长会隐藏中间业务含义和异常位置。适当把关键步骤命名成方法,保留输入校验和结果边界,比一味压成单行更易调试。并行执行前还要审查捕获对象是否线程安全;局部引用满足事实上的 final,只解决语言捕获限制,不等于对象内部状态没有竞争。

容易踩的坑

  • 用 orElse 执行昂贵或有副作用的默认计算。
  • 把事实上的 final 误解为捕获对象不可变。
  • 把所有异常捕获后转成 Optional.empty。
  • 认为惰性求值会自动缓存结果。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
直接返回值或抛异常契约直接正常缺失需要额外表示结果必须存在的操作
Optional 返回明确表达正常缺失容易被误用为错误掩盖查询与可选配置结果
Supplier 延迟行为可控制执行时机与次数可能隐藏副作用和资源引用昂贵默认值与按需回源

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01为什么 orElse 的默认方法即使不用也执行?

展开参考答案与推导

普通实参表达式在调用方法前求值,因此默认计算已经发生。orElseGet 接收 Supplier 行为,把调用位置放进缺失分支,才能实现按需执行。

02Lambda 捕获列表后还能 add,为什么?

展开参考答案与推导

捕获限制针对局部变量是否重新赋值,add 修改的是它指向的列表对象。引用保持不变不等于对象不可变,也不提供多个线程同时修改时的同步保证。

03map 与 flatMap 如何选择?

展开参考答案与推导

转换函数返回普通值时用 map;函数已经返回 Optional 时用 flatMap,避免多包一层。二者对于 null 返回还有不同约定,应让转换函数明确表达正常缺失而非返回混乱容器。

换个情况,还会处理吗

01缓存命中但数据库仍被查了,先检查什么?

展开参考答案与推导

检查是否写成 cached.orElse(loadFromDatabase()),使查询提前执行。改为惰性提供者并用调用次数测试命中路径,同时确认其它日志或参数构建没有提前触发同一查询。

02接口把网络错误转成空,调用者误以为不存在,怎么办?

展开参考答案与推导

保留错误与正常缺失的不同结果语义,通过异常或明确结果类型传递失败。Optional 本身只表达有没有值,不能承载所有失败原因,更不能自动决定重试策略。

03调用两次 orElseGet 为什么创建了两条记录?

展开参考答案与推导

惰性不是记忆化,每次缺失调用都可能执行提供者。创建动作需要幂等或显式缓存与事务控制,不应依赖 Optional 帮忙保证只执行一次。

写代码试一试

01增加后续 null 提供者的短路测试

输入 / 条件
[值A,null]
输出 / 预期
A

提示 使用允许 null 的列表构造。

展开参考答案与推导

用 Arrays.asList 构造成功提供者和 null 项,断言 first 返回 A。List.of 会提前拒绝 null,无法测试算法的未访问项契约;因此测试准备方式本身也需要正确。

02增加空字符串结果测试

输入 / 条件
[Optional.of(空字符串),值B]
输出 / 预期
空字符串

提示 存在与非空白不是一回事。

展开参考答案与推导

把第一项设为 Optional.of(""),应立即返回。若业务把空白视为缺失,应在提供者或明确 filter 步骤处理,不能把 Optional 存在判断偷偷改为字符串长度判断。

03把缺失转换成明确异常

输入 / 条件
所有提供者均空
输出 / 预期
抛指定业务异常

提示 在最终结果调用 orElseThrow。

展开参考答案与推导

使用 first(providers).orElseThrow(() -> new IllegalStateException("not found"))。这表示该调用场景要求结果存在,提供者异常仍原样传播,不应与最终未找到结果混淆。

读完之后,独立完成

课后作业:自己做一遍

本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能把判断和转换作为参数传递,并显式处理可缺失结果。

本篇重点加练

重点练捕获变量与延迟默认值,检验回调行为是否受到时间和副作用影响。

  • effectively final 捕获
  • map/filter/orElseGet

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    把“判断课程是否及格”“课程转标题”“输出日志”分别建模为函数式接口,写出 Lambda 的目标类型和参数返回类型;添加一个非函数式接口反例。

    提交内容
    接口表、可编译样例与反例编译记录。
    怎样算完成
    • 单抽象方法要求清楚
    • 行为职责与接口类型对应

    练到的知识点函数式接口单抽象方法 · Predicate/Function 等职责 · Lambda 目标类型

  2. 作业 02代码推演

    针对 String::length、System.out::println 与捕获局部阈值的 Lambda,推导参数匹配;分别尝试修改被捕获变量和被捕获对象的字段后编译运行。

    提交内容
    目标类型推演、编译结果与观察记录。
    怎样算完成
    • 绑定限制和对象可变性分开
    • 方法引用由上下文核验

    练到的知识点方法引用匹配 · effectively final 捕获

  3. 作业 03代码推演

    两个 Lambda 捕获同一个配置对象,另一个捕获独立对象;构造后再修改一个字段,先画引用关系再观察回调,不能只比较变量是否 final。

    提交内容
    对象关系图、预测与实际记录。
    怎样算完成
    • 捕获对象身份可区分
    • 观察时刻明确

    练到的知识点effectively final 捕获

综合应用

  1. 作业 04设计评审

    用户昵称可能缺失,需要先过滤空白、转换展示格式,最后按需加载默认昵称;定义缺失与加载失败的合同,并选择 Optional 操作组织接口。

    提交内容
    处理流程、失败合同和调用样例。
    怎样算完成
    • 缺失不会与正常空字符串混同
    • 默认值计算时机可验证

    练到的知识点Optional 存在与缺失 · map/filter/orElseGet

  2. 作业 05编程实践

    实现课程查询函数返回 Optional<Course>,再组合筛选和标题提取;让默认标题提供者统计调用次数,验证命中、未命中和不满足条件三类输入。

    提交内容
    Java 实现、自写边界和调用次数测试、运行证据。
    怎样算完成
    • 无无条件 get
    • 默认提供者的执行符合合同
    • 输入集合保持不变

    练到的知识点Predicate/Function 等职责 · Optional 存在与缺失 · map/filter/orElseGet

  3. 作业 06面试场景

    回调捕获同一个可变统计对象,被多个任务并发调用。评审“局部引用 effectively final 所以线程安全”的推理,设计需要补充的所有权证据。

    提交内容
    对象关系图、并发场景与改进合同。
    怎样算完成
    • 捕获规则不代替同步规则
    • 副作用范围明确

    练到的知识点effectively final 捕获 · 副作用与共享对象

  4. 作业 07编程实践

    实现可选学习目标的展示函数,默认值提供者读取一个可控计数对象;创建回调后再改变对象状态,分别在命中与缺失时调用并记录读取时点。

    提交内容
    Java 实现、自写调用时序/空值测试及运行证据。
    怎样算完成
    • 创建回调与执行回调分开
    • 默认值调用次数可核验

    练到的知识点effectively final 捕获 · map/filter/orElseGet

  5. 作业 08设计评审

    默认昵称需要一次可能失败的本地加载,团队同时使用 orElse 和 orElseGet。定义加载时机、失败传播和重复调用合同,再选择符合要求的接口。

    提交内容
    两种调用的验收矩阵和理由。
    怎样算完成
    • 选择由可观察行为支持
    • 缺失与加载失败分开

    练到的知识点map/filter/orElseGet

进阶挑战 · 掌握前置后选做

  1. 作业 09编程实践

    完成泛型后:实现可组合验证规则接口,支持先后验证和汇总错误两种政策;规则不得静默修改输入,测试短路与全部执行的调用轨迹。

    提交内容
    Java 实现、自写空规则/失败顺序/副作用测试及运行证据。
    怎样算完成
    • 组合政策清楚
    • 每条错误能关联规则
    • 函数行为可重复测试

    练到的知识点函数式接口单抽象方法 · Predicate/Function 等职责 · map/filter/orElseGet · 副作用与共享对象

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 条件变化

    回调从“定义后立即调用一次”改为“保存到队列一小时后可能多次调用”。重新审查配置捕获、默认结果新鲜度与副作用次数。

    提交内容
    延迟调用时间线和合同变更表。
    怎样算完成
    • 旧时间假设得到明确审查
    • 样本能区分一次与多次执行

    练到的知识点effectively final 捕获 · map/filter/orElseGet

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗