Skip to content

设计原则:怎么判断违反了,以及什么时候不必遵守 ​

「单一职责」「开闭原则」这些定义很好背,难的是回答两个问题:怎么判断当前代码违反了它,以及什么时候刻意不遵守是对的。这篇给每条原则配一个可检查的判断标准和一个反向条件。

本文是设计系列的第五篇。前几篇讨论了设计的对象(软件设计到底在设计什么)、关注点分离(关注点分离与可测试性)以及面向对象的三个手段(封装、组合与多态)。原则是在这些基础上判断取舍的尺子。

一、先说结论 ​

  • 所有原则服务于同一个目标:让变化的影响范围可控。 判断是否该遵守,先问「这部分会不会变、变的时候要改几个地方」。
  • 每条原则都有对应的坏味道:一个类有多个修改理由、加功能要改老代码、子类抛 UnsupportedOperationException、实现类被迫实现空方法、业务代码里出现具体的技术类名。
  • 原则之间会打架:单一职责与 KISS、DRY 与解耦经常冲突。冲突时按「变化频率」而不是「谁更正统」来决定。
  • 最容易被误用的是 DRY:把「长得像」当成「是同一件事」,会把两个本应独立演化的功能绑死。
  • 没有证据的扩展需求不应该驱动抽象:需求还没出现时,抽象层通常是负债。但已经发生过的变化、以及出错代价很高的边界(支付、权限、对外接口),应该主动设计。
  • 可测试性是反馈,不是第六条原则:一段代码难以测试,通常说明依赖、副作用或职责没有被隔离开。

二、SOLID 的五条 ​

2.1 单一职责(SRP) ​

定义:一个类应该只有一个引起它变化的原因。Robert C. Martin 后来把它表述得更具体:一个模块应该只对一类行为者(actor)负责,也就是只对一类会提出修改要求的人负责。

怎么判断违反了:说出这个类的职责时,如果需要用「并且」,通常就违反了。或者问:哪些角色会要求修改这个类? 如果产品、风控、运维三方都会来改它,它就有三个变化原因。

java
// 违反:三个变化原因挤在一起
class OrderService {
    void placeOrder(Order order) {
        if (order.amount().compareTo(LIMIT) > 0) { ... }   // 风控规则,风控要改
        orderMapper.insert(order);                          // 持久化,DBA 要改
        mailClient.send(order.userEmail(), render(order));  // 通知模板,运营要改
    }
}

// 改进:各自独立变化
class OrderService {
    private final RiskChecker riskChecker;
    private final OrderRepository repository;
    private final OrderNotifier notifier;

    void placeOrder(Order order) {
        riskChecker.check(order);
        repository.save(order);
        notifier.notifyCreated(order);
    }
}

什么时候不必遵守:逻辑很短、只有一个调用方、并且从没改过的代码。为一个二十行的工具类拆出三个接口,增加的理解成本大于收益。

单一职责也不是「粒度越小越好」。拆分的收益是变化被隔离,代价是协作变多:读一个流程要跳更多文件,改一个功能要同步修改更多类。当拆开的两部分总是一起修改时,就说明拆过头了,应该合回去。

2.2 开闭原则(OCP) ​

定义:对扩展开放,对修改关闭——增加新行为时不应该修改已有代码。

要关闭的是已经稳定的核心,而不是所有代码。扩展点应该来自已经观察到的第二个变化,而不是想象中的未来需求。

怎么判断违反了:每次加一种新类型,都要回去改同一个 if-else 或 switch。

java
// 违反:每加一种支付方式就要改这里
BigDecimal fee(String channel, BigDecimal amount) {
    if ("ALIPAY".equals(channel))  return amount.multiply(new BigDecimal("0.006"));
    if ("WECHAT".equals(channel))  return amount.multiply(new BigDecimal("0.006"));
    if ("UNIONPAY".equals(channel)) return amount.multiply(new BigDecimal("0.005"));
    throw new IllegalArgumentException(channel);
}

// 改进:新增渠道只需要新增一个实现类
interface FeeCalculator {
    boolean supports(String channel);
    BigDecimal fee(BigDecimal amount);
}

class FeeCalculators {
    private final List<FeeCalculator> calculators;      // Spring 自动注入所有实现

    BigDecimal fee(String channel, BigDecimal amount) {
        return calculators.stream()
                .filter(c -> c.supports(channel))
                .findFirst()
                .orElseThrow(() -> new IllegalArgumentException(channel))
                .fee(amount);
    }
}

什么时候不必遵守:分支数量固定且很少变(比如只有「成功 / 失败」两种),或者暂时还看不出扩展方向。过早地为开闭原则铺路,往往会抽象出错误的接口——等真正的第二种情况出现时,会发现接口定义得不对。经验做法是「第二次出现时再抽象」。

类型集合由自己掌控时,Java 21 的密封类型加穷举 switch 是另一种选择:新增类型时,编译器会指出所有没有处理它的 switch,修改点不会遗漏。见 封装、组合与多态。

2.3 里氏替换(LSP) ​

