上周代码评审,同事指着我写的一行 def log(handler, record): handler.write(record) 问了一句话:“这个 handler 到底要有什么接口?”我随口回了句“能 write 就行,鸭子类型”。他接着追问:“那如果有人传进来一个只有 flush、没有 write 的对象,程序是不是要跑到第 N 条日志才会炸?”会议室安静了。
这个问题看着简单,其实把 Python 多态设计里最值得掰扯的一块疆域给掀开了:同一个“对象能不能用”的问题,Python 里有三条路可以回答——鸭子类型(Duck Typing)、抽象基类(ABC)、以及 typing.Protocol。三者都在实现多态,但约束力度、检查时机、侵入程度完全不同。搞不清楚边界,就会出现两种典型局面:要么项目被一堆继承 ABC 的类绑得死死的,要么静态检查形同虚设,线上 AttributeError 一个接一个。
这篇文章我想把这三者的边界讲透,并且结合我实际重构过的代码,聊聊什么时候该用谁、什么时候坚决别用谁。
1. 一次评审会上的“哲学讨论”:三剑客各自在管什么
1.1 同一个问题,三种不同力度的回答
我们先把问题收敛成一个非常具体的场景:有一个函数,它接收一个日志输出目标,并调用目标上的 write() 方法。
用鸭子类型来回答,函数长这样:
python复制def log(handler, record):
handler.write(f"{record}\n")
这段代码对 handler 没有任何前置约束。你传一个文件对象、一个 StringIO、一个自己写的 FakeHandler,只要它有 write 方法,就能跑。如果传进来的对象没有 write,Python 不会在调用前给你任何警告,而是等到运行到这一行时才抛 AttributeError。
用 ABC 来回答,函数和调用方都变了:
python复制from abc import ABC, abstractmethod
class LogHandler(ABC):
@abstractmethod
def write(self, line: str) -> None: ...
class FileHandler(LogHandler):
def write(self, line: str) -> None:
# 真正的写文件逻辑
...
def log(handler: LogHandler, record):
handler.write(f"{record}\n")
注意,这里多了一个硬约束:任何想当作 LogHandler 使用的类,必须继承它,并且实现 write 方法。如果你漏实现了,实例化对象的那一刻 Python 就会报错,而不是等到业务代码里才崩。
用 Protocol 来回答,函数签名看得更舒服:
python复制from typing import Protocol
class LogHandler(Protocol):
def write(self, line: str) -> None: ...
class FileHandler:
def write(self, line: str) -> None:
# 不需要继承任何东西
...
def log(handler: LogHandler, record):
handler.write(f"{record}\n")
这段代码如果配合 mypy 或 pyright 使用,类型检查器会校验传入的对象是否“结构上”具备 write 方法。但在运行层面,Protocol 默认什么也不做——它不像 ABC 那样在实例化时拦截,也不会在运行时强制要求你继承。
1.2 为什么总有人把 ABC 和 Protocol 混为一谈
我见过不少团队,讨论这两个东西时能吵半个小时。原因在于:它们的代码长得太像了。都是定义一个类,里面写一堆方法签名,然后让别的类来“遵守”这个约定。加上 collections.abc 里的那些类名(Iterable、Sequence、Mapping)看着像 Protocol,但实现机制又是 ABC,难免混淆。
但从根本上说,两者的哲学完全不同:
- ABC 是运行时契约。它靠继承关系和
isinstance()检查来约束对象,目的是在你调错方法之前尽早把错误暴露出来。 - Protocol 是静态契约。它靠类型标注让 mypy/pyright 在写代码阶段就发现问题,运行时它几乎不产生任何开销,也不做任何强制。
这俩不是“谁替代谁”的关系,而是在不同阶段发挥作用的机制。要理解清楚,就得先回到 Python 多态最底层的那套逻辑:鸭子类型。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 鸭子类型:Python 多态默认的运行时底色
2.1 标准库里的鸭子类型无处不在
“如果它走起来像鸭子、叫起来像鸭子,那它就是鸭子。”这句老话在 Python 里不是修辞,是运行时真实行为。你写 for x in obj 时,Python 根本不管 obj 是什么类、继承自谁,只关心两件事:有没有 __iter__ 方法,或者有没有 __getitem__ 方法。这就是纯鸭子类型。
标准库到处都在依赖这套机制:
- 所有能被
len()调用的对象,靠的是__len__方法,而不是继承某个Lengthable基类。 - 能被
with使用的对象,靠的是__enter__和__exit__方法。 json.dump()接受的文件对象,只要它实现了.write(),管你是真文件还是网络流还是 BytesIO。
甚至自定义对象之间的协作也是这样。比如 mock 测试时,unittest.mock.Mock() 不继承任何被测类的父类,但你把它丢进被测代码里,它照样能通过——因为它动态地给你“变出”被访问的方法。这种能力只有鸭子类型体系下才这么自然。
2.2 鸭子类型的两个代价:晚报错与弱提示
鸭子类型亲测很爽,但代价很实在。
第一个代价是报错太晚。一个对象缺方法,通常不会在你传入函数的那一刻被发现,而是在程序跑了几万条数据之后、正好执行到那条调用时,才“啪”地一声抛出 AttributeError。对脚本型项目来说还能忍,对长驻服务或批处理任务来说,这就是线上事故。
第二个代价是IDE 和静态检查形同虚设。你写一个 def consume(obj): obj.do_something(),IDE 根本不知道 obj 是什么,补全、跳转定义、类型检查全部失效。团队协作时,别人看你的函数签名,还得去读函数体才知道该传什么。这就是我评审会上被同事问住的那个瞬间:鸭子类型让调用者拥有无限自由,也让调用者承担无限义务。
2.3 什么场景下“纯鸭子”依然是最优解
说了这么多代价,我仍然不会建议所有代码都上 Protocol。有一种场景,纯鸭子类型是最优解:内部脚本、原型验证、以及所有运行失败成本可控的代码。
比如我写一次性数据处理脚本时,函数里接受“任何有 .read() 的文件对象”,我不会去定义 Protocol,更不会去定义 ABC。因为脚本生命周期短、调用关系简单,报错成本低,多写一层接口定义反而拖慢节奏。
还有一类场景也建议保留鸭子类型:你想让函数具备极强的兼容性时。比如很多工具函数的 write_text(path, content),它内部只要 path.write_text(content),不管传的是 pathlib.Path 还是 django 的存储对象,能跑就行。这时候越是“不设限”,越容易复用。注意,是“能跑就行”的兜底,不是完全没有约束——约束可以靠文档、类型标注来补,而不是靠继承体系来卡。
3. ABC 抽象基类:把契约做成实例化前的一堵墙
3.1 抽象方法:实例化时的一记响亮报错
ABC 要解决的问题非常直白:在对象实例化之前,把“你漏实现方法了”这件事说出来。
它的核心机制是 ABCMeta 元类。当一个类继承了 ABC,并且类里存在被 @abstractmethod 装饰的方法时,这个类就成了抽象类。抽象类不能直接实例化,子类如果没有实现所有抽象方法,同样不能实例化。这个检查发生在 __new__ 阶段,也就是你 new 对象的那一刻。
举个例子:
python复制from abc import ABC, abstractmethod
class BaseService(ABC):
@abstractmethod
def start(self) -> None: ...
@abstractmethod
def stop(self) -> None: ...
class BadService(BaseService):
def start(self) -> None:
print("started")
# 忘了实现 stop()
s = BadService()
# TypeError: Can't instantiate abstract class BadService with abstract method stop
对比纯鸭子类型,这个报错时机简直是天壤之别。鸭子类型要等到运行到 stop() 时才炸,ABC 在你构建对象时就拦住了,而且错误信息里明确指出“哪个方法没实现”。在插件系统、框架基类、大型团队的核心抽象层里,这个特性是刚需。
3.2 collections.abc:被低估的“内置契约库”
很多 Python 开发者没有意识到,标准库的 collections.abc 模块就是一个现成的 ABC 宝库。里面定义了 Iterable、Iterator、Sequence、Mapping、MutableMapping、Callable、Hashable 等等抽象基类。
它们最大的价值不是让你去继承,而是让你做 isinstance 判断时更规范:
python复制from collections.abc import Iterable, Sequence, Mapping
isinstance([1, 2, 3], Iterable) # True
isinstance("abc", Sequence) # True
isinstance({"a": 1}, Mapping) # True
这种判断比 isinstance(x, list) 更有参考价值,因为它关注的是“行为能力”而不是“具体类型”。比如一个自定义类,哪怕它不是 list 也不是 tuple,只要实现了 __getitem__ 和 __len__,就可以被 isinstance(obj, Sequence) 识别为序列。
更妙的是,很多内置类型在 CPython 层面已经注册过这些 ABC 了。你不需要做任何事,就能享受“继承体系之外的类型也能被 ABC 识别”的红利。这也是理解后面 register 机制的一把钥匙。
3.3 register 与 subclasshook:给外部类型补办“户口”
ABC 有一个容易被忽视的能力:**它允许你把一个完全不相干的类“注册”为自己的虚拟子类。**注册之后,isinstance(obj, TheABC) 就会返回 True,但这个类实际上并没有继承你的 ABC。
比如你写了一个框架,定义了抽象基类 TaskHandler,正常的使用方式是让用户继承它:
python复制class TaskHandler(ABC):
@abstractmethod
def run(self, task): ...
class CustomHandler(TaskHandler):
def run(self, task):
...
但某个第三方库里的 ThirdPartyRunner 也有同名方法,又没办法改它的源码,这时候可以注册:
python复制TaskHandler.register(ThirdPartyRunner)
注册完成后,isinstance(third_party_obj, TaskHandler) 就返回 True。这相当于给外部类型“补办了一个户口”,让它在不改变自身任何代码的情况下被你的体系接受。
除了 register,你还可以重写 __subclasshook__,让 ABC 自动判断“长成什么样的类型算是我的子类”:
python复制class TaskHandler(ABC):
@abstractmethod
def run(self, task): ...
@classmethod
def __subclasshook__(cls, subclass):
if hasattr(subclass, "run"):
return True
return NotImplemented
这个钩子一旦开启,任何有 run 方法的类,即便从来不继承 TaskHandler,也会被 issubclass() 和 isinstance() 认定为“是”。这套机制本质上是在给鸭子类型开一个“官方认证接口”。
3.4 ABC 永远成不了“接口替代品”的三个理由
我不太建议把 ABC 当成 Java 里 interface 的替代品,原因有三个。
第一,继承是强侵入、强耦合的。一个类一旦继承了你的 ABC,它的父类关系就多了一条分支。如果 ABC 里后续加了新的抽象方法,所有下游实现类都得跟着改,这种传播效应在大型项目里相当痛苦。Java 的 interface 也是这个思路,但语言层面有完善的默认方法、多接口继承机制,Python 的 ABC 没有那么灵活。
第二,单继承限制。Python 类只能继承一个普通类,虽然 ABC 可以多继承,但一旦有多个基类且元类不兼容,立刻就会撞墙。协议这种东西,本质上是描述“能力集合”,一个对象往往同时具备多组能力,硬塞进继承树里会很别扭。
第三,它解决不了“外部类型”的问题。就算你用 register 给第三方类型补户口,你也只能在有限范围内做。真正理想的接口,应该像 Protocol 那样,对一个“没有继承任何东西”的普通对象也能生效。
4. Protocol:让鸭子类型在静态检查器里拥有姓名
4.1 结构性子类型:不看出身,只认能力
typing.Protocol 是 PEP 544 引入的机制,Python 3.8 正式支持,3.8 之前可以通过 typing_extensions 使用。它的核心思想叫结构性子类型(Structural Subtyping):一个类型是否满足接口要求,取决于它“有没有某个方法”,而不是它“是否继承了某个类”。
这相当于程序员给鸭子类型刻了一枚印章。你不需要让 FileHandler 继承 LogHandler,只需要让两个类都有 write 方法,类型检查器就认为 FileHandler 是 LogHandler 的合法实现。
python复制from typing import Protocol
class LogHandler(Protocol):
def write(self, line: str) -> None: ...
class FileHandler:
def write(self, line: str) -> None:
print("write to file")
class MemoryHandler:
def write(self, line: str) -> None:
self.buffer.append(line)
def log(handler: LogHandler, record: str) -> None:
handler.write(record)
在 mypy 或 pyright 的视角里,FileHandler 和 MemoryHandler 都满足 LogHandler 的结构,传参合法。但如果你传一个只实现了 flush 的对象,类型检查器会在你写代码的瞬间标红。注意,这里标红的是写代码时,不是运行时,这是 Protocol 与 ABC 最大的差别。
4.2 mypy 与 IDE 视角下的 Protocol
很多人以为 Protocol 是给我这种“爱写类型标注”的人自嗨用的,其实不是。当代 IDE 没有类型标注时,写代码和阅读代码都很痛苦——函数参数是个 object,你点 .write() 完全没法补全;而一旦用 Protocol 标注,PyCharm 和 VS Code 就能立刻知道参数具备哪些方法,补全和跳转都活了。
更重要的场景是库作者对外暴露接口。我写了一个第三方包,不想强制用户继承任何基类,又想告诉用户“你传进来的对象至少要有这些方法”,Protocol 就是最优雅的答案。用户完全不用感知你的包,它自己的类该怎么写还怎么写,只要方法名对得上就行。
mypy 里有一个配置叫 strict,打开后,未标注返回值的函数都会报错。在这种工程文化下,Protocol 几乎是唯一能在“不强加继承”和“类型安全”之间取得平衡的方案。
4.3 runtime_checkable:看起来能检查,别指望它看签名
Protocol 默认是不参与运行时 isinstance 检查的。你想 isinstance(obj, SomeProtocol),直接会抛 TypeError,提示你在 Protocol 类上加上 @runtime_checkable 装饰器。
加上之后就能检查了,但别高兴太早——这个检查非常粗糙。
python复制from typing import Protocol, runtime_checkable
@runtime_checkable
class LogHandler(Protocol):
def write(self, line: str) -> None: ...
class FakeHandler:
write = 42 # 这是一个整数,不是方法
isinstance(FakeHandler(), LogHandler) # True
看到了吗?runtime_checkable 只检查“有没有这个属性”,不检查“这个属性是不是方法”,更不检查“方法签名是否匹配”。它甚至不检查参数个数。也就是说,它只能提供“最低限度的运行时认识”,真正的签名校验还得靠静态检查工具。
所以我的结论是:别把 runtime_checkable 当成 ABC 的平替。在运行时需要强约束,就老老实实上 ABC;在静态检查阶段需要类型契约,就上 Protocol。
4.4 Protocol 与 ABC 的职责互补而非替代
理解了上面这些,你就该明白一个关键结论:Protocol 和 ABC 不是“你死我活”的关系,而是分别在两个层面发挥作用。
- Protocol 管的是“写代码的时候类型对不对”,由 mypy/pyright 执行。
- ABC 管的是“对象创建的时候方法全不全”,由 Python 运行时执行。
一个理想的库设计,完全可以两者同时存在:对外用 Protocol 描述接口,让用户零侵入接入;对内用 ABC 做运行时兜底,确保框架内部的插件实现不会漏方法。后面我讲实战重构时会展示这个模式。
5. 边界与抉择:对比表与五步决策流程
5.1 核心维度对照表
我先放一张整理过的对照表,后面所有分析都可以对照它看:
| 维度 | 鸭子类型 | ABC 抽象基类 | Protocol |
|---|---|---|---|
| 检查时机 | 调用时 | 实例化时 | 静态类型检查时 |
| 检查内容 | 不检查 | 方法是否存在 | 结构是否匹配(含方法签名) |
| 是否要求继承 | 否 | 是(或 register) | 否 |
| 是否能代替方法签名校验 | 否 | 否 | 是(交给类型检查器) |
| 运行时开销 | 无 | 很小,主要实例化时 | 无(默认运行时零参与) |
| 对 IDE 补全友好度 | 不友好 | 友好 | 友好 |
| 对第三方类型兼容 | 天然兼容 | 需要 register | 天然兼容 |
| 典型场景 | 脚本、内部函数、快速原型 | 框架基类、插件系统 | 库接口定义、团队协作代码 |
5.2 五步决策流程
我在实际项目中总结了一套判断流程,遇到“到底用哪一个”的问题就把流程过一遍:
- 如果这段代码只在一个仓库内部、内部工具、短期脚本中存活,并且你接收报错成本——直接用鸭子类型,不做任何多余抽象。
- 如果这段代码需要给别人用、需要长期维护,你想让调用者知道“传进来的对象具备什么能力”——上 Protocol 作为参数类型标注。
- 如果对象漏实现方法会导致严重后果,你希望在创建对象那一刻就拦住——上 ABC。
- 如果对象来自第三方库,你无法修改它的继承关系,但还是想让
isinstance(obj, YourABC)判定通过——用 ABC 的register或自定义__subclasshook__。 - 如果团队已经把 mypy 的 strict 模式跑起来了,并且你的接口大多是纯抽象描述——默认选 Protocol,ABC 只留给需要运行时强约束的少数场景。
这个流程不是银弹,但它能避免 90% 的“看我心情选一个”的拍脑袋决策。
5.3 一个文件里同时出现三种机制的场景
有人可能觉得“三种机制放一起是不是很乱?”其实在一个成熟的模块里,它们可以各司其职,彼此一点都不冲突。
举个我见过的消息中间件客户端设计:
python复制# 对外暴露的接口协议:任何有 publish 和 close 的对象都可以用
class MessageSender(Protocol):
def publish(self, topic: str, body: bytes) -> None: ...
def close(self) -> None: ...
# 框架内部要求插件必须实现 publish,否则不允许加载
class PluginBase(ABC):
@abstractmethod
def publish(self, topic: str, body: bytes) -> None: ...
@classmethod
def __subclasshook__(cls, subclass):
if hasattr(subclass, "publish"):
return True
return NotImplemented
# 第三方 SDK 自带 publisher,不继承 PluginBase
class ThirdPartyPublisher:
def publish(self, topic: str, body: bytes) -> None:
...
PluginBase.register(ThirdPartyPublisher)
对外,MessageSender 告诉用户“你的对象满足这个结构就能传入”;对内,PluginBase 保证所有插件都具备 publish 能力;对第三方 SDK,register 又让它们能够被正确识别。三者并存,互不干扰。
6. 实战复盘:一个日志模块的三次重构
说回文章开头那个让我在评审会上沉默的问题。当时我负责的监控系统里有一个日志分发模块,需求很简单:接收日志记录,输出到文件、控制台、以及一个外部告警服务。这个模块半年内经历了三次重构,正好把三剑客全部用了一遍。
6.1 v1:纯鸭子类型,能跑,但坑在深水区
第一版代码非常“朴实”:
python复制def emit_log(handler, record):
handler.write(f"{record.level}: {record.message}\n")
调用方传入文件对象、控制台对象、或者一个列表收集器,只要有 write 方法就能工作。当时团队规模小,这个函数用得很好,直到有一次某位同事写了一个新的告警 handler,只有 send 方法,忘了写 write。结果日志积压了三个小时,直到有人手动触发告警,代码才在 emit_log 处抛出一个让人摸不着头脑的 AttributeError: 'AlertHandler' object has no attribute 'write'。
排查过程不难,但很浪费。调用栈深、日志量大,定位到具体对象花了不少时间。这件事之后,我下定决心要给 handler 加一道“创建时的防线”。
6.2 v2:ABC 兜底,第三方 SDK 却进不来
第二版引入了 ABC:
python复制from abc import ABC, abstractmethod
class LogHandler(ABC):
@abstractmethod
def write(self, line: str) -> None: ...
@abstractmethod
def flush(self) -> None: ...
class FileHandler(LogHandler):
def write(self, line: str) -> None:
...
def flush(self) -> None:
...
从这之后,漏实现方法的问题从“线上运行时报错”变成了“开发阶段实例化报错”。同事新写一个 handler 时,IDE 会立刻提示他哪个方法没实现,实例化时也会被 Python 直接拦截。这个体验提升非常明显。
但新的问题很快出现了:有个外部告警 SDK 提供了一个 AlertClient,它自身有完美的 write 和 flush 方法,但它继承的是 SDK 内部的 BaseClient。让 AlertClient 再继承我们的 LogHandler,一来它已经有父类了,二来我们也不希望把外部 SDK 和内部框架的继承体系绑死。用 issubclass(AlertClient, LogHandler) 判断时,返回 False——即使它方法头完全匹配。
当时我用 LogHandler.register(AlertClient) 解决了这个问题,代码也能跑,但总感觉有点“事后补票”的意味。更让我介意的是,emit_log 函数的签名写的是 handler: LogHandler,可 LogHandler 的抽象方法列表和“运行起来实际要调用哪些方法”的契约,其实并非完全等价。
6.3 v3:Protocol 做对外契约,ABC 退守内部
第三版,我把对外接口定义从 ABC 换成了 Protocol:
python复制from typing import Protocol
class LogHandler(Protocol):
def write(self, line: str) -> None: ...
def flush(self) -> None: ...
def emit_log(handler: LogHandler, record) -> None:
handler.write(f"{record.level}: {record.message}\n")
handler.flush()
这一改带来两个明显变化。
第一,AlertClient 不再需要任何适配代码。它的方法和 LogHandler 结构一致,mypy 在调用 emit_log(alert_client, record) 时直接判定类型通过,不需要 register,不需要继承,不需要任何魔法。这就是 Protocol 对第三方类型天然兼容的最大价值。
第二,IDE 与静态检查真正生效了。以前用 ABC 标注函数参数,IDE 只知道“这是 LogHandler 类型”,但它无法校验“你有没有实现 write”;现在用 Protocol,mypy 会逐个方法去匹配签名,不匹配就直接标红。团队里后来再出现“少写一个方法”的情况,CI 阶段就拦住了,根本走不到运行时。
6.4 重构之后我对“三剑客”的最终判断
经过这次重构,我对自己代码里的选择标准变得更加清晰:
- 如果是框架内部的核心抽象,比如“所有插件必须实现 start/stop”,我会继续用 ABC。因为这里需要的是运行时强约束,漏实现必须在加载插件时就报出来。
- 如果是对外暴露的函数参数类型,比如“emit_log 要收一个能 write 的对象”,我会用 Protocol。因为没必要强迫调用者继承我的东西,接口越宽容越好。
- 如果是一两行代码的临时调用,我照样裸写鸭子类型,懒得加任何标注。
还有一个我踩过几次坑才记住的细节:@runtime_checkable 的 Protocol 并不适合做“运行时防漏方法”的检查。它只看属性是否存在,不检查方法签名,很容易让一个 write = 42 的对象蒙混过关。如果你真的要“创建时拦截漏实现”,ABC 才是那个对的选择。
至于文章开头同事问我的那个问题——“那要是传进来一个没有 write 方法的对象呢?”现在我的回答会非常具体:如果这是对外 API,我会把参数类型标注成 LogHandler Protocol,让 CI 在类型检查阶段拦住;如果这是框架内部插件,我会让插件基类继承 ABC,让 Python 在实例化阶段拦住;如果这只是一个内部脚本,那确实只能等它跑起来报错——但我会在函数注释里写明“需要一个有 write 方法的对象”,把自由和责任同时交给调用者。这三层防线,在同一个项目里经常同时存在,互不挤占。
