06-设计原则
Python 版本要求:Python 3.11+ 贯穿项目:oop_demo/ 代码位置:
oop_demo/app/services/library.py+oop_demo/app/services/notification.py测试验证:cd oop_demo && uv run pytest -k design -v
概念铺垫
第一部分:组合优于继承
为什么
继承是初学者最容易"过度使用"的 OOP 工具。当需要复用代码时,直觉反应是"建个子类"——但这会迅速导致继承层次爆炸、耦合过深、修改困难。
问题场景: 游戏角色系统需要多种能力组合。
# ❌ 过度继承 — 组合爆炸
class Character: ...
class FlyingCharacter(Character): ...
class SwimmingCharacter(Character): ...
class FlyingSwimmingCharacter(FlyingCharacter, SwimmingCharacter): ...
# 再加一个 Running?FlyingSwimmingRunningCharacter?每增加一种能力,子类数量呈指数增长。
类比
- 继承 = 从父母那里继承基因。你无法改变,也无法选择。
- 组合 = 背包系统。需要什么装备就往里放,随时可以更换。
好的设计优先用背包,而不是靠血缘。
原理
┌────────────────────────────────────────────────────┐
│ 组合 vs 继承 选择指南 │
├────────────────────────────────────────────────────┤
│ 继承(is-a):确实存在分类关系 │
│ Dog is-a Animal ✓ │
│ Rectangle is-a Shape ✓ │
│ │
│ 组合(has-a):能力可替换、可组合 │
│ Car has-a Engine ✓ │
│ Logger has-a LogHandler[] ✓ │
│ │
│ 原则:优先组合,不确定时一定选组合 │
│ 继承层次建议不超过 3 层 │
└────────────────────────────────────────────────────┘| 维度 | 继承 | 组合 |
|---|---|---|
| 关系 | is-a | has-a |
| 耦合度 | 高 | 低 |
| 灵活性 | 编译时确定 | 运行时可替换 |
| 测试性 | 困难(整条继承链) | 简单(独立组件) |
实战:Library 系统的组合设计
oop_demo/app/services/library.py 中,Library 类完全使用组合:
class Library:
"""图书馆 — 纯组合,零继承"""
def __init__(
self,
name: str,
notifier: NotificationService | None = None,
) -> None:
self.name: str = name
self._books: dict[str, BookItem] = {} # has-a books
self._members: dict[str, Member] = {} # has-a members
self._records: list[BorrowRecord] = [] # has-a records
self._notifier: NotificationService = notifier or ConsoleNotification()为什么不用继承?
| 如果错误使用继承 | 问题 |
|---|---|
class LibraryWithBooks(BookItem) | Library 不是 BookItem,关系不成立 |
class NotifyingLibrary(ConsoleNotification) | Library 不是通知器 |
class Library(SomeBaseService) | 继承层次加深,修改父类影响所有子类 |
组合的优势在此体现:
_books、_members、_records、_notifier各自独立演化- 测试时可传入
SilentNotification不产生输出 - 新增功能只需添加新的组合组件,不影响已有结构
再来看通知服务的组合应用:
# oop_demo/app/ports/notification.py(Protocol 定义接口)
class NotificationService(Protocol):
def notify(self, member: Member, message: str) -> None: ...
# oop_demo/app/services/notification.py(具体实现)
class ConsoleNotification:
def notify(self, member: Member, message: str) -> None:
print(f"[通知] {member.name}: {message}")
class SilentNotification:
def __init__(self) -> None:
self.messages: list[tuple[str, str]] = []
def notify(self, member: Member, message: str) -> None:
self.messages.append((member.name, message))
class EmailNotification:
def __init__(self) -> None:
self.sent_emails: list[tuple[str, str, str]] = []
def notify(self, member: Member, message: str) -> None:
self.sent_emails.append((member.email, member.name, message))三种实现互不继承,都满足 NotificationService 协议,Library 构造函数中自由注入。
从违反到遵循
# ❌ 违反:用继承实现"可通知的图书馆"
class NotifyingLibrary(Library, ConsoleNotification):
"""多重继承把两个不相关的类绑在一起"""
# 问题:MRO 混乱、职责不清、无法单独测试通知逻辑# ✅ 遵循:Library 组合 NotificationService
lib = Library("测试图书馆", notifier=SilentNotification())
lib.borrow("M001", "9780000000001") # 内部通过 self._notifier.notify() 发送通知第二部分:SOLID 原则
为什么
SOLID 不是理论口号,而是你在写类时会反复遇到的具体决策:
- 这个类是不是管得太多了?→ SRP
- 加新功能要不要改旧代码?→ OCP
- 子类能不能安全地替代父类?→ LSP
- 接口是不是太大?→ ISP
- 我依赖的是实现还是抽象?→ DIP
类比
把软件设计想象成开一家图书馆:
| 原则 | 图书馆类比 | 代码体现 |
|---|---|---|
| SRP | 图书管理员只管借还,不修书架、不记账 | 一个类一个职责 |
| OCP | 新增服务(如电子书借阅)不需要改变现有流程 | 扩展不修改 |
| LSP | 任何能借的书,不管纸质还是电子,流程一致 | 子类可替换父类 |
| ISP | 会员不需要知道编目细节,编目员不需要知道通知方式 | 接口小而专 |
| DIP | 通知系统对接"通知协议",不直接对接"控制台打印" | 依赖抽象 |
原理
┌─────────────────────────────────────────────────────┐
│ SOLID 速查 │
├─────────────────────────────────────────────────────┤
│ S - 单一职责 一个类只做一件事 │
│ O - 开放封闭 对扩展开放,对修改封闭 │
│ L - 里氏替换 子类可替换父类不破坏行为 │
│ I - 接口隔离 接口要小而专,不强迫实现不需要的 │
│ D - 依赖倒置 高层不依赖低层,两者都依赖抽象 │
└─────────────────────────────────────────────────────┘实战:SOLID 在 Library 中的体现
SRP — 单一职责
oop_demo 的类职责清晰分离:
BookItem — 只管理图书数据(title, author, isbn, year)
Member — 只管理会员信息(name, email, borrow/return)
BorrowRecord — 只记录借阅时间线
Library — 只编排借还流程
NotificationService — 只负责通知# ❌ 违反 SRP:一个上帝类做所有事
class Library:
def add_book(self, ...): ...
def send_email(self, ...): ... # 不该管的事
def save_to_database(self, ...): ... # 不该管的事
def generate_report(self, ...): ... # 不该管的事# ✅ 遵循 SRP:职责分离
# Library 只做流程编排,通知交给 NotificationService
lib = Library("测试图书馆", notifier=EmailNotification())OCP — 开放封闭
通知服务的扩展是 OCP 的最佳示范:
# 现有三种实现:Console / Silent / Email
# 新增 Slack 通知——无需修改 Library 任何代码:
class SlackNotification:
def notify(self, member: Member, message: str) -> None:
# 发送 Slack 消息
...
lib = Library("测试图书馆", notifier=SlackNotification())Library 的 _notifier.notify() 调用不变,新实现直接插入。
LSP — 里氏替换
oop_demo/app/ports/catalog.py 定义了 Catalogable ABC:
class Catalogable(ABC):
@abstractmethod
def get_info(self) -> str: ...
@abstractmethod
def get_isbn(self) -> str: ...BookItem 及其所有子类都实现了这两个方法:
# Library.add_book 接受 Catalogable
def add_book(self, book: BookItem) -> None:
self._books[book.isbn] = book
# 传入任何子类都不会破坏行为:
lib.add_book(PhysicalBook(...)) # ✓
lib.add_book(EBook(...)) # ✓
lib.add_book(AudioBook(...)) # ✓违反 LSP 的典型场景:
# ❌ 子类破坏父类契约
class Book:
def checkout(self) -> bool:
return True # 所有书都可借
class ReferenceBook(Book):
def checkout(self) -> bool:
raise ValueError("参考书不可外借") # 违反 LSP!
# 调用方期望 Book.checkout() 永远返回 bool,
# 但 ReferenceBook 却抛异常,替换后程序崩溃# ✅ 正确设计:在继承层次中保持行为一致
class BookItem:
def checkout(self) -> bool: ...
class PhysicalBook(BookItem):
def checkout(self) -> bool:
if not self._available:
return False
self._available = False
return True # 行为一致:返回 bool
class ReferenceBook(BookItem):
def checkout(self) -> bool:
return False # 仍然返回 bool,不调用方不意外如果某个子类重写 get_isbn() 返回空字符串,或者 checkout() 抛出异常而非返回 False,就违反了 LSP。
LSP 自检清单:
- 子类方法返回类型是否与父类兼容?
- 子类是否会抛出父类不会抛的异常?
- 子类是否弱化了父类的前置条件?
- 子类是否强化了父类的后置条件?
ISP — 接口隔离
oop_demo 用 Protocol 定义了最小接口:
@runtime_checkable
class Borrowable(Protocol):
"""只需三个方法,不多不少"""
def checkout(self) -> bool: ...
def return_item(self) -> None: ...
def is_available(self) -> bool: ...不需要的对象不会被强迫实现多余方法。对比违反 ISP 的反例:
# ❌ 违反 ISP:大而全的接口
class LibraryItem(Protocol):
def checkout(self) -> bool: ...
def return_item(self) -> None: ...
def is_available(self) -> bool: ...
def download(self) -> bool: ... # 纸质书不需要
def scan_barcode(self) -> str: ... # 电子书不需要违反 ISP 的直接后果:实现类被迫写 raise NotImplementedError 或返回 None,这些都是"接口撒谎"。
ISP 的实用规则: 如果一个接口的方法超过 4 个,停下来想想是否能拆分。
DIP — 依赖倒置
Library 构造函数接收 NotificationService | None:
def __init__(
self,
name: str,
notifier: NotificationService | None = None,
) -> None:
self._notifier: NotificationService = notifier or ConsoleNotification()Library(高层)不依赖 ConsoleNotification(低层),两者都依赖 NotificationService(抽象)。
违反 DIP 的代价:
# ❌ 违反 DIP:硬编码具体实现
class Library:
def __init__(self, name: str) -> None:
self._notifier = ConsoleNotification() # 死依赖
def borrow(self, ...) -> None:
self._notifier.notify(...)
# 问题:
# 1. 测试时必须捕获 stdout,无法用 SilentNotification 验证
# 2. 想换成 EmailNotification 必须改 Library 源码
# 3. 无法在不通知的场景下复用 Library(如批量导入)# ✅ 遵循 DIP:构造时注入
class Library:
def __init__(self, name: str, notifier: NotificationService | None = None) -> None:
self._notifier = notifier or ConsoleNotification()
# 测试时:
notifier = SilentNotification()
lib = Library("test", notifier=notifier)
lib.borrow(...)
assert len(notifier.messages) == 1 # 干净断言,无 stdout 污染从违反到遵循
# ❌ 违反 SOLID 的代码
class BadLibrary:
"""违反 SRP:又管理书,又发邮件,又存数据库"""
def __init__(self) -> None:
self.books: dict = {}
def add_book(self, book: dict) -> None: # 违反 DIP:依赖 dict 而非 BookItem
self.books[book["isbn"]] = book
def notify_member(self, email: str, msg: str) -> None: # 违反 SRP
print(f"邮件: {email} - {msg}")
def borrow_book(self, isbn: str) -> None:
# 违反 OCP:如果要支持 EBook/PhysicalBook 不同流程,要改这个函数
book = self.books[isbn]
if book["type"] == "physical":
book["available"] = False
elif book["type"] == "ebook":
book["downloaded"] = True
# 每加一种类型就要改这里# ✅ 遵循 SOLID 的代码(oop_demo 实际实现)
class Library:
"""SRP:只编排借还流程"""
def __init__(self, name: str, notifier: NotificationService | None = None) -> None:
self._books: dict[str, BookItem] = {}
self._notifier: NotificationService = notifier or ConsoleNotification() # DIP
def add_book(self, book: BookItem) -> None: # DIP:依赖 BookItem 抽象
self._books[book.isbn] = book
def borrow(self, member_id: str, isbn: str) -> BorrowRecord | None:
# OCP:任何 BookItem 子类都通过统一的 checkout() 接口处理
book = self._books.get(isbn)
if not book or not book.checkout():
return None
# 通知也是通过接口
self._notifier.notify(member, f"《{book.title}》借阅成功")
return recordSOLID 原则综合应用:借还流程完整分析
Library.borrow() 方法是 SOLID 五原则的集中体现:
def borrow(self, member_id: str, isbn: str) -> BorrowRecord | None:
member = self._members.get(member_id) # SRP:Library 只管编排
book = self._books.get(isbn)
if not member or not book:
return None
if not book.checkout(): # LSP:任何 BookItem 子类都可调用
return None
member.borrow(isbn)
record = BorrowRecord(member_id=member_id, isbn=isbn) # SRP:Record 只管记录
self._records.append(record)
self._notifier.notify( # DIP:依赖接口,不关心具体通知方式
member,
f"《{book.title}》借阅成功,请于 {record.due_date.date()} 前归还",
)
return record逐行分析:
| 代码 | 体现的原则 | 说明 |
|---|---|---|
book.checkout() | LSP | 不关心是 PhysicalBook、EBook 还是 AudioBook |
self._notifier.notify(...) | DIP + OCP | 依赖 Protocol,新增通知方式无需改这里 |
BorrowRecord(...) | SRP | 借阅记录由专门的 dataclass 负责 |
| 方法本身只做流程编排 | SRP | 不直接打印、不存数据库、不生成报告 |
接受 member_id 和 isbn | ISP | 不需要传入完整的 Member/Book 对象,只依赖需要的标识 |
L2 实践层:用好
推荐做法
| 做法 | 原因 | 示例 |
|---|---|---|
| 优先组合,不确定时选组合 | 耦合低、可替换、易测试 | Library has-a NotificationService |
| is-a 关系才用继承 | 确保语义正确 | PhysicalBook is-a BookItem |
| 依赖注入:构造时接收抽象 | 高内聚低耦合,可测试 | Library(name, notifier=...) |
| 一个类只做一件事(SRP) | 修改原因只有一个 | 分离 Library / Notification / Record |
| 扩展新功能不修改旧代码(OCP) | 符合开闭原则 | 新增 SlackNotification 不改 Library |
| 接口小而专(ISP) | 不强迫实现不需要的方法 | Borrowable 只有 3 个方法 |
| 依赖抽象而非具体(DIP) | 高层不依赖低层 | 依赖 NotificationService 而非 ConsoleNotification |
| Mixin 用单一职责 | 每个 Mixin 只提供一种能力 | DigitalMixin 只提供 download_info() |
反模式:不要这样做
# ❌ 上帝类 — 违反 SRP
class Library:
def add_book(self, ...): ...
def send_email(self, ...): ...
def save_to_database(self, ...): ...
def generate_report(self, ...): ...
def manage_users(self, ...): ...
# ✅ 职责分离
class Library: # 借还流程
pass
class NotificationService: # 通知
pass
class ReportGenerator: # 报表
pass# ❌ 硬编码依赖 — 违反 DIP
class Library:
def __init__(self):
self._notifier = ConsoleNotification() # 不可替换
# ✅ 依赖注入
class Library:
def __init__(self, notifier: NotificationService | None = None):
self._notifier = notifier or ConsoleNotification()# ❌ isinstance 分支 — 违反 OCP
def process(book):
if isinstance(book, PhysicalBook):
return process_physical(book)
elif isinstance(book, EBook):
return process_ebook(book)
# 每加一种类型都要改这里
# ✅ 多态
def process(book: BookItem):
return book.process() # 每个子类有自己的实现# ❌ is-a 不成立却用继承 — 违反 LSP / 滥用继承
class Library(BookItem): # Library 不是 BookItem!
pass
# ✅ has-a 用组合
class Library:
def __init__(self):
self._books: dict[str, BookItem] = {}适用场景
| 场景 | 推荐做法 | 原因 |
|---|---|---|
| 多个类共享相同接口 | ABC / Protocol | 统一约束 |
| 通知、日志、缓存等横切关注点 | 组合 + 依赖注入 | 可替换、可测试 |
| 明显的分类层次 | 继承(不超过 3 层) | is-a 语义自然 |
| 需要复用代码但无 is-a 关系 | 组合 | 避免耦合 |
| 第三方库集成 | 适配器模式 | 不修改第三方代码 |
能力清单
学完本章后,你应该能够:
- 判断复用方式:面对"is-a"和"has-a"场景,正确选择继承或组合
- 实现依赖注入:在构造函数中接收抽象接口而非具体类
- 应用 SOLID:在编写新类时,自觉检查五项原则
- 识别反模式:一眼看出上帝类、继承爆炸、违反 LSP 的设计
- 使用 Protocol:用结构类型定义最小接口,避免接口膨胀
验证
# 运行 oop_demo 第 6 章演示
cd 02-核心编程篇/02-面向对象编程/oop_demo
uv run python -m app # 选择 6
# 运行相关测试
uv run pytest tests/test_oop.py -k design -v知识关联
设计原则知识关联:
┌───────────────┐
│ 第 3 章:继承 │
│ is-a 关系 │
└───────┬───────┘
│
↓
┌───────────────┐
│ 第 6 章:设计 │
│ 组合 > 继承 │
└───────┬───────┘
│
┌────────────┼────────────┐
↓ ↓ ↓
┌───────────┐ ┌───────────┐ ┌───────────┐
│ SRP │ │ OCP/DIP │ │ LSP/ISP │
│ 单一职责 │ │ 开闭/依赖 │ │ 里氏/接口 │
└───────────┘ └───────────┘ └───────────┘
│
↓
┌───────────────┐
│ 第 5 章:多态 │
│ Protocol │
│ ABC │
└───────────────┘