设计模式 13:重复与抽象哪个更贵
会员满 100 元送礼,订单满 100 元包邮。这两个判断看起来相同,于是有人把门槛抽到 eligible(int cents)。现在会员部门把送礼门槛改成 150 元,物流部门维持 100 元。只改共享常量会同时改变包邮;保留旧常量又会错误地送礼。这里的相似代码并不代表同一条业务知识。
先让独立变化暴露出来
Before 的两种优惠都走同一个 eligible;测试特意断言它在 120 元时仍然送礼,用来记录旧实现的错误,而不是把错误算成新需求通过。最小修复 After 并没有引入策略接口:保留两处比较,礼品条件改为 >= 15_000 分,运费条件保持 >= 10_000 分。金额用分表示以免例子引入浮点舍入问题,这不是通用货币模型。
1 | |
对调用者而言,两条分支都能回答同一个旧问题:20_000 分时均成立;新问题却要求 12_000 分时只有包邮成立。Alternative 把两个条件放进同一个 switch (benefit):当入口只有一个、优惠种类固定时,这样也足够,不必为了让代码看起来“可扩展”增加注册表或抽象工厂。未知优惠名显式抛异常,不隐式将其视为不符合资格。
| 设计 | 12_000 分时送礼 / 包邮 | 再改送礼门槛时改哪里 |
|---|---|---|
Before |
是 / 是(不符合新增要求) | 共用门槛会波及物流 |
After |
否 / 是 | loyaltyGift,物流判断不动 |
Alternative |
否 / 是 | gift 分支,条件仍集中在一个类 |
DRY 要防止同一事实以多个副本维护,不能从两行字面相似直接推出共享的变化理由。KISS 在这里是先写两个能独立改变的判断;YAGNI 则要求在实际需求出现前保持克制:尚无第三种优惠或运行时可替换需求时,不要预置接口、工厂和插件。它们不是禁止抽象的定律:若两个入口确实受同一门槛约束,并有共同变更证据,共享一个命名规则更可能减少遗漏。
flowchart LR
BeforeGift[Before.loyaltyGift] --> Shared[eligible 10000]
BeforeShipping[Before.freeShipping] --> Shared
AfterGift[After.loyaltyGift 15000]
AfterShipping[After.freeShipping 10000]
实验如何判定
在 examples/design-patterns/ 运行 ./mvnw -B -ntp -pl labs/13 -am test,随后运行 ./mvnw -B -ntp verify。本章四项断言覆盖旧合同、旧实现的反例、新边界 14_999/15_000 分、未知优惠;本次真实命令、JDK、退出码和原始输出记录在 examples/design-patterns/evidence/13/RUN.md。这些测试既没有证明数据库金额的一致性,也没有覆盖负数输入的业务处理;负数是否允许应先由真实入口合同决定,不能从本章示例推断。
练习:
- 新增“积分礼品门槛 180 元,运费仍为 100 元”的变化。先写 100/150/180 元边界断言,再核查旧包邮合同是否仍成立,并记录三个方案各需修改的位置。
- 假设物流与会员确认门槛在未来由同一个合约统一发布。设计能证明“同一事实”的测试;比较保留两处分支与共享一个值对象,指出重新抽象何时才值得。
参考资料
- Martin Fowler,
Yagni。 - Andy Hunt 与 Dave Thomas, The Pragmatic Programmer, 20th Anniversary Edition 官方公开样章
DRY—The Evils of Duplication:DRY 定义为每一份知识在系统内只有单一、明确、权威的表示。 - NASA/NTRS,
KRUSTY Reactor Design:在 KRUSTY 目标取舍中明确使用 “Keep It Simple, Stupid (KISS)” 原则;NASAAppendix C: How to Write a Good Requirement的需求校验清单也要求需求 concise and simple。 - 来源边界:
writing-plans/design-patterns/SOURCES.md。 - 实验代码:
examples/design-patterns/labs/13/。
上一节:12 谁该决定库存预留;下一节:14 包依赖为什么成环。






