接手过烂代码的人都有这种体验:加一个新需求,改一个文件,崩三个地方,回归测一周。代码是能跑,但没人敢改。而另一类代码,加需求只是”新增一个类”,旧的代码一行不动。两者之间差的,往往不是技术能力,而是七个设计原则。
写代码的第一天老师就教我们”高内聚、低耦合”,但真正落地的时候,靠的是这七个可执行的原则:单一职责、开放封闭、里氏替换、依赖倒置、接口隔离、组合复用、迪米特法则。它们不教你怎么实现功能,而是教你怎么让代码”扛得住变化”。
先给一张总览表,心里有个地图,后面逐个拆解。
| 原则 | 一句话本质 | 关注点 |
|---|
| 单一职责 SRP | 一个类只有一个引起变化的原因 | 类级 |
| 开放封闭 OCP | 对扩展开放、对修改关闭 | 类/模块级 |
| 里氏替换 LSP | 子类必须能无缝替换父类且不破坏契约 | 继承关系 |
| 依赖倒置 DIP | 高层不依赖低层,都依赖抽象 | 依赖方向 |
| 接口隔离 ISP | 客户端不依赖它用不到的接口方法 | 接口粒度 |
| 组合复用 CRP | 优先组合(has-a)而非继承(is-a) | 复用方式 |
| 迪米特 LoD | 只和直接朋友说话,不和陌生人通信 | 耦合面 |
一、单一职责原则(SRP)
本质:一个类只应该有一个”引起它变化的原因”。判断标准不是”它做了几件事”,而是”它有几个独立的变化轴”。
先看反面教材——一个类同时扛着校验、持久化、通知三件事:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| public class UserService { public void register(String username, String password) { if (username == null || username.length() < 3) { throw new IllegalArgumentException("用户名不合法"); } try (Connection conn = DriverManager.getConnection("jdbc:mysql://...")) { conn.prepareStatement("INSERT INTO user ...").executeUpdate(); } catch (SQLException e) { throw new RuntimeException(e); } EmailUtil.send("user@example.com", "注册成功"); } }
|
这个类有多少个改它的理由?密码规则变了要改它,表结构变了要改它,邮件模板变了要改它。任何一个改动都可能把另外两个功能改坏——一个类背上三个变化源,等于三倍的出错面。
拆开之后:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| public class UserValidator { public void validate(String username, String password) { ... } }
public class UserRepository { public void save(User user) { ... } }
public class EmailNotifier { public void sendWelcome(User user) { ... } }
@Service public class UserService { public void register(String username, String password) { validator.validate(username, password); repository.save(new User(username, password)); notifier.sendWelcome(...); } }
|
最佳实践:
- 判断方法只有一个:问自己”这个类有几个修改理由”,多于一个就拆。
- 但别矫枉过正。过度拆分比不拆更糟——同一个变化轴上的逻辑硬拆成七八个类,类爆炸、方法满天飞。判断”是否同一变化轴”比”方法数量”重要得多。
- 代码信号:类里出现
if (type == A) ... else if (type == B) 大分支,或 import 了多个毫不相干的领域类,都是职责过重的味道。
二、开放封闭原则(OCP)
本质:软件实体对扩展开放(加新功能不用动旧代码),对修改关闭(已有代码尽量不改)。靠的是抽象和多态,不是改 if-else。
反面教材——每加一种支付方式,就改一次原类:
1 2 3 4 5 6 7 8 9 10 11
| public class PaymentService { public void pay(String type, double amount) { if ("alipay".equals(type)) { System.out.println("支付宝支付 " + amount); } else if ("wechat".equals(type)) { System.out.println("微信支付 " + amount); } else if ("card".equals(type)) { System.out.println("银行卡支付 " + amount); } } }
|
这种代码最可怕的地方在于:每加一次功能,就要重新验证一遍所有旧逻辑。产品半年加了五种支付方式,这个类就被动过五次,每一次都是一次风险敞口。
正确的打开方式——策略模式,新支付方式只是”新增一个类”:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| public interface PaymentStrategy { boolean support(String type); void pay(double amount); }
@Component public class AlipayStrategy implements PaymentStrategy { public boolean support(String type) { return "alipay".equals(type); } public void pay(double amount) { } }
@Component public class WechatPayStrategy implements PaymentStrategy { public boolean support(String type) { return "wechat".equals(type); } public void pay(double amount) { } }
@Component public class PaymentService { private final List<PaymentStrategy> strategies; public void pay(String type, double amount) { strategies.stream().filter(s -> s.support(type)) .findFirst().orElseThrow(...) .pay(amount); } }
|
以后要加银联支付,新建一个 UnionPayStrategy 就行,PaymentService 一行不改。新需求 = 新增代码,而不是修改旧代码,这就是 OCP 的验收标准。
最佳实践:
- 但 OCP 不是让你到处套接口。识别出变化点再抽象——变化点还不确定就提前抽象,是过度设计的重灾区。
- 落地模式常客:策略、模板方法、装饰器、观察者、工厂。
- 别为了”完全不改”写出绕来绕去的配置地狱,适度即可。
三、里氏替换原则(LSP)
本质:所有引用基类的地方,换成子类对象后行为必须依然正确。继承必须满足真正的”is-a”关系,且不能破坏父类的契约(前置条件、后置条件、不变量)。
这是最容易被踩的一条。经典反例——Square extends Rectangle:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| public class Rectangle { private int width, height; public void setWidth(int w) { this.width = w; } public void setHeight(int h) { this.height = h; } public int area() { return width * height; } }
public class Square extends Rectangle { @Override public void setWidth(int w) { super.setWidth(w); super.setHeight(w); } @Override public void setHeight(int h) { super.setHeight(w); super.setWidth(w); } }
public void resize(Rectangle r) { r.setWidth(5); r.setHeight(3); assert r.area() == 15; }
|
问题不是”正方形是不是矩形”这种数学争论,而是行为不可替换:子类重写了 setWidth 的语义(连带改了 height),破坏了父类的不变量。所有用父类引用写逻辑的代码,遇到这个子类就全部失灵。
其他常见违规形态,都是大厂代码 review 必查项:
- 子类抛出父类未声明的更宽泛异常(把父类能处理的情况变成失败);
- 子类削弱父类能力(父类方法在子类里直接抛”不支持”);
- 子类前置条件更严格(父类接受 null,子类拒绝),或后置条件更弱(父类保证排序,子类不保证)。
正确做法——不强行继承,抽真正的共性:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| public interface Shape { int area(); }
public class Rectangle implements Shape { private int width, height; public Rectangle(int w, int h) { this.width = w; this.height = h; } public int area() { return width * height; } }
public class Square implements Shape { private int side; public Square(int side) { this.side = side; } public int area() { return side * side; } }
|
最佳实践:
- 自测一句话:”这段代码把父类引用换成子类对象,结果还一样吗?” 不一样,继承关系就是假的。
- 一旦发现要”重写父类方法 + 加一堆
if (obj instanceof X)“,大概率已经违反 LSP。 - 优先接口 + 组合,别用继承去硬”套”关系。
四、依赖倒置原则(DIP)
本质:高层模块不依赖低层模块,两者都依赖抽象;抽象不依赖细节,细节依赖抽象。落地就一句话:面向接口编程 + 依赖注入。
反面教材——高层直接 new 具体实现:
1 2 3 4 5 6 7
| public class OrderService { private final MysqlOrderRepository repository = new MysqlOrderRepository();
public void createOrder(Order order) { repository.insert(order); } }
|
想换数据库、加缓存、做 mock 测试?全得改 OrderService。而且 OrderService 还得自己管 MysqlOrderRepository 的生命周期——它不该管,也管不好。
正确做法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| public interface OrderRepository { void insert(Order order); }
@Component public class MysqlOrderRepository implements OrderRepository { ... }
@Service public class OrderService { private final OrderRepository repository;
public OrderService(OrderRepository repository) { this.repository = repository; }
public void createOrder(Order order) { repository.insert(order); } }
|
换实现只改 Spring 装配,OrderService 一行不动,测试直接注入 mock。“倒置”指的是控制流倒置:不是你去 new 依赖,而是容器把依赖”倒过来”送给你——这就是 IoC 控制反转。
最佳实践:
- 自测:
OrderService 的 import 里出现的是 OrderRepository(抽象)还是 MysqlOrderRepository(细节)?是细节就违背了 DIP。 - Spring 里构造器注入优先,避免字段注入。
- 但别所有类都套接口——只有存在多个实现/替换需求时才抽象,否则是接口爆炸。
五、接口隔离原则(ISP)
本质:客户端只依赖它真正用到的接口方法。胖接口要按角色拆成小接口,别让实现类被逼着实现一堆没意义的方法。
反面教材——一个”万能”接口:
1 2 3 4 5 6 7 8 9 10 11
| public interface Worker { void work(); void eat(); void sleep(); }
public class Robot implements Worker { public void work() { } public void eat() { throw new UnsupportedOperationException("机器人不吃饭"); } public void sleep(){ throw new UnsupportedOperationException("机器人不睡觉"); } }
|
Robot 被迫实现两个永远不会用的方法,要么抛异常、要么留空。调用方还必须知道”这个 Worker 其实不支持 eat/sleep”——接口的契约被污染了。
正确做法——按角色拆:
1 2 3 4 5 6 7 8 9
| public interface Workable { void work(); } public interface Eatable { void eat(); } public interface Restable { void sleep(); }
public class Robot implements Workable { public void work() { } }
public class Human implements Workable, Eatable, Restable { ... }
|
最佳实践:
- 代码信号:接口里出现
UnsupportedOperationException、空实现、@Deprecated 占位方法,就是该拆了。 - 一个接口对应”某种角色的完整能力“,一个类可实现多个角色接口。
- Java 8 的默认方法能缓解接口演进,但别拿它掩盖设计问题——那是止痛药,不是治疗。
六、组合复用原则(CRP)
本质:尽量用对象组合(has-a,持有接口引用委托调用)实现复用,而不是继承(is-a)。继承会暴露父类实现细节、加深耦合,组合只暴露你需要的能力。
经典反面教材是 Java 标准库自己踩过的坑——Stack extends Vector:
1 2 3 4
| public class Stack<E> extends Vector<E> { public E push(E item) { addElement(item); return item; } public E pop() { ... } }
|
问题在于:Stack 本应只允许 push/pop(后进先出),却继承了 Vector 的 add(int, E)、get(int) 等任意位置插入/访问方法——栈的语义被彻底破坏。现实中无数 Stack 被误用,就是继承复用惹的祸。这同时也是一个 LSP 反例:父类 Vector 的能力被子类 Stack 无意义地继承。
正确做法——组合持有底层容器,只暴露设计好的能力:
1 2 3 4 5 6 7 8 9 10 11
| public class Stack<E> { private final ArrayList<E> elements = new ArrayList<>();
public void push(E e) { elements.add(e); } public E pop() { if (elements.isEmpty()) throw new EmptyStackException(); return elements.remove(elements.size() - 1); } public int size() { return elements.size(); } }
|
最佳实践:
- 复用能力时先问:”这是 is-a 还是 has-a?” 能用组合就别用继承,这是判断的默认值。
- 继承只保留三种情况:真 is-a、子类能完整替代父类(LSP)、你要用父类的多态扩展点(模板方法)。
- 装饰器、策略、代理本质都是组合复用;模板方法、工厂方法属于继承复用,用之前务必确认继承关系成立。
七、迪米特法则(LoD / 最少知识原则)
本质:一个对象只和它的直接朋友通信,不跟”陌生人”说话。直接朋友 = 自己、方法参数、自己的成员变量、自己创建的对象。
反面教材——火车残骸式调用链:
1 2 3
| order.getCustomer().getAddress().getCity().getPostOffice() .sendNotification("您的订单已发货");
|
OrderService 为了发个通知,被迫了解了 Customer → Address → City → PostOffice 的全部内部结构。中间任何一层的字段改名,这里就得跟着改——耦合面被无限放大。这就是传说中的”火车残骸”(Train Wreck)坏味道。
正确做法——由”朋友”封装完整能力:
1 2 3 4 5 6 7 8 9
| public class Customer { private final Address address; public void notifyShipping(String msg) { address.getCity().getPostOffice().sendNotification(msg); } }
order.getCustomer().notifyShipping("您的订单已发货");
|
最佳实践:
- 代码信号:连续 3 层以上的
.getX().getY().getZ(),基本就是和陌生人说话了。 - 但 LoD 是适度原则,不是”零调用链”。过度封装会让中间类变成传话人,反而更难维护。
- 判断标准:中间类封装的是真正的领域行为(
notifyShipping 是 Customer 的职责)就合理;只为少写两个 getter 硬包一层,就不合理。 - 门面模式、中介者模式都是 LoD 的落地。
把七个原则放一起:一个真实场景
用一个 Spring Boot 风格的订单系统,看它们怎么协同作战:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| public interface OrderRepository { void save(Order o); } public interface Notifier { void notify(Order o, String msg); } public interface DiscountCalculator { double calc(Order o); }
@Component public class OrderValidator { public void validate(Order o) { if (o == null || o.getItems().isEmpty()) throw new IllegalArgumentException("订单为空"); } }
@Component public class OrderRepositoryImpl implements OrderRepository { public void save(Order o) { } }
@Component public class VipDiscount implements DiscountCalculator { public double calc(Order o) { return o.getAmount() * 0.85; } } @Component public class CouponDiscount implements DiscountCalculator { public double calc(Order o) { return o.getAmount() - o.getCoupon(); } }
@Service public class OrderService { private final OrderValidator validator; private final OrderRepository repository; private final Notifier notifier; private final List<DiscountCalculator> discounts;
public OrderService(OrderValidator v, OrderRepository r, Notifier n, List<DiscountCalculator> d) { this.validator = v; this.repository = r; this.notifier = n; this.discounts = d; }
public void placeOrder(Order order) { validator.validate(order); double total = discounts.stream() .mapToDouble(d -> d.calc(order)).min().orElse(order.getAmount()); order.applyDiscount(total); repository.save(order); notifier.notify(order, "下单成功,应付 " + total); } }
|
逐个对照:SRP 让校验/存储/通知各管一事;OCP 加新折扣只新增实现类;LSP 任意 DiscountCalculator 实现都能无缝替换;DIP 高层只依赖接口、由容器注入;ISP 三个接口各管一个角色互不臃肿;CRP 用组合(持有依赖引用)而不是继承去复用能力;LoD OrderService 只跟自己的直接朋友打交道,不触碰任何对象的内部结构。
七个原则全在这里了,而且每个都直接对应一种可执行的做法,不是空话。
总结:一句话记住七个原则
SRP 管”类为什么变”,OCP 管”扩展怎么加”,LSP 管”继承靠不靠谱”,DIP 管”依赖朝哪指”,ISP 管”接口拆多细”,CRP 管”复用用继承还是组合”,LoD 管”跟谁说话”。
最后说句掏心窝的话:设计原则不是考试题,是代码的”后悔药”。写代码的时候多问一句”这个类以后会不会因为别的原因被改””新增需求我要改旧代码还是加新代码”,你就已经在用这七个原则了。它不会让你今天写得快,但会让你三个月后改得动——而”能改”,才是软件工程里真正的财富。