定义:子类对象能够替换父类对象出现在任何地方,而不破坏程序的正确性。

怎么判断违反了:子类里出现 throw new UnsupportedOperationException(),或者调用方需要 instanceof 判断具体子类后区别对待。

java
// 违反:正方形不是矩形的合法子类型,改宽度会连带改高度
class Rectangle { void setWidth(int w); void setHeight(int h); }
class Square extends Rectangle { /* setWidth 同时改 height */ }

// 依赖父类契约的代码会失败
void resize(Rectangle r) {
    r.setWidth(5); r.setHeight(4);
    assert r.area() == 20;        // Square 传进来时不成立
}

详细的分析与修复方式见 里氏替换原则。

什么时候不必遵守:没有「不必遵守」的情况。违反 LSP 意味着继承关系建错了,正确做法是改用组合或重新划分类型,而不是接受它。

2.4 接口隔离(ISP) ​

定义:不应该强迫客户端依赖它不需要的方法。

怎么判断违反了:实现类里有一堆空方法,或者 return null;、throw new UnsupportedOperationException()。从调用方看,则是一个调用方只用到接口里的一小部分方法。

接口应该按调用者的角色来定义,而不是把实现类的方法列表原样搬出来。同一个实现类可以实现多个角色接口,不同的调用方各自依赖自己需要的那一个。

java
// 违反:只读的数据源被迫实现写方法
interface DataSource {
    Data read(String key);
    void write(String key, Data data);
    void delete(String key);
}

// 改进:按能力拆分,实现类各取所需
interface ReadableSource { Data read(String key); }
interface WritableSource { void write(String key, Data data); void delete(String key); }

JDK 里常被提到的例子是 java.sql.Connection。在 JDK 21 上用反射统计,它有 60 个公共方法,其中 54 个抽象方法、6 个 default 方法。每个驱动都要实现全部抽象方法,而大多数业务代码只用到其中的获取语句、事务控制和关闭。

default 方法让接口可以新增能力而不破坏已有的实现,但代价是能力是否可用变成了运行时才知道的事。实测 Connection 上与分片相关的两个 default 方法(setShardingKey、setShardingKeyIfValid),在驱动没有重写时直接抛出 SQLFeatureNotSupportedException,编译期看不出来。

JDK 集合框架则是有意的取舍:List 把 add、remove 标为「可选操作」,Arrays.asList 返回的列表可以 set 但 add 会抛 UnsupportedOperationException。如果严格按接口隔离,只读、定长、可变列表需要分成多个接口,类型数量会成倍增加。JDK 选择用文档约定换取更少的类型,这是一个明确记录在接口契约里的例外。

什么时候不必遵守:接口只有一个实现、而且短期内不会有第二个。拆得太细会让调用方需要同时注入好几个接口。

2.5 依赖倒置(DIP) ​

定义:高层模块不应该依赖低层模块,两者都应该依赖抽象;抽象不应该依赖细节。

倒置前OrderServiceMysqlOrderRepository直接 new倒置后OrderServiceOrderRepository接口由业务层定义MysqlOrderRepository实现在外层实现依赖接口判断标准:换一个实现(内存、其他数据库、Mock)需不需要改业务代码
图 1 · 倒置之前,业务代码直接依赖具体实现;倒置之后,双方都依赖业务层定义的接口,实现可以随时替换

怎么判断违反了:业务代码里出现了具体的技术类名(RedisTemplate、XxxHttpClient、MysqlXxxMapper),或者「换一个实现要改业务代码」。

java
// 违反:业务逻辑直接持有具体实现
class OrderService {
    private final MysqlOrderRepository repository = new MysqlOrderRepository();
}

// 改进:接口由业务层定义(注意接口归属),实现放在外层
package com.acme.order.domain;
public interface OrderRepository {          // 业务层定义它需要什么
    void save(Order order);
    Optional<Order> findById(long id);
}

package com.acme.order.infrastructure;
class MysqlOrderRepository implements OrderRepository { ... }   // 实现依赖接口

关键不在于「用了接口」,而在于接口属于谁。如果接口是按数据库能力定义的(selectByExample、updateSelective),那只是给实现套了层壳,依赖方向并没有倒置。

什么时候不必遵守:稳定的标准库和语言设施(String、List、LocalDate)不需要抽象;只有一个实现且永远不会换的技术组件,加接口只是增加跳转层级。

控制反转、依赖注入、依赖倒置不是同一件事:

概念回答的问题例子
控制反转(IoC)谁掌握流程的控制权框架调用你的代码,而不是你调用框架
依赖注入(DI)依赖对象由谁创建、怎样交给使用方Spring 容器通过构造器把 OrderRepository 传给 OrderService
依赖倒置(DIP)依赖的方向指向哪里业务层定义 OrderRepository,数据库实现去依赖它

用了 Spring 的依赖注入,不代表依赖已经倒置:如果注入的是 MysqlOrderMapper,业务代码依然依赖着具体技术。反过来,不用任何容器、手动 new 出实现再传进去,也可以做到依赖倒置。Spring 容器的模型见 读懂一个系统的设计。

