Skip to content

05-多态

Python 版本要求:Python 3.11+ 贯穿项目:oop_demo/ 代码位置oop_demo/app/ports/catalog.py + oop_demo/app/domain/book/model.py测试验证cd oop_demo && uv run pytest -k polymorph -v


概念铺垫

为什么需要多态?

问题场景

图书馆系统里有三种图书:实体书、电子书、有声书。每本书都要展示编目信息,但每种书的"关键信息"不同:

图书类型get_info() 输出示例
PhysicalBook`...
EBook`...
AudioBook`...

不使用多态:需要逐一 isinstance 判断,每加一种新类型就要改一遍函数。

使用多态:统一调用 book.get_info(),Python 自动根据实际类型选择正确的实现。

isinstance 的场景

除了 get_info() 多态调用,还需要判断对象是否"可借阅"。传统做法是 isinstance(book, BookItem),但如果有一个外部类也实现了借还逻辑呢?Protocol 让 isinstance 只看行为,不看继承。


生活类比:万能遥控器

同一个"开/关"按钮(接口),能控制电视、空调、音响(不同实现)。用户不需要知道每种设备的内部电路,只需要按同一个按钮。

  • 按钮 = 接口get_info(), checkout(), return_item()
  • 不同设备 = 不同类:PhysicalBook, EBook, AudioBook
  • 用户 = 调用者for book in books: print(book.get_info())

L1 理解层:会用

多态的核心:同一接口,不同实现

多态的三种方式

┌──────────────────┬────────────────────┬────────────────────────┐
│   方式           │   匹配机制          │   适用关系              │
├──────────────────┼────────────────────┼────────────────────────┤
│ 1. 继承多态      │ 子类重写父类方法    │ is-a(明确的继承关系)  │
│ 2. ABC 多态      │ 抽象基类强制实现    │ is-a(必须遵守的契约)  │
│ 3. Protocol 多态 │ 结构匹配(鸭子类型) │ has-behavior(有这行为就行)│
└──────────────────┴────────────────────┴────────────────────────┘

ABC(抽象基类):Catalogable

概念

ABC(Abstract Base Class)用于定义"必须遵守的契约":

  • 继承 ABC 的类不能直接实例化,除非实现了所有 @abstractmethod
  • 未实现抽象方法的子类在实例化时立即抛出 TypeError
  • 适用于 is-a 关系:PhysicalBook 是一种 可编目的图书

代码:ports/catalog.py

python
from abc import ABC, abstractmethod

class Catalogable(ABC):
    """可被编目的抽象基类

    所有图书类型都必须实现 get_info() 和 get_isbn(),
    否则无法实例化 → 演示 ABC 的强制接口约束。
    """

    @abstractmethod
    def get_info(self) -> str:
        ...

    @abstractmethod
    def get_isbn(self) -> str:
        ...

子类实现:domain/book/model.py

python
class BookItem(Catalogable):
    """图书基类 — 显式继承 Catalogable"""

    def get_isbn(self) -> str:
        return self._isbn

    def get_info(self) -> str:
        return f"[{self._isbn}] {self.title}{self.author} ({self._year})"


class PhysicalBook(BookItem):
    """实体书 — 重写 get_info(),添加页数信息"""

    def get_info(self) -> str:
        return f"{super().get_info()} | {self._pages} 页"


class EBook(BookItem):
    """电子书 — 重写 get_info(),添加格式和大小"""

    def get_info(self) -> str:
        return f"{super().get_info()} | {self._format} {self.__file_size_mb:.1f} MB"


class AudioBook(PhysicalBook, DigitalMixin):
    """有声书 — 重写 get_info(),添加有声书标识"""

    def get_info(self) -> str:
        return f"{super().get_info()} | 有声书 {self.download_info()}"

ABC 的强制约束

python
>>> class Incomplete(Catalogable):
...     pass  # 未实现 get_info() 和 get_isbn()

>>> Incomplete()
TypeError: Can't instantiate abstract class Incomplete
           without an implementation for abstract methods 'get_info', 'get_isbn'

关键理解:错误发生在实例化时Incomplete()),而非定义时。Python 在 __new__ 阶段检查抽象方法是否全部实现。


Protocol(结构类型):Borrowable

概念

Protocol 用于定义"行为协议"——不关心你继承什么,只看你有没有这些方法:

  • 无需显式继承:只要对象拥有协议要求的方法,就被视为满足协议
  • 结构子类型(Structural Subtyping):匹配基于结构,而非名义
  • 适用于 has-behavior 关系:有借还行为的对象就是可借阅的
  • @runtime_checkableisinstance(obj, Protocol) 在运行时生效

代码:ports/catalog.py

python
from typing import Protocol, runtime_checkable

@runtime_checkable
class Borrowable(Protocol):
    """可借阅协议 — 鸭子类型

    只要对象拥有这三个方法,就被视为"可借阅",无需显式继承。
    @runtime_checkable 允许 isinstance() 检查。
    """

    def checkout(self) -> bool:
        ...

    def return_item(self) -> None:
        ...

    def is_available(self) -> bool:
        ...

结构匹配:自动满足

python
from app.domain.book.model import BookItem
from app.ports.catalog import Borrowable

book = BookItem("测试", "作者", "9780000000001", 2020)
isinstance(book, Borrowable)  # True — BookItem 没有显式继承 Borrowable
                              # 但实现了 checkout/return_item/is_available

关键理解:BookItem 从未声明 class BookItem(Borrowable),但因为实现了三个方法,Python 在 isinstance 时自动认为它满足 Borrowable 协议。这就是"结构子类型"——看结构,不看声明。


ABC vs Protocol 对比

这是学习者最常混淆的问题。核心区别在于匹配机制

特性ABC(Catalogable)Protocol(Borrowable)
继承要求必须显式继承 Catalogable无需继承,自动匹配
检查方式名义子类型(Nominal)结构子类型(Structural)
适用关系is-a(是一种)has-behavior(有这行为)
实例化保护未实现抽象方法 → TypeError不保护,运行时才发现缺方法
isinstance直接支持需要 @runtime_checkable 装饰器
类型检查器mypy 基于继承关系检查mypy 基于结构匹配检查
典型场景强制子类实现特定接口灵活接纳第三方对象

何时用 ABC

python
# 场景:你是框架作者,要求所有插件必须实现特定方法
class Plugin(ABC):
    @abstractmethod
    def execute(self, data: dict) -> dict: ...

# 好处:如果开发者忘了实现,实例化时立即报错,不会等到运行时

何时用 Protocol

python
# 场景:你写了一个函数,希望接受任何有 .read() 方法的对象
class Readable(Protocol):
    def read(self, size: int = -1) -> str: ...

def process(reader: Readable) -> str:
    return reader.read()

# 好处:标准库的 open()、io.StringIO()、自定义类——只要实现了 read() 就能用
# 无需修改第三方类的继承关系

选择决策树

需要强制子类实现方法?
  ├── 是 → ABC(契约式,失败快)
  └── 否 → 需要类型提示 + 灵活性?
              ├── 是 → Protocol(结构匹配,鸭子类型 + 静态检查)
              └── 否 → 直接用鸭子类型(不写 Protocol,纯运行时)

贯穿实战:多态调用

demo_ch05 演示(app/main.py

python
def demo_ch05() -> None:
    """多态 — ABC + Protocol"""
    from app.domain.book.model import AudioBook, BookItem, EBook, PhysicalBook
    from app.ports.catalog import Borrowable, Catalogable

    # 1. ABC 强制约束演示
    try:
        class Incomplete(Catalogable):
            pass
        Incomplete()
    except TypeError as e:
        print(f"实例化未实现抽象方法的类 → TypeError: {e}")

    # 2. Protocol 结构匹配演示
    book = BookItem("测试", "作者", "9780000000001", 2020)
    print(f"BookItem 是 Borrowable? {isinstance(book, Borrowable)}")

    # 3. 多态调用:同一方法,不同实现
    books: list[Catalogable] = [
        PhysicalBook("流畅的Python", "Ramalho", "9781491946008", 2015, 792),
        EBook("深入理解Python", "作者", "9780000000002", 2022, 5.2, "EPUB"),
        AudioBook("代码大全", "McConnell", "9780735619678", 2004, 960, 150.0),
    ]
    for b in books:
        print(f"  {b.__class__.__name__}: {b.get_info()}")

运行验证

bash
# 方式 1:交互式菜单
cd oop_demo && uv run python -m app  # 选 5

# 方式 2:单元测试
cd oop_demo && uv run pytest -k polymorph -v

测试覆盖(tests/test_oop.py:TestPolymorphism

测试验证内容
test_catalogable_abc_cannot_instantiate_without_methodsABC 强制约束
test_book_item_satisfies_catalogableBookItem 满足 Catalogable
test_book_item_satisfies_borrowable_protocolBookItem 满足 Borrowable(结构匹配)
test_borrowable_checkout_return_cycle借还状态循环
test_polymorphic_get_info多态调用返回不同格式

L2 实践层:用好

推荐做法

做法原因示例
is-a 关系用 ABC强制契约,失败快class BookItem(Catalogable)
has-behavior 用 Protocol灵活接纳,无需修改第三方isinstance(obj, Borrowable)
Protocol 加 @runtime_checkable否则 isinstance 会报错@runtime_checkable class Borrowable(Protocol)
类型提示用基类或 Protocol不要写 list[object]books: list[Catalogable]
ABC 至少有一个 @abstractmethod否则 ABC 可被实例化Catalogable 有 2 个抽象方法
避免 isinstance 分支判断违反开放封闭原则直接用多态调用

反模式:不要这样做

python
# ❌ 用 isinstance 分支判断类型 —— 违反开放封闭原则
def show_book_info(book):
    if isinstance(book, PhysicalBook):
        print(f"{book.title}{book.pages}页")
    elif isinstance(book, EBook):
        print(f"{book.title}{book.file_size_mb}MB")

# ✅ 用多态调用 —— 新增类型无需修改
def show_book_info(book: Catalogable) -> None:
    print(book.get_info())
python
# ❌ ABC 接口过大 — 子类被迫实现不需要的方法
class LibraryItem(ABC):
    @abstractmethod
    def checkout(self): ...
    @abstractmethod
    def return_item(self): ...
    @abstractmethod
    def download(self): ...      # 纸质书不需要!
    @abstractmethod
    def scan_barcode(self): ...  # 电子书不需要!

# ✅ 拆分小接口
class Borrowable(Protocol):
    def checkout(self) -> bool: ...
    def return_item(self) -> None: ...

class Downloadable(Protocol):
    def download(self) -> bool: ...
python
# ❌ Protocol 忘加 @runtime_checkable
class MyProto(Protocol):
    def do_thing(self): ...

isinstance(obj, MyProto)  # TypeError: Instance and class checks can only be used
                          #           with @runtime_checkable protocols

# ✅ 需要运行时检查必须加装饰器
@runtime_checkable
class MyProto(Protocol):
    def do_thing(self): ...

适用场景

场景是否推荐原因
框架定义插件接口ABC强制契约,实例化时报错
接受第三方对象Protocol不要求修改继承关系
简单的"有方法就能用"鸭子类型(不写 Protocol)过度设计
需要静态类型检查Protocolmypy 支持结构子类型
需要运行时检查ABC 或 @runtime_checkable Protocolisinstance 检查

常见陷阱

陷阱问题解决方案
忘记 @abstractmethodABC 可被实例化,失去保护作用每个必须实现的方法都加装饰器
Protocol 忘加 @runtime_checkableisinstance(obj, Protocol) 抛 TypeError需要运行时检查时加上装饰器
isinstance 替代多态写成 if isinstance(x, A): ... elif isinstance(x, B): ...直接用多态调用 x.do_thing()
ABC 接口过大子类被迫实现不需要的方法拆分为小 ABC 或用 Protocol
Protocol 方法签名不一致结构匹配只看方法名和参数数量确保方法签名与协议一致
混用 ABC 和 Protocol 做同一件事增加理解成本按 is-a vs has-behavior 二选一

L3 专家层:深入

Python 如何实现:鸭子类型与虚子类机制

CPython 中的鸭子类型本质

Python 的鸭子类型不依赖接口声明,而是依赖运行时的属性查找。当你写 book.checkout() 时:

book.checkout()


┌───────────────────────────────────────────────┐
│  CPython LOAD_METHOD + CALL_METHOD 字节码      │
│                                                │
│  1. 在 type(book).__mro__ 中查找 'checkout'    │
│  2. 如果找到且是描述符 → 调用 __get__           │
│  3. 如果找到且是普通函数 → 绑定 self            │
│  4. 调用返回的 callable                         │
│                                                 │
│  关键是:从不检查 book 的类型是否匹配任何接口    │
│  只要对象有该方法,就能调用 —— 这就是鸭子类型   │
└───────────────────────────────────────────────┘

ABC 虚子类注册机制

CPython 的 ABC 模块允许通过 register() 方法将任意类注册为虚子类:

python
from abc import ABC, abstractmethod

class MyABC(ABC):
    @abstractmethod
    def my_method(self): ...

# 一个已有类,完全不继承 MyABC
class ExistingClass:
    def my_method(self):
        return "I work!"

# 注册为虚子类
MyABC.register(ExistingClass)

# 现在 isinstance 返回 True!
obj = ExistingClass()
print(isinstance(obj, MyABC))  # True
print(issubclass(ExistingClass, MyABC))  # True

虚子类注册的内部机制:

MyABC.register(ExistingClass)


┌──────────────────────────────────────────────────┐
│  ABCMeta.__subclasscheck__ 被调用                 │
│                                                   │
│  检查逻辑(简化):                                │
│  1. 检查 ExistingClass 是否在 MyABC 的 _abc_registry 中
│  2. 如果是 → 返回 True(直接注册的虚子类)          │
│  3. 检查 ExistingClass 是否继承了 MyABC            │
│  4. 如果是 → 返回 True(名义子类)                  │
│  5. 否则 → 返回 False                              │
│                                                   │
│  关键:ABCMeta 缓存 __subclasscheck__ 的结果        │
│  每次 isinstance() 调用时优先查缓存                 │
└──────────────────────────────────────────────────┘
python
# 验证:查看 ABC 的内部注册表
from abc import ABC

class MyABC(ABC):
    pass

class MyClass:
    pass

MyABC.register(MyClass)

# 查看内部缓存
print(MyABC._abc_impl)
# <_abc_data object at 0x...>  (C 级别的缓存,包含注册的虚子类)

# 查看注册表
print(MyABC._abc_registry)
# <_weakrefset.WeakSet object at 0x...>

# 注意:_abc_registry 使用弱引用,防止内存泄漏

@runtime_checkable Protocol 的 instancecheck

python
# @runtime_checkable 的等效实现原理
class Borrowable(Protocol):
    def checkout(self) -> bool: ...
    def return_item(self) -> None: ...
    def is_available(self) -> bool: ...

# @runtime_checkable 做了两件事:
# 1. 将 Borrowable.__instancecheck__ 指向一个自定义实现
# 2. 该实现检查对象是否拥有协议要求的所有方法

# 等效于:
def __instancecheck__(cls, instance):
    # 检查 instance 是否有协议定义的所有方法名
    required_methods = {'checkout', 'return_item', 'is_available'}
    for name in required_methods:
        if not hasattr(instance, name):
            return False
        # 可选:检查方法签名
    return True
python
# 验证:Protocol 的结构匹配机制
from typing import Protocol, runtime_checkable

@runtime_checkable
class Drawable(Protocol):
    def draw(self) -> None: ...

class Circle:
    def draw(self) -> None:
        print("drawing circle")

class Square:
    def draw(self) -> None:
        print("drawing square")

class NoDraw:
    pass

print(isinstance(Circle(), Drawable))  # True  — 有 draw() 方法
print(isinstance(Square(), Drawable))  # True  — 有 draw() 方法
print(isinstance(NoDraw(), Drawable))  # False — 没有 draw() 方法

性能考量

操作时间复杂度说明
ABC 实例化检查O(n × m)n=待检查方法数,m=抽象方法数,有缓存
isinstance(obj, ABC)O(1)ABC 有 __subclasscheck__ 缓存
isinstance(obj, Protocol)O(k)k=协议方法数,逐个检查 hasattr
虚子类注册 ABC.register()O(1)加入弱引用集合
多态方法调用O(1)正常属性查找,无额外开销
python
# 性能对比:isinstance 检查
import timeit

from abc import ABC
from typing import Protocol, runtime_checkable

class MyABC(ABC):
    pass

@runtime_checkable
class MyProto(Protocol):
    def method(self): ...

class C(MyABC):
    def method(self): pass

# ABC isinstance 检查(有缓存)
print(timeit.timeit("isinstance(c, MyABC)", globals={'c': C(), 'MyABC': MyABC}, number=1000000))
# ~0.05 秒

# Protocol isinstance 检查(需遍历方法)
print(timeit.timeit("isinstance(c, MyProto)", globals={'c': C(), 'MyProto': MyProto}, number=1000000))
# ~0.15 秒(比 ABC 慢约 3 倍,但不影响实际使用)

知识关联

多态知识关联:
              ┌───────────────┐
              │  第 3 章:继承 │
              │  重写/扩展    │
              └───────┬───────┘

         ┌────────────┼────────────┐
         ↓            ↓            ↓
   ┌───────────┐ ┌───────────┐ ┌───────────┐
   │ 继承多态  │ │ ABC 多态  │ │Protocol多态│
   │ 子类重写  │ │ 强制契约  │ │ 结构匹配  │
   └───────────┘ └───────────┘ └───────────┘
                      │              │
                      ↓              ↓
              ┌───────────────┐ ┌─────────────┐
              │  设计原则(第6章)│ │ 魔术方法(第8章)│
              │  LSP / DIP    │ │ __eq__/__lt__│
              └───────────────┘ └─────────────┘


              ┌───────────────┐
              │  ABCMeta      │
              │  __subclass   │ ← 虚子类注册机制
              │  check__      │    第 10 章详解
              └───────────────┘

自检清单

回答以下问题,检验理解程度:

  1. 为什么 Incomplete(Catalogable) 定义时不报错,实例化时才报错? → Python 的 ABC 在 __new__ 阶段检查抽象方法,定义类时不检查,实例化时才检查。

  2. BookItem 没有继承 Borrowable,为什么 isinstance(book, Borrowable) 返回 True? → Borrowable 是 @runtime_checkable 的 Protocol,isinstance 只看结构(是否有 checkout/return_item/is_available),不看继承。

  3. 如果给 EBook 删除 get_info() 方法,会发生什么? → 实例化 EBook 时抛 TypeError,因为它继承了 Catalogable(通过 BookItem),必须实现所有抽象方法。

  4. 什么时候该用 ABC,什么时候该用 Protocol? → 需要"强制子类实现"时用 ABC(契约式);需要"只要有这行为就行"时用 Protocol(结构式)。

  5. books: list[Catalogable] = [PhysicalBook, EBook, AudioBook] 的类型提示有什么作用? → 告诉阅读者和类型检查器:这个列表里的元素都保证有 get_info()get_isbn(),可以安全调用。


本章能力清单

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

  • [ ] 解释多态的核心概念:同一接口,不同实现
  • [ ] 使用 ABC 定义抽象基类,强制子类实现特定方法
  • [ ] 使用 Protocol 定义行为协议,实现结构子类型
  • [ ] 正确使用 @runtime_checkable 使 Protocol 支持 isinstance 检查
  • [ ] 根据 is-a vs has-behavior 选择 ABC 或 Protocol
  • [ ] 在子类中重写父类/基类方法,使用 super() 调用链
  • [ ] 编写多态调用代码,避免 isinstance 分支判断
  • [ ] 识别并避免多态常见陷阱(接口过大、签名不一致等)