SD-01 设计原则
01 · 设计原则(Design Principles)
📅 预计 60 分钟 | ⭐ = 重要知识点 | 📌 中英术语见文末
1.0 先给直觉:设计原则是"装修的规矩"
把写代码比作装修房子。房子能不能住得舒心,硬装和软装都有讲究:电线怎么走、插座装在哪、承重墙能不能砸、以后想加个房间要不要大动干戈。设计原则就是软件世界里的装修规矩——它不是某件具体的家具(那是设计模式),而是一套判断"这样改、这样拆、这样加,会不会出事"的准则。
学完这一篇,你会拥有一种"嗅到坏味道"的能力:看到某个类又长又杂、加个功能要改一堆文件、子类替换父类就出 bug,你能立刻指出来——它违反了哪条原则。
本篇核心是 SOLID 五原则,加上两个贯穿始终的"组合优于继承"和"高内聚低耦合"。
1.1 单一职责原则(SRP)⭐
一个类只负责一件事
单一职责原则(Single Responsibility Principle)说的是:一个类只有一个让它改变的理由。就像一把菜刀只负责切菜、一把钥匙只开一把锁——职责专一,才不容易互相连累。
反例很好找:一个 Student 类,既保存学生信息,又负责打印成绩单,还去连数据库。哪天数据库字段变了,打印功能没碰却被迫跟着改;哪天打印格式变了,数据库代码也可能被牵连。把这三件事拆开:
class Student: # 只保存数据
def __init__(self, name, score):
self.name = name
self.score = score
class ScorePrinter: # 只负责"打印"
def print_score(self, s: Student):
print(f"{s.name}: {s.score}")
class ScoreDB: # 只负责"存取"
def save(self, s: Student): ...
判断标准很朴素:要改这个类时,你的理由有几个? 如果一个理由(比如"成绩展示改版了")就足够,说明职责单一;如果不同方向的改动都会落到同一个类上,说明它们被硬塞在一起了。
⚠️ 常见错误
- 把"类的方法少"当成"职责单一"——一个类功能可以丰富,关键是这些功能是否都围绕同一件事。
- 一个方法里塞满
if-elif,每个分支各干各的职责,这种"上帝方法"同样违反 SRP,要拆。
1.2 开闭原则(OCP)⭐
对扩展开放,对修改封闭
开闭原则(Open-Closed Principle)要求:在不修改已有代码的前提下,就能为系统增加新功能。听起来很矛盾——不加代码怎么加功能?答案是靠抽象和多态:让变化的部分被"新增"而不是被"修改"覆盖。
看一个绘图程序。如果靠类型判断逐个分支处理,每加一种图形都要改主函数:
# 不好的做法:加新图形就要改 draw_all
def draw_all(shapes):
for s in shapes:
if s.kind == "circle":
draw_circle(s)
elif s.kind == "rect": # 每加一种图形,这里就要改
draw_rect(s)
更好的做法:让每种图形自己知道怎么画,主程序只面向统一接口,从此不用再改:
class Shape:
def draw(self): ...
class Circle(Shape):
def draw(self): print("画圆")
class Rect(Shape):
def draw(self): print("画矩形")
def draw_all(shapes): # 这个函数以后不需要动
for s in shapes:
s.draw()
将来新增 Triangle,只需新建一个类,draw_all 一行不动。这就是"对扩展开放、对修改封闭":扩展体现在新增类,封闭体现在旧代码不被改动。
⚠️ 常见错误
- 把"不修改"理解成"代码永远不能改"——OCP 针对的是"加新功能时不该被迫改旧功能",日常重构、修 bug 不算违反。
- 过度设计:还没出现新需求,就为可能的扩展提前抽象成接口,凭空增加理解成本。
1.3 里氏替换原则(LSP)⭐
子类要能替换父类
里氏替换原则(Liskov Substitution Principle)说的是:凡是能放父类对象的地方,换成子类对象,程序行为依然正确。子类不能悄悄打破父类的承诺。
最经典的反例是"正方形继承长方形"。长方形允许分别设置宽和高,正方形却要求两者永远相等。一旦有人把正方形当长方形用,调用 set_width 时高度也被悄悄改了,行为就乱了:
class Rectangle:
def set_width(self, w): self.w = w
def set_height(self, h): self.h = h
class Square(Rectangle): # 违背 LSP 的经典反例
def set_width(self, w):
self.w = w
self.h = w # 偷偷改高度,破坏父类约定
"是"的关系在语义上不成立,强行用继承就会破坏替换性。判断方法:把子类放进所有使用父类的场景,逐一验证行为是否仍然合理。行为不合理的继承,多半应该改成组合或重新建模。
⚠️ 常见错误
- 只因为"语法上能继承"就继承,不看语义是否真的符合 is-a 关系。
- 子类重写父类方法时抛出父类没声明的异常、返回不同类型的值、悄悄改前置条件,这些都是破坏替换性的表现。
1.4 接口隔离原则(ISP)⭐
别让调用者依赖它用不上的方法
接口隔离原则(Interface Segregation Principle):不要把一大堆方法塞进一个大接口,应当拆成多个小接口,让每个调用者只依赖它真正用到的部分。
把接口想象成餐厅菜单。如果菜单厚厚一大本,一家小吃店也必须"实现"做意面、做寿司,那纯属负担。用 Python 抽象基类示意:
from abc import ABC, abstractmethod
# 大而全的接口:实现它的类被迫实现一堆用不到的方法
class Worker(ABC):
@abstractmethod
def code(self): ...
@abstractmethod
def test(self): ...
@abstractmethod
def cook(self): ... # 程序员不需要会做饭
# 按职责拆开,谁需要谁实现
class Coder(ABC):
@abstractmethod
def code(self): ...
class Tester(ABC):
@abstractmethod
def test(self): ...
接口隔离让系统更稳:接口变小时,改动影响的范围也跟着变小,调用者不会被无关方法的变化波及。
⚠️ 常见错误
- 为了"接口统一"强行合并接口,最后调用者被迫依赖一堆空实现(
pass占位)。 - 接口拆得过于细碎,一个类要实现十几个接口,反而更难用——适度拆分即可。
1.5 依赖倒置原则(DIP)⭐
高层不依赖低层的具体实现
依赖倒置原则(Dependency Inversion Principle)有两条:
- 高层模块不应该依赖低层模块,两者都应依赖抽象;
- 抽象不应该依赖细节,细节应该依赖抽象。
用"开关"打比方:一个开关不该认识具体的"灯泡""风扇""空调",它只认一个抽象概念"可开关的东西"。这样加任何新电器,开关都不用改:
class Switchable: # 抽象:能开能关
def on(self): ...
def off(self): ...
class Lamp(Switchable): # 具体设备实现抽象
def on(self): print("灯亮")
def off(self): print("灯灭")
class Switch: # 高层只依赖抽象
def __init__(self, device: Switchable):
self.device = device
def turn_on(self):
self.device.on()
好处非常实际:业务层依赖"数据库接口",而不是某个具体的数据库类。从 MySQL 换成 PostgreSQL,业务代码一行不用动,只要换一个实现。
⚠️ 常见错误
- 把"依赖抽象"理解成"绕来绕去"——抽象是为了隔离变化,不是为了让调用路径更曲折。
- 高层直接
new低层具体对象(如new MySQLDB()),把选择权攥在自己手里,想换实现就要改代码。
1.6 组合优于继承 ⭐
优先"有一个",其次才是"是一个"
继承建立的是 is-a(是一个)关系,组合建立的是 has-a(有一个)关系。现实里"是"关系常被滥用:鸟会飞,但企鹅不会飞,硬让企鹅继承"会飞的鸟"立刻出事。
更稳的做法:把"飞行能力"做成一个组件,谁需要谁就组合进去:
class FlyBehavior:
def fly(self): print("展翅飞")
class Bird: # 组合:鸟"有一个"飞行能力
def __init__(self, fly_behavior):
self.fly_behavior = fly_behavior
def do_fly(self):
self.fly_behavior.fly()
penguin = Bird(FlyBehavior()) # 示意:换一个不会飞的组件即可
为什么组合更灵活:
- 继承关系在编译期就写死了,运行时很难改变行为;
- 继承会把父类的所有能力(哪怕用不到的)一并带给子类,容易破坏 LSP;
- 多层继承容易撞上"菱形问题"(一个类同时继承两个有共同祖先的类)。
组合则可以在运行时更换组件,且不强制父子语义。
⚠️ 常见错误
- 为了"复用几行代码"就继承,结果引入一堆用不到的方法,还破坏了替换性。
- 反过来也一样:关系稳定、确实是 is-a 的场景(如"猫是动物")也硬用组合,反而绕。
1.7 高内聚、低耦合 ⭐
内部紧抱一团,对外少打扰
高内聚(high cohesion):一个模块内部的元素联系紧密,都围绕同一个目的。低耦合(low coupling):模块之间的依赖尽量少、尽量简单。
想象一个班级做项目:高内聚是"每个小组负责一个明确任务,组内配合默契";低耦合是"组与组之间只需要一个简单交接,不互相打探内部细节"。
- 高内聚让模块好改:改动集中在内部,不扩散到外面。
- 低耦合让模块好换:换掉一个模块,别的模块几乎无感。
这两个指标常常相伴出现——内聚高,通常耦合也低。它们是用直觉衡量可维护性最直接的两个标尺:一个模块既好改又好换,维护成本自然低。
⚠️ 常见错误
- 只看"类名短、文件少"就以为高内聚——关键看元素是否围绕同一职责运转。
- 为了"完全解耦"而层层转发,把简单的 A→B 调用绕成 A→X→Y→B 的消息接力,耦合没降反升。
📌 术语对照(中英)
| 中文 | English |
|---|---|
| 单一职责原则 | Single Responsibility Principle (SRP) |
| 开闭原则 | Open-Closed Principle (OCP) |
| 里氏替换原则 | Liskov Substitution Principle (LSP) |
| 接口隔离原则 | Interface Segregation Principle (ISP) |
| 依赖倒置原则 | Dependency Inversion Principle (DIP) |
| 组合优于继承 | Composition over Inheritance |
| 高内聚低耦合 | High Cohesion, Low Coupling |
🎯 复习清单
- SOLID 五个原则分别管什么问题,用一句话说清各自要点。
- 开闭原则为什么依赖"抽象 + 多态",新增功能为什么不该改旧代码。
- "正方形继承长方形"违反哪条原则(LSP),根因是什么。
- 组合与继承的取舍标准,组合在何时比继承更合适。
- 高内聚、低耦合各自怎么判断,为什么它们能衡量可维护性。