第 16 章 / 共 57 章 · 约 5 分钟
SOLID 原则在面试里的真实用法
本页目录
别背定义,要能指着代码说
几乎每个候选人都能背出 SOLID 五个字母,但面试官真正想看的是:你的类图里哪里体现了这些原则,以及你什么时候选择不用。下面每条原则都配一个面试中真实会出现的例子。
S:单一职责(Single Responsibility)
更准确的说法是:一个类只应该有一个“被修改的理由”。
反例:
class ParkingLot {
Spot allocate(Vehicle v) {...}
long fee(Ticket t) {...} // 计费规则变了要改这里
void printReceipt(Ticket t) {...} // 小票格式变了也要改这里
void saveToDb(Ticket t) {...} // 换数据库还要改这里
}
三种变化来自三拨不同的人(运营、前台、后端),却都要动同一个类。拆成 SpotAllocator、PricingStrategy、ReceiptPrinter、TicketRepository,每个类被修改的理由就只剩一个,改计费不会误伤打印。
判断技巧:试着用一句话描述这个类。如果句子里出现“并且”,大概率可以拆。
O:开闭原则(Open/Closed)
对扩展开放,对修改关闭:加新行为时尽量新增代码,而不是修改已经测试过的老代码。
面试里最常见的落点是策略模式:
interface PricingStrategy { long fee(Ticket t, Instant exit); }
class HourlyPricing implements PricingStrategy {...}
class WeekendPricing implements PricingStrategy {...}
加一种周末计费,只新增一个类,ParkingLot 一行不动。
但要注意:开闭原则针对的是已知会变的维度。题目没有暗示会变的地方,就别提前抽象。“我先写成具体类,如果计费规则需要多种,再抽出接口”——这句话同样体现了设计判断,而且更诚实。
L:里氏替换(Liskov Substitution)
子类必须能在任何使用父类的地方无缝替换,而不破坏正确性。子类不能要求更严格的输入,也不能给出更弱的保证。
经典反例是“正方形继承长方形”:setWidth 会悄悄改掉高度,依赖长方形行为的代码就坏了。另一个常用的例子:
class Bird {
void fly() {...}
}
class Penguin extends Bird {
@Override void fly() {
throw new UnsupportedOperationException();
}
}
for (Bird b : flock) b.fly(); // 混进一只企鹅就崩
Bird 对调用方的承诺是“能飞”,Penguin 给不出这个保证,调用方只好到处写 instanceof 特判。修法是把能力拆出来:Bird 只保留所有鸟都有的行为,会飞的再实现 Flyer 接口,需要飞的代码只接收 Flyer。
判断信号:子类重写方法后抛异常或什么都不做,往往说明继承关系建错了,应该改用组合,或者把接口拆开。
一个容易讲错的例子是 class ReadOnlyList<T> extends ArrayList<T>,只把 add 重写成抛异常。它有两个问题:
- 它根本不是只读的:
addAll、set、remove、iterator().remove()照样能改。 - Java 的
List.add在文档里标为可选操作(optional operation),允许抛UnsupportedOperationException,JDK 自己的List.of就这么做。严格按接口约定,它不算违反 LSP。
Java 集合把修改操作设成“可选”,本身常被批评违背 LSP/ISP 的精神:拿到一个 List 的调用方并不知道能不能改。真要只读,用组合:返回 Collections.unmodifiableList(list),或者只暴露一个不含修改方法的只读接口。
I:接口隔离(Interface Segregation)
不要强迫调用方依赖它用不到的方法。
interface Machine { void print(); void scan(); void fax(); }
一台只能打印的机器不得不实现 scan() 和 fax() 并抛异常——这又回到了里氏替换的问题。拆成 Printer、Scanner、Fax 三个小接口,多功能机同时实现三个即可。
电梯题里也一样:乘客面板只需要 requestFloor(),维护人员才需要 setMaintenanceMode(),别把它们塞进同一个接口暴露给所有人。
D:依赖倒置(Dependency Inversion)
高层模块依赖抽象,而不是具体实现;具体实现通过构造函数注入。
在 LLD 面试里,它最有价值的用途是可测试性。比如带 TTL 的缓存:
class TtlCache {
private final Clock clock; // 注入,而不是直接调用系统时间
TtlCache(int capacity, Clock clock) {...}
}
测试时传入一个可以手动拨动的假时钟,就能在毫秒内验证过期逻辑,不用真的 sleep 几秒。面试官一般会对这个细节点头。
组合优于继承
五条原则背后有一个共同倾向:用组合(has-a)表达可变的行为,继承(is-a)只留给真正稳定的分类层级。比如车辆类型,一个 enum Size 字段往往就够了,不需要 Car extends Vehicle、Truck extends Vehicle 一串子类,它们唯一的区别只是返回不同的尺寸。
速查表
| 原则 | 一句话 | 面试中的体现 | 过度使用的样子 |
|---|---|---|---|
| S | 一个修改理由 | 计费、分配、持久化分开 | 每个方法一个类 |
| O | 加功能靠新增 | 策略接口 | 不会变的地方也抽接口 |
| L | 子类可替换 | 不把方法重写成抛异常 | 为了“复用”硬继承 |
| I | 接口要小 | 按调用方角色拆接口 | 一个方法一个接口 |
| D | 依赖抽象并注入 | 注入 Clock、Repository | 到处是容器和工厂 |
面试里怎么说
不要说“这里我用了开闭原则”然后就停下。更好的说法是把原因和代价一起讲:“题目说计费规则可能有多种,所以我放在 PricingStrategy 接口后面;代价是多一层间接,但新增规则不用碰 ParkingLot。”
原则只是工具,判断力才是面试官真正在打分的东西。能说出“这里我故意没有抽象,因为……”的候选人,往往比处处套模式的候选人得分更高。