三、DRY、KISS、YAGNI 与可测试性 ​

3.1 DRY:重复的是「知识」,不是「代码行」 ​

DRY 的原始定义是「系统中每一项知识都必须有单一、明确、权威的表示」。重点是知识,不是字符。

java
// 看起来重复,但其实是两件事:一个是下单风控,一个是提现风控
if (amount.compareTo(new BigDecimal("50000")) > 0) { manualReview(); }   // 下单
if (amount.compareTo(new BigDecimal("50000")) > 0) { manualReview(); }   // 提现

强行抽成 checkLimit(amount) 之后,风控要求「下单阈值调到 10 万、提现保持 5 万」时就会很难受——两个本应独立变化的规则被绑在了一起。

判断标准:如果两处代码会因为不同的原因而修改,它们就不是重复。

反过来,真正的重复(同一个业务规则在三个地方各写一遍)必须消除,否则改的时候一定会漏。

3.2 KISS 与 YAGNI ​

  • KISS:能用简单方式解决就不要引入复杂机制。一个 if 能解决的判断,不需要策略模式加工厂。
  • YAGNI:不为「以后可能需要」的功能写代码。预留的扩展点在真需求到来时,十有八九方向是错的。

这两条是对前面所有原则的制动器。SOLID 倾向于「多拆分、多抽象」,KISS 与 YAGNI 负责踩刹车。

YAGNI 针对的是没有证据的需求。下面两类情况要主动设计,而不是等问题出现:

  • 已经发生过的变化:同一个地方第二次因为同一类需求被修改,就是抽象的信号。
  • 出错代价很高的边界:对外 API、消息格式、数据库表结构、权限和资金相关的逻辑,改错一次的代价远高于提前设计的成本。

Kent Beck 的四条简单设计规则给出了「简单」的可检查定义,按优先级排列:

  1. 通过所有测试;
  2. 清楚地表达意图;
  3. 没有重复的知识;
  4. 在满足前三条的前提下,元素(类、方法)尽可能少。

第四条正是对过度抽象的约束:多出来的接口和类,要能用前三条中的某一条来证明。

3.3 可测试性是反馈 ​

可测试性不是和 SOLID 并列的第六条原则,而是检验它们的反馈。一段代码难以测试,通常是下面几种情况之一:

  • 必须启动容器或连接外部服务才能测,说明规则和 I/O 混在一起(依赖倒置没做到);
  • 结果依赖当前时间或随机数,说明不可控的输入没有被隔离;
  • 一个测试要准备大量无关的数据,说明这个类承担了太多职责(单一职责)。

这些信号的实测对比见 关注点分离与可测试性。

四、原则冲突时怎么选 ​

单一职责 / 接口隔离拆得更细KISS / YAGNI不要过度设计DRY消除重复拆过头 → 类爆炸抽象不足 → 改动扩散强行复用 → 耦合判断顺序:这段逻辑会不会独立变化?变化时要改几个地方?改动会不会波及无关功能?三个问题都指向「变化」——原则服务于可维护性,不是目的本身
图 2 · 原则之间会互相拉扯,没有一条可以无条件贯彻;决定权在「这段代码会不会变、变的时候改几处」

真实的代码评审里,冲突比遵守更常见:

冲突判断依据
单一职责 vs KISS这两部分会不会因为不同原因修改?不会就先合着
DRY vs 解耦两处代码会不会因为不同原因修改?会就允许重复
开闭原则 vs YAGNI第二种情况出现了吗?没出现就先写 if
接口隔离 vs 调用方便利实现类里有没有空方法?没有就不用拆

统一的判断标准只有一个:变化。会独立变化的东西要分开,会一起变化的东西要放在一起,还看不出会不会变的东西先别动。

五、常见误区 ​

  • 「原则是规范,必须全部遵守」:它们是经验总结,适用条件是「代码会变化」。
  • 「用了接口就是依赖倒置」:接口按实现的能力定义时,依赖方向并没有改变。
  • 「代码一样就要抽出来」:DRY 针对的是知识重复,不是文本重复。
  • 「先把扩展点留好」:没有第二个用例时,抽象往往是错的。
  • 「用了 Spring 注入就是依赖倒置」:依赖注入解决对象由谁创建,依赖倒置解决依赖指向哪里,两者可以独立存在。
  • 「类拆得越小越符合单一职责」:总是一起修改的两个类,应该合回去。
  • 「设计模式用得越多设计越好」:模式是解决特定问题的手段,见 设计模式决策图。

小结 ​

把五条 SOLID 原则翻译成可检查的问题,比记住定义有用得多:这个类有几个修改理由?加新类型要不要改老代码?子类能不能替换父类?调用方是不是只用到接口的一小部分?业务代码里有没有技术类名?然后用 KISS 和 YAGNI 做制动:**在变化真正出现之前,简单就是最好的设计;变化已经出现、或者边界出错代价很高时,就该主动设计。**难以测试的代码,是提醒你回头检查这几个问题的信号。


配套实验

可以用 make verify 一条命令复现;链接固定在实验仓库的 c9f6692 版本。

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权