❝DRY(Don’t Repeat Yourself) 并不是说“别写两行长得像的代码”,而是说:系统中的每一项知识(knowledge)都应当有且仅有一个权威、无歧义的表达
DRY原则由Andy Hunt与Dave Thomas在1999年的经典著作《The Pragmatic Programmer》中提出,其初衷并非追求代码的“简洁”或“短小”,而是对抗软件演进中最隐蔽的敌人——隐性重复。
当同一业务规则、配置逻辑或算法决策散落在多个角落时,每一次需求变更都可能变成一场“捉虫游戏”:你改了A模块,却忘了B模块也藏着同样的逻辑——于 bug在静默中滋生。
DRY 中的 “Yourself”是关键:它强调的是对自己所写系统的责任——不要让你自己(未来的你,或你的队友)陷入“一处修改、处处遗漏”的泥潭。DRY也是容易被误读的原则之一。很多人将其简化为“消灭视觉重复”,结果为了消除几行结构相似的 if 语句,硬造出泛型工具类、抽象基类甚至运行时反射机制,最终导致:
- 区分 “知识重复” 与 “代码巧合相似” ——前者必须消除,后者不妨容忍。
一、DRY的含义:知识≠代码
把DRY简单理解为“不要写重复代码”是危险的简化。关键在于 “knowledge”(知识) —— 比如业务规则、算法逻辑、配置策略、状态转换条件等。而表面相似的代码,未必代表同一知识。
✅ 正确理解:
- 如果两个地方的“重复”源于同一个业务规则(例如“用户必须年满18岁才能注册”),那么这个规则应该只在一个地方定义。
- 如果两个地方只是“碰巧长得像”(例如两个独立模块都用了
if (list != null && !list.isEmpty())),强行合并反而会制造耦合。
DRY 的目标是:当需求变更时,你只需修改一处。
二、反面教材:违反 DRY 的 Java 代码
❌ 反例1:硬编码导致多处重复业务规则
// 用户服务publicvoidregisterUser(User user){if (user.getAge() < 18) {thrownew IllegalArgumentException("年龄必须大于等于18岁"); }// ...}// 订单服务publicvoidcreateOrder(Order order){if (order.getUser().getAge() < 18) {thrownew IllegalStateException("未成年用户不能下单"); }// ...}// 营销服务publicbooleanisEligibleForPromotion(User user){return user.getAge() >= 18; // 注意:这里逻辑一致,但表述不同!}
问题分析:
- “18岁”这一业务规则散落在三个地方,且错误消息不一致。
- 若将来法定成年年龄改为20岁,开发者必须找到所有相关位置修改,极易遗漏。
三、正面示范:正确应用 DRY
✅ 正例1:将业务规则封装为单一权威源
publicclassAgePolicy{publicstaticfinalint MIN_AGE_FOR_REGISTRATION = 18;publicstaticbooleanisAdult(int age){return age >= MIN_AGE_FOR_REGISTRATION; }publicstaticvoidvalidateAdult(int age){if (!isadult(age)) {thrownew IllegalArgumentException("用户必须年满 " + MIN_AGE_FOR_REGISTRATION + " 岁"); } }}// 使用publicvoidregisterUser(User user){ AgePolicy.validateAdult(user.getAge());}publicvoidcreateOrder(Order order){ AgePolicy.validateAdult(order.getUser().getAge());}
优点:
- 常量命名具有业务语义(
MIN_AGE_FOR_REGISTRATION),而非魔法数字。
四、警惕“伪 DRY”:过度抽象的陷阱
DRY 的最大误区,是把视觉重复当作知识重复。反例“为了不重复而制造复杂性”的典型错误。
❌ 反例2:强行通用化无关逻辑
// 试图“复用”两个完全无关的校验逻辑publicclassGenericValidator{publicstatic <T> voidvalidateNotNull(T obj, String name){if (obj == null) thrownew IllegalArgumentException(name + " 不能为空"); }publicstaticvoidvalidateListNotEmpty(List<?> list, String name){ validateNotNull(list, name);if (list.isEmpty()) thrownew IllegalArgumentException(name + " 不能为空列表"); }}// 在 UserValidator 中publicvoidvalidate(User user){ GenericValidator.validateNotNull(user.getName(), "姓名"); GenericValidator.validateListNotEmpty(user.getRoles(), "角色列表");}// 在 OrderValidator 中publicvoidvalidate(Order order){ GenericValidator.validateNotNull(order.getId(), "订单ID"); GenericValidator.validateListNotEmpty(order.getItems(), "商品列表");}
表面看:避免了 if (x == null) 的重复。实际上:
GenericValidator 成为“万能工具箱”,职责模糊。- 错误信息模板化,丧失上下文(“角色列表不能为空” vs “用户至少需分配一个角色”)。
- 更严重的是:User 和 Order 的校验逻辑本无共性,强行统一反而掩盖了各自的业务语义。
✅ 正例2:接受“良性重复”,保持上下文清晰
publicclassUserValidator{publicvoidvalidate(User user){if (user.getName() == null || user.getName().isBlank()) {thrownew IllegalArgumentException("用户姓名不能为空或空白"); }if (user.getRoles() == null || user.getRoles().isEmpty()) {thrownew IllegalArgumentException("用户必须至少拥有一个角色"); } }}publicclassOrderValidator{publicvoidvalidate(Order order){if (order.getId() == null) {thrownew IllegalArgumentException("订单ID缺失,无法创建"); }if (order.getItems() == null || order.getItems().isEmpty()) {thrownew IllegalArgumentException("订单中必须包含至少一件商品"); } }}
为什么这是更好的做法?
- 每个验证消息都贴合具体业务场景,便于用户理解和日志排查。
- 即使有几行结构相似的
if,但表达的是不同知识,不应合并。
❝记住:重复的代码片段 ≠ 重复的知识。
五、复杂场景:DRY 与配置、策略模式的结合
当真正的“同一知识”需要在多个上下文中使用时,可通过策略、配置或领域模型实现优雅复用。
✅ 正例3:用策略模式统一费率计算(同一业务规则)
publicinterfaceShippingRateCalculator{BigDecimal calculateShippingCost(Order order);}@ComponentpublicclassDomesticShippingCalculatorimplementsShippingRateCalculator{@Overridepublic BigDecimal calculateShippingCost(Order order){// 国内运费规则(可能很复杂)return applyWeightBasedRate(order.getTotalWeight()); }}@ComponentpublicclassInternationalShippingCalculatorimplementsShippingRateCalculator{privatefinal DomesticShippingCalculator domesticCalc;publicInternationalShippingCalculator(DomesticShippingCalculator domesticCalc){this.domesticCalc = domesticCalc; }@Overridepublic BigDecimal calculateShippingCost(Order order){// 国际运费 = 国内基础运费 + 固定附加费return domesticCalc.calculateShippingCost(order).add(new BigDecimal("20.00")); }}
这里,国内运费的计算逻辑是共享知识,通过依赖注入复用,既避免重复,又保持扩展性。
六、DRY 与 YAGNI 的平衡
DRY 常与 YAGNI(You Aren’t Gonna Need It) 产生张力:
- 过早抽象(违反 YAGNI):只为“可能”复用而设计通用组件。
- 放任重复(违反 DRY):明知是同一规则却到处复制粘贴。
平衡之道:
- 问自己:“如果这个规则变了,我需要改几个地方?” 如果答案 >1,就该 DRY 了。
- 优先提取方法,而非类或接口。局部方法复用成本最低。
DRY是手段不是目的
DRY原则的终极目标不是“代码行数最少”,而是降低系统认知负荷与变更成本。这意味着:
- 用清晰的命名与上下文,让每一行代码都讲述自己的故事。
重复不可怕,可怕的是用错误的抽象掩盖了本应独立的逻辑。保持警惕,保持简单,保持对“知识”而非“代码”的敬畏——这才是 DRY 的真谛。
信条:好的复用,是让变化只发生在一个地方;坏的复用,是让 bug 出现在所有地方。