设计模式 E01:高阶函数替代了什么
第 28 篇把报价规则抽成 Strategy,第 29 篇把固定步骤放进 Template Method。Java 8 之后,同样的“把算法交给调用者”常被写成 lambda。问题不在 lambda 是否“算模式”,而在它替代的是哪一部分:替代一段可调用算法很容易,替代对象的命名、状态、生命周期和调试边界并不免费。 同一契约下的三种写法 JDK 21 的 IntUnaryOperator 文档把它定义为函数式接口,函数式接口可以作为 lambda 或方法引用的目标;它的抽象方法是 applyAsInt(int)。labs/E01 没有直接依赖这个接口,而是定义了一个领域名更明确的 PriceRule,再用 QuoteService 固定负数检查。这样 lambda 和对象策略都必须通过同一个入口。 1234Scenario.QuoteService lambda = new Scenario.QuoteService(cents -> (int) ((long) cents * 9 / 10));Scenario.QuoteService object = ...
设计模式 39:从变化需求保留必要模式
一条订单用例原来处理原价和会员九折,审核挡下被封禁订单,然后只生成邮件收据。现在要允许清仓八折,收据可选择短信,还要保持“拒绝的订单不生成收据”。要为每个变化都套 Strategy、Bridge、责任链、Facade 吗?先把新旧行为跑出来,再数清新增的边界由谁维护。 从一个可检查的旧入口出发 labs/39 的 Before.checkout 在 1000 分上分别得到 email|receipt/1000 和 email|receipt/900,被拒绝返回 DENIED;它对清仓规则与短信渠道都抛异常。两条新需求是独立的:新增清仓时不能改变会员九折的金额,新增短信时不能改变邮件收据文本。负金额由新入口拒绝、未知渠道也显式失败;拒绝时不应调用报价策略,测试用会抛 AssertionError 的策略固定了这个顺序。 最小修复不等于保留所有模式 After 复用本系列已有的 labs/28 价格规则接口和报价入口,不再新写一个互不关联的策略框架;用构造参数注入原价、会员或清仓规则。仅有两种固定收据渠道且没有渠道自带生命周期时,switch (channel) 就足够,不额外引入 ...
设计模式 38:折扣表达式需要语法树吗
原来的报价规则写死为“price - 10 + 5”,100 分的结果是 95。配置方现在想写“price - 2 * 3”表达另一种固定折扣。若直接按文本从左到右执行,会把先减后乘算成 294,而要求乘法优先的结果是 94。为这点变化建立小语言值不值得?首先要把能写什么和不能写什么划清。 限定文法,不运行任意源码 本章文法仅支持 price、非负十进制整数、+、-、* 和空格:expression := term ((+ | -) term)*,term := atom ('*' atom)*,atom := price | integer。没有括号、除法、变量名、函数调用或任何反射/脚本执行。labs/38 的 After 用递归下降先解析乘法项,再组合加减表达式树;Plus/Minus/Times 在求值时使用 Math.*Exact,负输入或负最终结果拒绝,超过 64 个字符也拒绝。节点仅有教学用途,并不对外提供脚本权限。 12int oldPrice = new Scenario.After("price - 10 + 5").evaluate(10...
设计模式 37:新操作与新节点哪个更贵
报表节点只有单品与组合时,导出文本已经可用。新需求是在不改已有导出代码的前提下校验“名称不能为空且金额不能为负”。若未来还要新增收费节点,原来的取舍会不会反过来?Visitor 的价值取决于“对象种类稳定、操作常变”是否真的成立。 新操作容易;新节点会扩散 labs/37 的 Before.export 用 instanceof 分支递归导出 [paper:200,[pen:100]]。Node.accept(visitor) 则让实际 Item 或 Group 类型决定调用 visitItem / visitGroup,访问者再决定返回内容;Export 复现旧文本,新增 Validate 不必修改这两种节点的 accept 代码。测试断言负价节点导出仍有文本,却在新校验中返回 false;空组合导出 []、校验为 true。导出和校验是两个不同合同,不能把“能导出”误认为“业务值有效”。 123public <Result> Result accept(Visitor<Result> visitor) { return visitor....
设计模式 36:快照如何限制内存与别名
编辑报价方案时,需要恢复前一次商品清单。若把可写 List 当成“快照”存进历史,调用方随后增加一项,旧快照也会变;若每次都无限制保存完整清单,历史又会无限增长。保存状态不等于保留一个别名,还需要决定恢复资格和内存边界。 状态由原对象保存,历史只管凭据 labs/36 的 Before.save() 直接返回内部 List,测试把新商品加进调用方输入后,“旧快照”也被修改。After 的内部清单经 List.copyOf 防御性复制;Snapshot 只有私有字段和私有构造器,不给调用方可写状态。History.save(editor) 保存由原对象创建的不透明快照、只保留最新两份;恢复前确认它属于当前编辑器且仍在这份历史中,否则拒绝。测试还断言修改原输入不会改变编辑器、已过期快照不能恢复且拒绝时当前清单不变。 1234Scenario.History history = new Scenario.History(2);Scenario.Snapshot saved = history.save(editor);editor.edit(List.of("paper&qu...
设计模式 35:协作集中会形成大对象吗
金额审核要同时请财务与库存给出意见。旧版 Finance.approve() 自己直接创建并调用 Stock,财务组件知道库存组件的类型;新增审计参与方时,这段相互依赖会更难改。但把所有判断都放到一个“总审批器”里,也可能只是造出新的大对象。 集中路由,不集中所有规则 labs/35 的 Before 让财务知道库存,500 分时两者通过,轨迹为 finance, stock。After 的协调者只保存一份参与者列表、按注册顺序请求每人检查、汇总布尔结果;财务是否允许非正金额、库存是否允许超过 1000 分仍由各自的 Participant 决定。对 0 分,协调者仍向两人征求意见,返回失败并留下两条轨迹;这不是责任链遇失败就停止。新增一个审计参与者只需加入列表,本章断言 500 分被审计拒绝、100 分仍通过,旧财务与库存的实现不修改。 123456boolean approved = true;for (Participant participant : participants) { boolean result = participant.check(am...
设计模式 34:遍历协议隐藏了什么
订单展示方要依次读取 paper, pen。旧接口直接把内部 List 交出来,展示方可以在读取过程中添加元素,也不得不知道订单实际以列表保存。若以后用双端队列收集商品,调用方能否仍使用同一个遍历协议,且得到明确的空列表、末尾与修改语义? 让调用方依赖遍历而非存储 labs/34 的 Before.items 与调用者传入的 ArrayList 是同一个引用;测试追加 pen 后旧对象也多了一项。After 的 ListOrder 构造时复制输入,DequeOrder 从相同数据构造队列;两者都实现 Iterable<String>,调用者只需 for (String item : order)。示例约定新订单在构造时形成稳定快照,后续修改原列表不会改变结果。DequeOrder.iterator() 再提供不可修改的遍历快照;对空迭代器调用 next() 抛 NoSuchElementException,在迭代器上 remove() 抛 UnsupportedOperationException。 123for (String item : order) {...
设计模式 33:对象状态如何约束操作
订单刚建好还是草稿,不允许直接付款。旧对象却只有三个互不约束的布尔字段,pay() 无条件把 paid 置为真。再增加取消、重复确认和重复付款时,靠调用方记住全部组合很容易漏检查:草稿、已确认、已支付、已取消哪一步能做什么? 把迁移合同放到当前阶段 labs/33 的 Before 测试保留“草稿也能 paid=true”的错误反例。After 持有 Phase:草稿允许确认或取消;已确认允许支付或取消,重复确认返回原阶段;已支付允许重复支付但不再次做事,取消则拒绝;已取消不再付款。非法操作抛异常,失败后当前阶段不变。测试旧正常路径“确认 → 付款”仍达已支付,新增草稿付款拒绝、取消后付款拒绝、重复调用幂等均有断言。 123public void pay() { phase = phase.pay();} 此处 State 的变化来自同一个订单自身的生命周期:不同阶段决定允许哪些行为。与 28 的 Strategy 不同,后者由外部选定报价算法,本章阶段按操作迁移;与 29 的 Template Method 不同,这里并无固定的“读/写”骨架等待子类覆...
设计模式 32:审核何时停止传递
订单审核先查风险、再查金额上限。被风险规则拒绝的订单不应该被后面的金额规则重新批准。旧代码遍历所有处理器,把每一次决定写入同一个 last,最后的批准覆盖了前面的拒绝。问题不是处理器数量,而是“何时必须停止传递”没有合同。 三种结果决定链路去向 labs/32 的 Before.review(blocked=true, amount=500) 先得到 DENIED,最后返回 APPROVED;断言保留这个错误和 fraud,limit 两步轨迹。After 约定处理器只可能返回 NEXT、DENIED 或 APPROVED;一旦得到明确终态立刻返回,链尾全是 NEXT 时返回 UNHANDLED。因此同样的受阻订单只跑 fraud,后续金额规则不会意外翻案。正常 500 分仍批准;1001 分与空链都未处理,不被默认为通过。 12345for (Reviewer reviewer : reviewers) { Decision decision = reviewer.review(request); if (decision != NEXT) return ...
设计模式 31:谁管理订阅和失败
订单付款后要通知日志与开票两个本地回调。第一个回调抛错时第二个还需收到事件;开票回调又可能在处理 paid 时发布 invoiced。如果直接遍历订阅列表,回调失败会阻断后续通知,重入时顺序还可能让人意外。同步事件的顺序与失败到底由谁规定? 订阅表不等于交付保证 Before.publish 直接遍历监听器;在第一个回调抛 IllegalStateException 的反例中,发布调用抛错,后面的回调不会执行。After 维护订阅列表与一个待处理事件队列:事件按发布顺序入队,正在派发时的重入事件留到当前事件的所有订阅者处理完再派发;每轮取订阅快照,监听器抛出的 RuntimeException 被记录到 failures(),其他监听器仍被调用。测试轨迹是 first:paid, second:paid, first:invoiced, second:invoiced。退订后再次发布不会给被退订者送达。 1234after.subscribe(event -> { if (event.equals("paid")) after.publi...










