接手过烂代码的人都有这种体验:加一个新需求,改一个文件,崩三个地方,回归测一周。代码是能跑,但没人敢改。而另一类代码,加需求只是”新增一个类”,旧的代码一行不动。两者之间差的,往往不是技术能力,而是七个设计原则。

写代码的第一天老师就教我们”高内聚、低耦合”,但真正落地的时候,靠的是这七个可执行的原则:单一职责、开放封闭、里氏替换、依赖倒置、接口隔离、组合复用、迪米特法则。它们不教你怎么实现功能,而是教你怎么让代码”扛得住变化”。

先给一张总览表,心里有个地图,后面逐个拆解。

原则一句话本质关注点
单一职责 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) {
// 职责1:业务校验
if (username == null || username.length() < 3) {
throw new IllegalArgumentException("用户名不合法");
}
// 职责2:数据持久化
try (Connection conn = DriverManager.getConnection("jdbc:mysql://...")) {
conn.prepareStatement("INSERT INTO user ...").executeUpdate();
} catch (SQLException e) {
throw new RuntimeException(e);
}
// 职责3:发送通知
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; // Spring 自动注入所有实现
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; // 传 Square 进来变成 9,断言失败!
}

问题不是”正方形是不是矩形”这种数学争论,而是行为不可替换:子类重写了 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<>(); // 组合,has-a

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
// 订完单要发通知,一路穿透了 4 层内部结构
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);
}
}

// OrderService 只跟自己的"朋友" Customer 说话
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
// ===== DIP + ISP:面向抽象、按角色拆接口 =====
public interface OrderRepository { void save(Order o); } // 持久化角色
public interface Notifier { void notify(Order o, String msg); } // 通知角色
public interface DiscountCalculator { double calc(Order o); } // 折扣角色

// ===== SRP:每个类单一职责 =====
@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) { /* INSERT */ }
}

// ===== OCP + LSP + CRP:折扣用策略组合,新增折扣=新增类,实现可替换 =====
@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(); }
}

// ===== LoD:只和直接朋友(注入的依赖)说话 =====
@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) { // DIP:构造器注入抽象
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); // 不 new、不穿透内部
notifier.notify(order, "下单成功,应付 " + total);
}
}

逐个对照:SRP 让校验/存储/通知各管一事;OCP 加新折扣只新增实现类;LSP 任意 DiscountCalculator 实现都能无缝替换;DIP 高层只依赖接口、由容器注入;ISP 三个接口各管一个角色互不臃肿;CRP 用组合(持有依赖引用)而不是继承去复用能力;LoD OrderService 只跟自己的直接朋友打交道,不触碰任何对象的内部结构。

七个原则全在这里了,而且每个都直接对应一种可执行的做法,不是空话。


总结:一句话记住七个原则

SRP 管”类为什么变”,OCP 管”扩展怎么加”,LSP 管”继承靠不靠谱”,DIP 管”依赖朝哪指”,ISP 管”接口拆多细”,CRP 管”复用用继承还是组合”,LoD 管”跟谁说话”。

最后说句掏心窝的话:设计原则不是考试题,是代码的”后悔药”。写代码的时候多问一句”这个类以后会不会因为别的原因被改””新增需求我要改旧代码还是加新代码”,你就已经在用这七个原则了。它不会让你今天写得快,但会让你三个月后改得动——而”能改”,才是软件工程里真正的财富。