Skip to content

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 工具。当需要复用代码时,直觉反应是"建个子类"——但这会迅速导致继承层次爆炸、耦合过深、修改困难。

问题场景: 游戏角色系统需要多种能力组合。

python
# ❌ 过度继承 — 组合爆炸
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-ahas-a
耦合度
灵活性编译时确定运行时可替换
测试性困难(整条继承链)简单(独立组件)

实战:Library 系统的组合设计

oop_demo/app/services/library.py 中,Library 类完全使用组合:

python
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 不产生输出
  • 新增功能只需添加新的组合组件,不影响已有结构

再来看通知服务的组合应用:

python
# 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 构造函数中自由注入。

从违反到遵循

python
# ❌ 违反:用继承实现"可通知的图书馆"
class NotifyingLibrary(Library, ConsoleNotification):
    """多重继承把两个不相关的类绑在一起"""
    # 问题:MRO 混乱、职责不清、无法单独测试通知逻辑
python
# ✅ 遵循: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 — 只负责通知
python
# ❌ 违反 SRP:一个上帝类做所有事
class Library:
    def add_book(self, ...): ...
    def send_email(self, ...): ...       # 不该管的事
    def save_to_database(self, ...): ... # 不该管的事
    def generate_report(self, ...): ...  # 不该管的事
python
# ✅ 遵循 SRP:职责分离
# Library 只做流程编排,通知交给 NotificationService
lib = Library("测试图书馆", notifier=EmailNotification())
OCP — 开放封闭

通知服务的扩展是 OCP 的最佳示范:

python
# 现有三种实现: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:

python
class Catalogable(ABC):
    @abstractmethod
    def get_info(self) -> str: ...
    @abstractmethod
    def get_isbn(self) -> str: ...

BookItem 及其所有子类都实现了这两个方法:

python
# 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 的典型场景:

python
# ❌ 子类破坏父类契约
class Book:
    def checkout(self) -> bool:
        return True  # 所有书都可借

class ReferenceBook(Book):
    def checkout(self) -> bool:
        raise ValueError("参考书不可外借")  # 违反 LSP!

# 调用方期望 Book.checkout() 永远返回 bool,
# 但 ReferenceBook 却抛异常,替换后程序崩溃
python
# ✅ 正确设计:在继承层次中保持行为一致
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_demoProtocol 定义了最小接口:

python
@runtime_checkable
class Borrowable(Protocol):
    """只需三个方法,不多不少"""
    def checkout(self) -> bool: ...
    def return_item(self) -> None: ...
    def is_available(self) -> bool: ...

不需要的对象不会被强迫实现多余方法。对比违反 ISP 的反例:

python
# ❌ 违反 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

python
def __init__(
    self,
    name: str,
    notifier: NotificationService | None = None,
) -> None:
    self._notifier: NotificationService = notifier or ConsoleNotification()

Library(高层)不依赖 ConsoleNotification(低层),两者都依赖 NotificationService(抽象)。

违反 DIP 的代价:

python
# ❌ 违反 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(如批量导入)
python
# ✅ 遵循 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 污染

从违反到遵循

python
# ❌ 违反 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
        # 每加一种类型就要改这里
python
# ✅ 遵循 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 record

SOLID 原则综合应用:借还流程完整分析

Library.borrow() 方法是 SOLID 五原则的集中体现:

python
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_idisbnISP不需要传入完整的 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()

反模式:不要这样做

python
# ❌ 上帝类 — 违反 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
python
# ❌ 硬编码依赖 — 违反 DIP
class Library:
    def __init__(self):
        self._notifier = ConsoleNotification()  # 不可替换

# ✅ 依赖注入
class Library:
    def __init__(self, notifier: NotificationService | None = None):
        self._notifier = notifier or ConsoleNotification()
python
# ❌ 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()  # 每个子类有自己的实现
python
# ❌ 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 关系组合避免耦合
第三方库集成适配器模式不修改第三方代码

能力清单

学完本章后,你应该能够:

  1. 判断复用方式:面对"is-a"和"has-a"场景,正确选择继承或组合
  2. 实现依赖注入:在构造函数中接收抽象接口而非具体类
  3. 应用 SOLID:在编写新类时,自觉检查五项原则
  4. 识别反模式:一眼看出上帝类、继承爆炸、违反 LSP 的设计
  5. 使用 Protocol:用结构类型定义最小接口,避免接口膨胀

验证

bash
# 运行 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          │
              └───────────────┘