跳到内容

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) {
    return value -> rules.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) {
    return value -> rules.stream()
        .map(rule -> rule.validate(value))
        .flatMap(Optional::stream)
        .toList();
}

高阶函数把变化点从分支内部提到了组合层:单条规则关注事实,组合器关注控制策略。

mapfilterfold 各自回答什么

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

  • 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 的行为参数通常应当无状态,并且不能干扰数据源。实现允许在不影响结果时省略某些阶段,因此不要依赖 mapfilter 中的日志、计数等副作用一定执行:

java
// 错误示范:用副作用收集结果
List<String> names = new ArrayList<>();
orders.stream()
    .filter(Order::isPaid)
    .forEach(order -> names.add(order.customerName()));

// 更清楚:让流水线返回值
List<String> names = orders.stream()
    .filter(Order::isPaid)
    .map(Order::customerName)
    .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 自动拥有合理隔离。

并行归约要求组合方式能安全分块再合并。例如加法满足结合律:

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

浮点加法因舍入并不严格满足数学结合律,所以并行分组可能产生末位不同的结果。涉及顺序、共享状态、线程池竞争或少量廉价元素时,并行开销还可能大于收益。是否采用并行 Stream 应通过基准测试和运行环境评估,而不是按数据量凭感觉开启。

完成检查

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

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

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

参考资料

Built with VitePress | Software Systems Atlas