跳到内容

第 9 卷 / 第 4 章 / 第 2 课

4.2 高阶函数与组合:把控制流程变成数据 ​

订单校验常从一串 if 开始。规则增多后,每个分支既判断条件,又拼错误消息,还决定是否继续执行。高阶函数提供的价值不是少写几行循环,而是把“要执行的规则”作为值组织起来。

函数可以被传递和返回 ​

接收函数参数或返回函数的函数,称为高阶函数:

java
@FunctionalInterface
interface Rule<T> {
    Optional<String> validate(T value);
}

static Rule<Order> minimumAmount(BigDecimal minimum) {
    return order -> order.total().compareTo(minimum) >= 0
        ? Optional.empty()
        : Optional.of("订单金额不能低于 " + minimum);
}

minimumAmount 返回一个捕获了 minimum 的函数。捕获的变量形成闭包。Java 要求局部变量为 final 或 effectively final,这避免闭包观察到该局部绑定之后又被改写,但闭包捕获的对象本身仍可能可变。

Java 在 Lambda 出现之前也能通过匿名类传递行为;Java 8 增加的是 Lambda 表达式、方法引用和配套函数式接口,并非此前“函数绝对不能当参数”。

用组合替代分支扩散 ​

java
static <T> Rule<T> all(List<Rule<T>> rules) {
    var snapshot = List.copyOf(rules);
    return value -> snapshot.stream()
        .map(rule -> rule.validate(value))
        .flatMap(Optional::stream)
        .findFirst();
}

Rule<Order> checkoutRule = all(List.of(
    minimumAmount(new BigDecimal("10.00")),
    order -> order.items().isEmpty()
        ? Optional.of("订单不能为空")
        : Optional.empty(),
    order -> order.shippingAddress() == null
        ? Optional.of("缺少收货地址")
        : Optional.empty()
));

这里的控制策略是“按顺序执行规则,返回第一个错误”。如果业务要一次返回全部错误,可以修改组合器,而不必重写每条规则:

java
static <T> Function<T, List<String>> collectAll(List<Rule<T>> rules) {
    var snapshot = List.copyOf(rules);
    return value -> snapshot.stream()
        .map(rule -> rule.validate(value))
        .flatMap(Optional::stream)
        .toList();
}

组合器在创建时复制规则列表,避免调用方后来增删列表,悄悄改变已构造规则。规则对象本身仍应行为稳定,并返回非 null 的 Optional。collectAll 只适用于可独立执行的规则;前一条非空检查失败后,后一条不能直接解引用同一个空地址。

这些 Java 片段适用于 JDK 17 或更高版本(Optional.stream 要求 Java 9,Stream.toList 要求 Java 16)。运行前需把方法放进类中,补齐 Order 定义与标准库导入。

map、filter、fold 各自回答什么 ​

对集合处理可以先区分三类操作:

  • map:每个输入元素产生一个输出元素;
  • filter:保留满足条件的元素;
  • fold / reduce:把多个元素合成为一个结果。
java
BigDecimal revenue = orders.stream()
    .filter(order -> order.status() == PAID)
    .map(Order::total)
    .reduce(BigDecimal.ZERO, BigDecimal::add);

这段流水线能直接读成“筛选已支付订单,取金额,再求和”。但链式调用不是天然更清楚。若每个 Lambda 都包含十几行分支,或者需要在阶段间观察复杂状态,应提取有名称的函数,必要时回到普通循环。

惰性求值改变执行时机 ​

Java Stream 的中间操作通常是惰性的,直到终止操作开始才消费数据:

java
var pipeline = orders.stream()
    .filter(Order::isPaid)
    .map(Order::total);       // 此时尚未遍历

var first = pipeline.findFirst(); // 从这里开始执行,并可能短路

惰性能避免不必要的计算,也能处理潜在无限序列;代价是错误和副作用的发生时间不再等同于代码书写顺序。

Java 官方文档要求传给 Stream 的行为参数通常应当无状态,并且不能干扰数据源。实现允许在不影响结果时省略某些阶段,因此不要依赖 map 或 filter 中的日志、计数等副作用一定执行:

java
// 顺序流可以工作;并行写这个 ArrayList 则不安全
List<String> imperativeNames = new ArrayList<>();
orders.stream()
    .filter(Order::isPaid)
    .forEach(order -> imperativeNames.add(order.customerName()));

// 更清楚:让流水线返回值
List<String> names = orders.stream()
    .filter(Order::isPaid)
    .map(Order::customerName)
    .toList();

上面的终止操作 forEach 会对每个选中元素执行动作,并非 map 被省略的例子。它的问题是依赖外部可变收集过程,尤其容易在后来改成并行时出错。返回值版本去掉了这种共享修改,但 toList 返回不可修改的列表;调用方若需要修改,应明确选择可变收集器。

Stream 不是集合 ​

Java Stream 是一次性消费的计算管道,不是可重复遍历的数据结构:

java
Stream<Order> paid = orders.stream().filter(Order::isPaid);
long count = paid.count();
// paid.findFirst(); // 不应重用同一个 Stream

以 I/O 为数据源的 Stream 还可能持有资源:

java
try (Stream<String> lines = Files.lines(path)) {
    return lines.filter(s -> !s.isBlank()).count();
}

把 Stream 存到字段、跨方法传很远或多次消费,都会让生命周期难以判断。通常应在数据源附近创建,在同一作用域内终止。

并行 Stream 不是免费加速 ​

把 .stream() 改成 .parallelStream() 只改变执行策略,不会修复不满足结合律的归约,也不会让阻塞 I/O 自动拥有合理隔离。

并行归约需要满足结合律的组合操作、适用时的正确单位元,以及彼此兼容的 accumulator 与 combiner。数学整数加法可用来说明结合律:

text
(a + b) + c = a + (b + c)

浮点加法因舍入并不严格满足数学结合律,所以重新分组可能造成末位差异,也可能引起更大的相消误差。例如 Java double 中,(1e16 + -1e16) + 1.0 是 1.0,而 1e16 + (-1e16 + 1.0) 是 0.0。涉及顺序、共享状态、线程池竞争或少量廉价元素时,并行开销还可能大于收益。是否采用并行 Stream 应通过基准测试和运行环境评估,而不是按数据量凭感觉开启。

完成检查 ​

把一段“读取订单、过滤、转换、汇总”的代码分别写成普通循环和 Stream:

  1. 两版都为中间规则命名;
  2. 不在 map、filter 中修改外部集合;
  3. 标出短路行为;
  4. 若考虑并行,说明归约是否满足结合律;
  5. 选择更容易维护的一版,并写出理由。

函数式风格的目标是缩小推理范围,不是让每段代码都变成最长的调用链。

参考资料 ​

Built with VitePress | Software Systems Atlas