软件设计

[SOLID #1] SOLID 五个字母,不要死记而要理解(SRP・OCP・LSP)

面试时被问过“请解释一下 SOLID 原则”这个问题的人,应该都有过一次吧。

3 分钟阅读
[SOLID #1] SOLID 五个字母,不要死记而要理解(SRP・OCP・LSP) 封面图

面试时被问过“请解释一下 SOLID 原则”这个问题的人,应该都有过一次吧。

但说实话,即使能把五条原则逐一背出来,能继续回答“那该怎么用在我的代码里?”的人也并不多。

这次不讲教科书定义,而是通过实际工作场景来拆解 SOLID。内容较多,所以分成上下两篇。今天先讲前三个字母:SRP、OCP 和 LSP。

SOLID 是 Robert Martin(Uncle Bob)整理出的面向对象设计五大原则的首字母缩写。

  • SRP — 单一职责原则
  • OCP — 开闭原则
  • LSP — 里氏替换原则
  • ISP — 接口隔离原则
  • DIP — 依赖倒置原则

五条原则看起来各自讨论不同的问题,但贯穿其中的核心信息只有一个:发生变更时,缩小需要修改的代码范围。


SRP — 单一职责原则

教科书中的定义是“一个类应该只有一个职责”,但 Uncle Bob 本人后来用更好的方式重新表述了它。

一个模块应该只对一个行为者负责。

这里的行为者,是指要求修改这段代码的人或部门。假设我们有这样一个类。

class Employee {
    func calculatePay() { ... }   // 会计团队要求修改
    func reportHours() { ... }    // 人力资源团队要求修改
    func save() { ... }           // 开发团队(DBA)要求修改
}

三个方法的负责人完全不同。如果因会计团队的需求修改 calculatePay时碰到了共享逻辑,人力资源报表可能会悄悄出错。实际发生这种事故时,也很难找到原因。

SRP 将其总结为:“要求修改的人不同,代码也要分开。”如果把工资计算、工作报表和存储分别拆成不同的类,会计团队的需求就不会影响人力资源功能。


OCP — 开闭原则

定义是“对扩展开放,对修改关闭”。刚听到时像是禅宗问答,但放到代码中就很清楚了。

// 如果每增加一种支付方式都要修改这个函数
func pay(method: String, amount: Int) {
    if method == "card" { ... }
    else if method == "kakaopay" { ... }
    else if method == "naverpay" { ... }
    // 新支付方式 = 修改 else if = 现有代码
}

在这种结构中,要添加 Toss Pay,就必须打开并修改原本运行正常的函数。每次修改都要承担破坏现有支付方式的风险。

// 只需“添加”新的支付方式即可的结构
let payments: [String: Payment] = [
    "card": CardPayment(),
    "kakaopay": KakaoPayment(),
    "naverpay": NaverPayment(),
    // 添加 Toss Pay = 一行注册新类
]

func pay(method: String, amount: Int) {
    payments[method]?.process(amount: amount)
}

无需触碰现有代码(对修改关闭),只要添加新类就能扩展功能(对扩展开放)。诀窍是只在经常变化的地方采用这种结构。如果所有代码都这样做,就会违反上一篇提到的 YAGNI。

添加 Toss Pay,只需再加一个类就结束
添加 Toss Pay,只需再加一个类就结束
OCP 就是用添加来应对,而不是修改
OCP 就是用添加来应对,而不是修改

LSP — 里氏替换原则

名字看起来最吓人,但含义很简单。

子类应该可以直接替换父类出现在任何位置,而不会破坏程序。

最著名的反例是矩形和正方形问题。如果按照“正方形是矩形”这一数学常识来继承,就会发生这种情况。

class Rectangle {
    var width = 0
    var height = 0
    func setWidth(_ w: Int) { width = w }
    func setHeight(_ h: Int) { height = h }
}

class Square: Rectangle {
    override func setWidth(_ w: Int) { width = w; height = w }  // 因为它是正方形
    override func setHeight(_ h: Int) { width = h; height = h }
}

// 期待矩形的代码
func test(_ rect: Rectangle) {
    rect.setWidth(5)
    rect.setHeight(4)
    print(rect.width * rect.height) // 20期待 Square但如果是正方形 16
}

一旦传入 Square,“宽 5、高 4 时面积为 20”这一理所当然的预期就被打破了。即使 is-a 关系看似成立,只要破坏行为契约,就不应该使用继承,这就是 LSP。

实际工作中的信号是这样的:如果子类重写父类方法后抛出异常,或者留下空实现,那么这种继承很可能违反了 LSP。

原来正方形无法放到矩形的位置
原来正方形无法放到矩形的位置

上篇到此结束

把今天介绍的三条原则各留下一句话吧。

  • SRP — 要求修改的人不同,代码也要分开。
  • OCP — 让经常变化的地方能够通过添加而不是修改来应对。
  • LSP — 子类不要破坏父类的契约。如果必须破坏,说明继承就是错误的。

下一篇将介绍剩下的两个字母:ISP(接口隔离)和 DIP(依赖倒置)。尤其是 DIP,它与现代框架为什么都使用依赖注入直接相关,应该会很有趣。