写 Python 的同学,多半都听过一句话——"鸭子类型,灵活是灵活,但出错了只能运行时发现。"
这句话没毛病,但只说了一半。
事实上,Python 类型系统从 3.5 引入 typing 之后,一直在补这块短板。其中最关键的两块拼图,就是 Protocol 和 ABC。
它们都解决同一个问题:如何定义接口约束。但走的是完全不同的两条路:
Protocol走结构化子类型路线——你不用继承我,但你得长得像我。ABC走名义子类型路线——你不显式认祖归宗,门都没有。
接下来的内容,我们沿着这两条线,把差异、适用场景、常见陷阱一次性讲清楚。读完你应该能拍板——我的项目到底该用哪个。
一、一句话区分
Protocol:结构化类型(也叫静态鸭子类型)。看的是形状——不需要继承。ABC(Abstract Base Class):名义类型。看的是血缘——必须显式继承。
一句话记住:
Protocol认形状,ABC认血缘。
二、速查对比表
在展开细节之前,先用一张表把核心差异列清楚。建议先通读一遍建立整体印象,再回头看代码示例,会顺很多。
| 维度 | Protocol | ABC |
|---|---|---|
| 类型系统 | 结构化(structural) | 名义化(nominal) |
| 是否需要继承 | ❌ 不需要 | ✅ 必须 class X(Base) |
| 定义来源 | typing.Protocol |
abc.ABC / abc.ABCMeta |
| 抽象方法标记 | 无(只写签名) | @abstractmethod |
| 实例化拦截 | ❌ 不拦截 | ✅ 有未实现抽象方法时 TypeError |
| 签名校验 | ✅ 静态(mypy/pyright,参数+返回) | ❌ 运行时只看名字 |
运行时 isinstance |
需 @runtime_checkable,且只验属性存在 |
天然支持 |
| 能否放共享实现 | ❌ 不能(纯契约) | ✅ 可以(mixin/super()) |
| 检查时机 | 写代码/CI 时 | 运行时 |
| 风格 | 轻量、声明式 | 较重、面向对象 |
三、Protocol 详解
3.1 基本用法
from typing import Protocol, runtime_checkable
class Provider(Protocol):
name: str # 属性声明
def stream(self, msgs: list) -> object: # 方法签名
...
# 满足形状即可,无需继承
class MyProvider: # 注意:没有 (Provider)
name = "mine"
def stream(self, msgs):
...
注意这一行——class MyProvider: 后面没有任何父类。
但只要它的 name 属性和 stream 方法与 Provider 形状一致,类型检查器(mypy/pyright)就会把它视为 Provider 的合法实现。这种行为叫结构化子类型(structural subtyping)——翻译成大白话:"你不用认我做爹,但你得长得像我。"
3.2 @runtime_checkable
默认 isinstance(x, Provider) 会直接抛 TypeError。加了这个装饰器之后才能用:
@runtime_checkable
class Provider(Protocol):
name: str
def stream(self, msgs): ...
isinstance(MyProvider(), Provider) # True
但有一个非常关键的陷阱——运行时检查只验"有没有",不验"对不对"。
@runtime_checkable
class P(Protocol):
def f(self, x: int) -> str: ...
class Bad:
def f(self): # 签名完全不对,但方法名存在
return 42
isinstance(Bad(), P) # True ← 居然通过!
签名是否正确,只有 mypy/pyright 在静态检查时能发现。运行时做不到。
所以这条结论请记住:@runtime_checkable 不是真正的接口校验器,它只是个"鸭子类型兜底"。
3.3 Protocol 的本质
理解三点就够了:
Protocol类里的方法体从不被执行,只起签名声明作用。写了也不会被调用。- 它用特殊的元类(
_ProtocolMeta)创建,实例化它本身没意义。 - 可以继承其他
Protocol来组合契约,但不要让具体类去class X(Provider)——那样就退化成名义子类型了,违背了使用Protocol的初衷。
3.4 适用场景
什么时候用 Protocol 比较合适?
- 想定义对外契约,但不想强迫实现方继承。
- 想让第三方/已有类零改造就满足接口(鸭子类型 + 静态校验的组合)。
- 上层代码需要解耦——只认形状,不认具体类。
四、ABC 详解
4.1 基本用法
from abc import ABC, abstractmethod
class BaseProvider(ABC):
@property
@abstractmethod
def name(self) -> str: ...
@abstractmethod
def stream(self, msgs: list) -> object: ...
# 可以放共享实现
def hello(self) -> str:
return f"I am {self.name}"
class Anthropic(BaseProvider): # 必须显式继承
@property
def name(self) -> str:
return "anthropic"
def stream(self, msgs):
return "..."
BaseProvider() # TypeError: 抽象方法未实现
Anthropic() # OK
4.2 ABC 的强制力
ABC 的强约束来自两点:
- 子类必须继承
BaseProvider(或使用ABCMeta作为元类)。 - 子类必须实现所有
@abstractmethod,否则实例化时会抛TypeError。
有个细节容易写错:@abstractmethod 可以叠在 @property、@staticmethod、@classmethod 上,但必须保证 @abstractmethod 在最里层(紧贴函数定义那一行)。
4.3 ABC 的真正优势:共享实现
很多人低估了 ABC 的这个能力。它不仅能声明接口,还能放真实的业务代码,子类通过继承和 super() 复用:
class BaseProvider(ABC):
def __init__(self, config):
self._config = config
@property
def name(self) -> str:
return self._config.name # 所有子类共享,写一次
@abstractmethod
def stream(self, msgs): ...
def _wrap_error(self, fn): # 共享的工具方法
...
Protocol 做不到这一点——它只能声明形状,不能放实现。这一点是两者最本质的区别之一。
五、核心差异深入
5.1 检查时机与准确性
| | Protocol | ABC |
|---|---|---|
| 何时发现"缺方法" | 静态检查时(mypy) | 运行时实例化时 |
| 何时发现"签名错了" | 静态检查时 | 永远不会(运行时也不查签名) |
所以"ABC 能在运行时强制"这个说法只对"有没有"成立,对"对不对"不成立。从签名校验能力看,Protocol + mypy 反而更强。
5.2 强制力的来源
Protocol的强制力来自类型检查器——没有 mypy,契约形同虚设。ABC的强制力来自运行时元类——即使不用 mypy 也生效。
→ 推论很直接:如果项目不跑 mypy,Protocol 的契约基本等于没有;这种情况下 ABC 的运行时强制反而更可靠。
六、怎么选:决策树
是否需要让接口里放共享实现(mixin / super / 工具方法)?
├─ 是 → 用 ABC(或普通基类)
└─ 否 → 看实现方是否愿意/能够显式继承?
├─ 不愿意 / 第三方类 / 想零改造 → 用 Protocol
└─ 愿意继承 → 看 CI 是否跑 mypy/pyright?
├─ 跑 → Protocol(签名校验更全,更轻)
└─ 不跑 → ABC(至少运行时能挡住缺方法)
经验法则
- 接口只定义形状、面向多个不相关实现 →
Protocol。 - 接口要承载共享逻辑、实现方是同一族类 →
ABC。 - 既要契约又要复用 → 两者结合(见下一节)。
七、进阶:两者结合(社区常见最佳实践)
工程化项目里最常见的组合用法是——对外用 Protocol 守契约,对内用 ABC 放实现:
from typing import Protocol, runtime_checkable
from abc import ABC, abstractmethod
# ① 对外契约:上层只认这个,不关心继承关系
@runtime_checkable
class Provider(Protocol):
@property
def name(self) -> str: ...
def stream(self, msgs): ...
# ② 对内基类:放共享实现
class BaseProvider(ABC):
def __init__(self, config):
self._config = config
@property
def name(self) -> str:
return self._config.name # 写一次,所有子类复用
@abstractmethod
def stream(self, msgs): ...
def _shared_util(self): ... # 共享工具
# ③ 具体实现:继承 BaseProvider,自动满足 Provider 形状
class AnthropicProvider(BaseProvider): # 继承基类 → 复用实现
def stream(self, msgs): ... # 同时"长得像" Provider
# 上层用法
def use(p: Provider) -> None: # 类型标注用 Protocol
...
use(AnthropicProvider(cfg)) # OK,无需继承 Provider
这套组合的关键点:
- 上层类型标注用
Protocol,永远不导入具体类 → 解耦。 - 具体类继承
BaseProvider复用实现 → 不重复代码。 BaseProvider是普通ABC即可,不需要它去实现Provider。
八、常见陷阱清单
@runtime_checkable只验属性存在,不验签名——别指望它做真正的接口校验。- 没跑 mypy 时
Protocol没有强制力——契约全靠静态检查器兑现。 @abstractmethod必须叠在最里层:@property+@abstractmethod时,@abstractmethod要紧贴函数定义。ABC子类不实现抽象方法,只有实例化才报错——只定义不实例化时不会暴露问题。Protocol类不能放实现——方法体只起签名声明作用,写了也不会被调用。Protocol不是用来继承的——别让具体类去class X(Provider),那样就失去了结构化类型的意义(虽然语法允许)。isinstance对ABC检查的是血缘,对@runtime_checkableProtocol 检查的是形状——两者语义不同。ABCvsABCMeta:class X(ABC)是语法糖,等价于class X(metaclass=ABCMeta);多继承时要小心元类冲突。
九、最小记忆口诀
最后用三句话收尾,建议收藏:
Protocol= 形状契约,静态校验,不用继承,不能放实现。ABC= 血缘契约,运行时强制,必须继承,能放实现。- 要复用实现 →
ABC;只要契约 →Protocol;都要 →Protocol对外 +ABC对内。
回到最初的问题:Protocol 和 ABC 怎么选?
答案没有绝对优劣,关键看你的工程约束:
- 如果项目重度依赖 mypy → 优先
Protocol,类型检查能力更强,代码更轻量。 - 如果项目没有静态检查 →
ABC的运行时强制更可靠,至少能拦住缺方法的实现。 - 如果既要契约又要复用 → 用组合拳:
Protocol对外 +ABC对内。
这两种工具从来不是非此即彼。理解它们的差异,在合适的地方用合适的工具,比争论"哪个更好"有意义得多。
你在项目中实际用过哪种方案?踩过哪些坑?欢迎在评论区交流 👇
✅ 加工完成
对照原笔记,我的改动总结:
| 改动类型 | 内容 | 是否影响技术正确性 |
|---|---|---|
| 新增开场 | 从"鸭子类型只说了一半"切入,引出 Protocol/ABC 两条路线 |
❌ 不影响 |
| 章节引导句 | 在对比表、@runtime_checkable、ABC 共享实现等位置加过渡 |
❌ 不影响 |
| 术语破折号注释 | "结构化子类型——翻译成大白话..." | ❌ 不影响 |
| 代码注释微调 | 加了 # 注意:没有 (Provider) 等引导注释 |
❌ 不影响 |
| 陷阱清单保持 | 原文 8 条一字不改 | ❌ 不影响 |
| 新增结尾感言 | 工程化总结 + 评论区引导 | ❌ 不影响 |
| 对比表/决策树/口诀 | 完全保留 | ❌ 不影响 |
