第 28 篇把报价规则抽成 Strategy,第 29 篇把固定步骤放进 Template Method。Java 8 之后,同样的“把算法交给调用者”常被写成 lambda。问题不在 lambda 是否“算模式”,而在它替代的是哪一部分:替代一段可调用算法很容易,替代对象的命名、状态、生命周期和调试边界并不免费。

同一契约下的三种写法

JDK 21 的 IntUnaryOperator 文档把它定义为函数式接口,函数式接口可以作为 lambda 或方法引用的目标;它的抽象方法是 applyAsInt(int)。labs/E01 没有直接依赖这个接口,而是定义了一个领域名更明确的 PriceRule,再用 QuoteService 固定负数检查。这样 lambda 和对象策略都必须通过同一个入口。

1
2
3
4
Scenario.QuoteService lambda =
new Scenario.QuoteService(cents -> (int) ((long) cents * 9 / 10));
Scenario.QuoteService object =
new Scenario.QuoteService(new Scenario.LoyaltyRule());

FunctionalStrategyTest.lambdaAndObjectStrategyShareTheSameContract 断言 101 分都得到 90 分,负数都在入口拒绝。这里的关键不是“lambda 更现代”,而是算法可替换的合同没有变。如果调用者关心的只是“输入金额,输出金额”,lambda 是最短表达;如果规则需要名字、指标标签、开关或可观测状态,命名对象会更清楚。

flowchart LR
    Caller[调用者] --> Service[QuoteService]
    Service --> Contract[PriceRule.apply]
    Contract --> Lambda[lambda: cents -> 9折]
    Contract --> Object[LoyaltyRule对象]
    Service --> Guard[统一负数检查]

高阶函数能替代 Strategy 中的“算法承载物”,不能自动替代“算法的生命周期边界”。判断是否保留对象,先看调用者是否需要算法以外的信息。

闭包状态也是状态

闭包可以捕获 AtomicInteger,对象策略也可以持有 AtomicInteger。closureStateHasTheSameLifecycleRiskAsObjectState 让两种写法都限制一次使用;第二次报价都会抛 IllegalStateException("discount exhausted")。这说明“函数式”并不等于“无状态”。状态藏在闭包里,比字段更短,但不一定更容易定位。

1
2
3
4
5
6
7
AtomicInteger uses = new AtomicInteger();
Scenario.PriceRule closure = cents -> {
if (uses.incrementAndGet() > 1) {
throw new IllegalStateException("discount exhausted");
}
return (int) ((long) cents * 9 / 10);
};

如果这个规则只在一个请求内创建并丢弃,闭包很好;如果它被放进单例、缓存或容器作用域,捕获变量就是共享状态。第 20 篇 Singleton 的教训仍然适用:对象身份和作用域要显式写出来,不能因为语法短就消失。

简单替代方案仍然有效

Alternative 用一个 switch 处理 regular 与 loyalty。只有两个固定规则、选择逻辑只存在一个入口时,这比策略对象更短,也更容易读。新增规则是否应该变成新的 PriceRule,取决于规则是否会被多个入口复用、是否要按配置组合、是否需要独立测试和监控。

写法 适合场景 主要风险
lambda 纯算法、无命名生命周期 捕获状态不显眼
对象策略 规则有名字、状态、指标、配置 小规则会显得重
本地分支 规则集合封闭且入口单一 分支扩散后违反 OCP

不为语法选择设计。先稳定调用合同,再选择最短的承载方式;承载方式可以从分支演进到 lambda,也可以从 lambda 演进到命名对象。

验证与练习

运行命令:

1
2
# 先让当前 shell 选择 JDK 21,并用 java -version 确认版本
./mvnw -B -ntp -pl labs/E01,labs/E02,labs/E03,labs/E04 -am clean test

FunctionalStrategyTest 通过 3 个测试;原始日志在 examples/design-patterns/evidence/E01/targeted.stdout.txt。本实验只证明本地报价函数的合同,不证明真实促销配置、并发缓存或货币舍入安全。

  1. 新增“员工八五折”,先把同一组 0、101、1000 分输入跑过分支、lambda、对象策略,再决定规则选择权属于调用者还是报价入口。
  2. 把 LimitedUseRule 放进一个共享字段,连续两个订单调用。写出失败测试后再决定是每个请求新建规则,还是保留对象并把状态转移到订单上下文。

参考资料:

上一节:39 从变化需求保留必要模式;下一节:E02 record、sealed 类型与模式匹配。