cswork / 系统设计

第 16 章 / 共 57 章 · 约 5 分钟

SOLID 原则在面试里的真实用法

本页目录
  1. 别背定义,要能指着代码说
  2. S:单一职责(Single Responsibility)
  3. O:开闭原则(Open/Closed)
  4. L:里氏替换(Liskov Substitution)
  5. I:接口隔离(Interface Segregation)
  6. D:依赖倒置(Dependency Inversion)
  7. 组合优于继承
  8. 速查表
  9. 面试里怎么说
  10. 章节测验

别背定义,要能指着代码说

几乎每个候选人都能背出 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 重写成抛异常。它有两个问题:

  1. 它根本不是只读的:addAll、set、remove、iterator().remove() 照样能改。
  2. 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。”

原则只是工具,判断力才是面试官真正在打分的东西。能说出“这里我故意没有抽象,因为……”的候选人,往往比处处套模式的候选人得分更高。

章节测验

5 道选择题
  1. 单一职责原则更准确的表述是?
  2. Penguin 继承 Bird,并把 fly() 重写为抛异常,主要违反了哪条原则?
  3. 为什么要给 TTL 缓存注入 Clock?
  4. 以下哪种做法是打着开闭原则旗号的过度设计?
  5. Machine 接口包含 print、scan、fax,导致简单打印机被迫抛异常。最好的修正是?

章节讨论

正在加载讨论…