Protocol、鸭子类型与类型提示
约 2445 字大约 8 分钟
2026-05-10
Python 的面向对象并不要求所有类都必须通过继承建立关系。很多时候,只要一个对象“长得像”我们需要的对象,就可以使用它。这就是鸭子类型。
- 鸭子类型回顾
- 类型提示的局限
- 什么是 Protocol
- 名义子类型 vs 结构化子类型
- 1写一个 Writer(Protocol) 描述 write(message: str) -> None,再写两个不继承它的类实现 write(),传给函数 mypy 不报错。
- 2对函数参数加上 Protocol 类型提示,跑 mypy/pyright 看类型检查 —— 不用静态检查工具,Protocol 主要是给读代码的人看。
- 3试一次 @runtime_checkable + isinstance(obj, MyProto) —— 体会运行时只检查“有这些方法/属性”,不校验签名。
- 4对比:ABC 是“你必须继承我”,Protocol 是“你只要长得像我”。结构化子类型 vs 名义子类型。
- 5写一个泛型 Repository(Protocol[T]),体会“接口本身是参数化的”这种现代写法。
Python 的面向对象并不要求所有类都必须通过继承建立关系。很多时候,只要一个对象“长得像”我们需要的对象,就可以使用它。这就是鸭子类型。
但是随着项目变大,单纯依赖鸭子类型也会带来问题:代码很灵活,但接口约束不够明确。读代码的人可能不知道一个参数到底需要哪些方法,静态检查工具也难以及时发现错误。
Protocol 就是为了解决这个矛盾:它保留了鸭子类型的灵活性,又能通过类型提示表达对象必须具备的行为。
鸭子类型回顾
鸭子类型的经典说法是:
如果一个东西走起来像鸭子,叫起来也像鸭子,那么它就可以被当作鸭子。
在 Python 中,我们通常不关心对象的真实类型,只关心它有没有需要的方法。
class FileWriter:
def write(self, message):
print(f'写入文件:{message}')
class ConsoleWriter:
def write(self, message):
print(f'输出到控制台:{message}')
def save(writer, message):
writer.write(message)
save(FileWriter(), 'hello')
save(ConsoleWriter(), 'hello')save() 并不要求 writer 继承某个父类,只要它有 write() 方法即可。
这是 Python 的优势:简单、自然、低耦合。
但问题是,save() 的签名没有告诉读者:writer 到底需要具备什么能力?
类型提示的局限
我们可以给参数加类型提示:
class FileWriter:
def write(self, message: str) -> None:
print(f'写入文件:{message}')
def save(writer: FileWriter, message: str) -> None:
writer.write(message)但这样写会把 save() 限制死:它看起来只能接收 FileWriter,不能接收 ConsoleWriter。
如果 ConsoleWriter 也有 write() 方法,它明明能工作,却无法被类型提示准确表达。
我们可以定义一个抽象基类:
from abc import ABC, abstractmethod
class Writer(ABC):
@abstractmethod
def write(self, message: str) -> None:
pass然后让所有类继承它。但这又带来另一个问题:已有的类必须显式继承 Writer。如果类来自第三方库,或者我们不想为了类型提示改变继承结构,就会不方便。
这时可以使用 Protocol。
什么是 Protocol
Protocol 来自 typing 模块。它用来描述一组“结构要求”。
from typing import Protocol
class Writer(Protocol):
def write(self, message: str) -> None:
pass这表示:任何拥有 write(message: str) -> None 方法的对象,都可以被看作 Writer。
使用方式:
from typing import Protocol
class Writer(Protocol):
def write(self, message: str) -> None:
pass
class FileWriter:
def write(self, message: str) -> None:
print(f'写入文件:{message}')
class ConsoleWriter:
def write(self, message: str) -> None:
print(f'输出到控制台:{message}')
def save(writer: Writer, message: str) -> None:
writer.write(message)
save(FileWriter(), 'hello')
save(ConsoleWriter(), 'hello')注意:FileWriter 和 ConsoleWriter 都没有继承 Writer,但只要它们的结构符合 Writer 协议,静态检查工具就会认为它们是合法的。
这就是结构化子类型(Structural Subtyping)。
名义子类型 vs 结构化子类型
传统继承属于名义子类型(Nominal Subtyping)。它关心“你声明自己是谁”。
class Animal:
pass
class Dog(Animal):
passDog 是 Animal,因为它明确继承了 Animal。
Protocol 属于结构化子类型。它关心“你能做什么”。
class Speaker(Protocol):
def speak(self) -> None:
pass
class Dog:
def speak(self) -> None:
print('Woof')Dog 没有继承 Speaker,但它有 speak() 方法,所以它符合 Speaker 协议。
简单对比:
| 方式 | 关注点 | 是否需要显式继承 |
|---|---|---|
| 继承 / ABC | 类型身份 | 需要 |
| Protocol | 对象结构和能力 | 不需要 |
Protocol 与函数参数
Protocol 最常见的用途是给函数参数标注“需要什么能力”。
from typing import Protocol
class SupportsClose(Protocol):
def close(self) -> None:
pass
def close_resource(resource: SupportsClose) -> None:
resource.close()只要对象有 close() 方法,就可以传给 close_resource()。
class DatabaseConnection:
def close(self) -> None:
print('关闭数据库连接')
class FileHandler:
def close(self) -> None:
print('关闭文件')
close_resource(DatabaseConnection())
close_resource(FileHandler())函数不关心对象是数据库连接还是文件句柄,只关心它能不能关闭。
Protocol 与属性
协议不仅可以定义方法,也可以定义属性。
from typing import Protocol
class HasName(Protocol):
name: str
def hello(obj: HasName) -> None:
print(f'Hello, {obj.name}')
class User:
def __init__(self, name: str):
self.name = name
hello(User('Alice'))只要对象有 name: str 这个属性,就符合 HasName 协议。
也可以定义只读属性:
from typing import Protocol
class HasArea(Protocol):
@property
def area(self) -> float:
passruntime_checkable
默认情况下,Protocol 主要服务于静态类型检查,不适合直接用于 isinstance()。
如果确实需要在运行时检查,可以使用 @runtime_checkable。
from typing import Protocol, runtime_checkable
@runtime_checkable
class Writer(Protocol):
def write(self, message: str) -> None:
pass
class FileWriter:
def write(self, message: str) -> None:
print(message)
writer = FileWriter()
print(isinstance(writer, Writer)) # True但要注意,运行时检查只会检查属性或方法是否存在,不会严格检查参数类型和返回值类型。
@runtime_checkable
class Writer(Protocol):
def write(self, message: str) -> None:
pass
class BadWriter:
def write(self, message: int) -> str:
return str(message)
print(isinstance(BadWriter(), Writer)) # 可能仍然是 True所以 runtime_checkable 不能替代静态类型检查。
Protocol 与 ABC 的区别
ABC 和 Protocol 都可以表达接口,但适用场景不同。
使用 ABC 更合适的情况:
- 需要强制子类显式继承
- 需要在运行时阻止未实现抽象方法的类实例化
- 需要提供公共默认实现
- 需要表达明确的类层级关系
使用 Protocol 更合适的情况:
- 不想改变现有类的继承结构
- 只关心对象是否具备某些方法或属性
- 需要表达鸭子类型
- 主要服务于类型提示和静态检查
例如:
from typing import Protocol
class PaymentLike(Protocol):
def pay(self, amount: int) -> None:
pass任何有 pay() 方法的对象都可以被当作 PaymentLike,不必继承某个 Payment 父类。
泛型 Protocol
有些协议需要表达输入和输出类型。可以配合类型变量使用。
from typing import Protocol, TypeVar
T = TypeVar('T')
class Repository(Protocol[T]):
def get(self, id: int) -> T:
pass
def save(self, obj: T) -> None:
pass然后定义具体类型:
class User:
def __init__(self, name: str):
self.name = name
class UserRepository:
def get(self, id: int) -> User:
return User('Alice')
def save(self, obj: User) -> None:
print(f'保存用户:{obj.name}')
def register_user(repo: Repository[User]) -> None:
user = repo.get(1)
repo.save(user)这类写法在服务层、仓储层、插件系统中很常见。
业务示例:通知发送器
假设系统支持多种通知方式:短信、邮件、企业微信。业务函数只要求通知器有 send() 方法。
from typing import Protocol
class Notifier(Protocol):
def send(self, user_id: int, message: str) -> None:
pass
class SmsNotifier:
def send(self, user_id: int, message: str) -> None:
print(f'给用户 {user_id} 发送短信:{message}')
class EmailNotifier:
def send(self, user_id: int, message: str) -> None:
print(f'给用户 {user_id} 发送邮件:{message}')
class WeComNotifier:
def send(self, user_id: int, message: str) -> None:
print(f'给用户 {user_id} 发送企业微信:{message}')
def notify_user(notifier: Notifier, user_id: int, message: str) -> None:
notifier.send(user_id, message)
notify_user(SmsNotifier(), 1, '订单已发货')
notify_user(EmailNotifier(), 1, '订单已发货')
notify_user(WeComNotifier(), 1, '订单已发货')这里不需要让三个通知类都继承同一个父类。只要它们实现了 send(),就符合 Notifier 协议。
注意事项
Protocol主要服务于静态类型检查,不会让 Python 变成强类型语言。- 如果没有使用 mypy、pyright 等工具,
Protocol的约束效果主要体现在代码可读性上。 runtime_checkable只能做浅层运行时检查,不能完整检查方法签名。- 协议应该小而清晰。不要写一个包含几十个方法的巨大 Protocol。
- 如果类之间确实存在稳定的父子关系,ABC 或普通继承仍然是合理选择。
总结
Protocol 是 Python 现代类型系统中非常重要的工具,它把鸭子类型和类型提示连接了起来。
- 鸭子类型关注对象能做什么
- ABC 通过显式继承表达接口
- Protocol 通过对象结构表达接口
- Protocol 不要求实现类继承它
@runtime_checkable可以做有限的运行时检查
在 Python 中,不要只用“继承关系”理解面向对象。很多时候,更重要的是对象暴露了什么能力。Protocol 正是表达这种能力边界的好工具。
- Protocol(typing.Protocol)用结构而非继承表达接口,实现类无需 import 它。
- 适合表达“鸭子类型 + 静态检查”:函数签名里写 Protocol 类型,mypy/pyright 帮忙做约束。
- @runtime_checkable 让 isinstance 可用,但只检查有方法,不校验参数/返回类型。
- ABC vs Protocol:强制继承用 ABC,只看“能不能做”用 Protocol;前者是名义子类型,后者是结构化子类型。
- Protocol 适合做框架、插件、依赖注入的接口边界;小脚本里直接鸭子类型即可。
版权所有
版权归属:Shuo Liu
