1. 三个概念为什么总是被混在一起
接口(interface)、抽象基类(ABC)、协议(Protocol)这三个词,是很多 Python 开发者最绕不清的概念。我之前接手一个老项目,代码里用一个 BaseParser 抽象类统一所有解析器,后来新同事直接写了一个不带继承的 Parser,运行时才暴露方法签名不一致,排查花了大半夜。那一刻我意识到,很多人不是不懂语法,而是不理解这三种"接口"方案到底分别在约束什么。
先说结论:Python 里没有 interface 关键字,所以"接口"这件事可以用三种思路来表达。第一种是鸭子类型(Duck Typing),靠的是对象有没有对应的方法;第二种是 abc.ABC 抽象基类,靠的是显式继承和强制实现;第三种是 typing.Protocol 协议,靠的是结构匹配,也就是"长得像就是"。它们解决的问题其实是同一个:怎么在代码里约定"这个对象应该提供哪些能力",避免调用方传进来一个完全不匹配的对象。
这篇文章适合三类人看:刚接触 Python 设计模式的人,想给团队代码加类型约束的人,以及正在设计库/工具 API 的人。你可以把它当成一份接口设计思路的实操手册,不是背语法,而是知道什么场景用哪把刀。
1.1 鸭子类型带来的自由和代价
Python 的灵活很大程度上来自"鸭子类型":只要一个对象能 walk() 能 quack(),它就是鸭子。你不需要继承某个父类,也不需要声明实现了某个接口。
python复制def make_sound(animal):
animal.quack()
只要能调 quack() 的对象传进去都能跑,哪怕是完全不相干的类。这对小型脚本、快速原型来说非常舒服,写起来几乎没有负担。
但代价也很明显:契约是隐形的。调用方只能靠文档、命名规范、代码注释去猜这个对象需要什么方法。一旦某个对象恰好有同名方法但语义不同,或者方法签名对不上,问题要到运行时才暴露。更麻烦的是,IDE 和类型检查器都帮不了你,因为它们不知道 animal 应该长什么样。
1.2 一句话区分三种接口表达
如果非要给一个简单粗暴的记忆方式,我会这么总结:
- 鸭子类型:我没有规定你必须长什么样,但你最好有我要的方法。
- 抽象基类(ABC):你必须认我这个祖宗,并且把我标记的方法都实现出来,否则不让你创建对象。
- 协议(Protocol):你不用继承我,也不用注册,只要结构上具备我声明的方法,类型检查器就认你是我的实现。
这三种不是替代关系,而是互补关系。理解了这个基础,再往下看实际代码就容易了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 抽象基类(ABC):当你想强制子类完成"家庭作业"
抽象基类在 Python 标准库中对应 abc 模块,核心思想是定义一个规定动作的模板,并要求子类把这些动作补齐。比如你定义一个存储接口,要求所有存储后端必须实现 save 和 load,那 ABC 就是最适合的强制工具。
2.1 从零定义一个抽象基类
先看一个最小例子:
python复制from abc import ABC, abstractmethod
from pathlib import Path
class Storage(ABC):
@abstractmethod
def save(self, name: str, data: bytes) -> None: ...
@abstractmethod
def load(self, name: str) -> bytes: ...
这个 Storage 不能直接实例化,因为方法体是空的抽象方法。子类继承了它,只要有一个抽象方法没实现,也无法实例化:
python复制class LocalStorage(Storage):
def save(self, name: str, data: bytes) -> None:
Path(name).write_bytes(data)
def load(self, name: str) -> bytes:
return Path(name).read_bytes()
如果你写一个 IncompleteStorage(Storage),只实现 save 不实现 load,然后试图执行 IncompleteStorage(),Python 会直接抛 TypeError。这相当于运行时给子类做了一次"作业检查",比纯鸭子类型严谨得多。
这里有个很容易忽略的点:必须让类继承 ABC,或者指定 metaclass=ABCMeta,@abstractmethod 才会真正生效。如果你仅仅是:
python复制class Storage:
@abstractmethod
def save(self): ...
那这个类完全可以直接实例化,抽象方法形同虚设。原因在于抽象方法的检查逻辑在 ABCMeta.__call__ 中,没有这个元类就不会触发。
2.2 抽象基类不只是普通方法
在实际业务中,接口经常需要定义类方法、静态方法和属性。abc 都支持,只是装饰器顺序要注意:
python复制from abc import ABC, abstractmethod
class Cache(ABC):
@classmethod
@abstractmethod
def create(cls, config: dict) -> "Cache":
...
@property
@abstractmethod
def ttl(self) -> int:
...
上面这段代码要求子类实现类方法 create 和一个属性 ttl。你甚至可以在抽象基类里写非抽象方法,这些普通方法会被子类直接继承,作为公共逻辑。这构成了"模板方法"的基础:抽象方法留给子类填空,公共方法在父类统一编排。
2.3 更灵活的虚拟子类注册
ABC 不只是靠继承约束,还支持 register() 把一个类"挂靠"到抽象基类名下。比如标准库 collections.abc 里就是这么干的:内置类型通过注册成为 Iterable、Sequence 等抽象基类的子类。
python复制from abc import ABC, abstractmethod
class SupportsRead(ABC):
@abstractmethod
def read(self) -> bytes: ...
class MyReader:
def read(self) -> bytes:
return b"hello"
SupportsRead.register(MyReader)
print(isinstance(MyReader(), SupportsRead)) # True
register() 的好处是不需要改动原类的继承关系,就能让它通过 isinstance 判断。但它有个隐患:注册不会校验这个类是否真的实现了抽象方法。我上面例子中 MyReader 恰好实现了 read,但如果我注册一个完全没有 read 方法的类,isinstance 照样返回 True。所以 register() 适合用在"你知道它没问题"的场合,不适合替代抽象方法的强制检查。
想要更精细地控制"哪些类算子类",可以重写 __subclasshook__:
python复制from abc import ABC
class LenSupport(ABC):
@classmethod
def __subclasshook__(cls, C):
for B in C.__mro__:
if "__len__" in B.__dict__:
return True
return NotImplemented
print(isinstance([], LenSupport)) # True
print(isinstance("abc", LenSupport)) # True
print(isinstance(42, LenSupport)) # False
这个钩子用起来很灵活,但也需要小心,因为它会在每次 isinstance 时被调用,如果判断逻辑很重,会影响性能。而且它只负责回答"你是不是我的子类",不负责方法实现是否完整。
3. typing.Protocol:用结构匹配替代继承绑定
ABC 的问题是太父子主义了。现实中的代码有很多类彼此没有血缘关系,但都具备同一个行为。你不可能为了让两个类通过类型检查,就强迫它们继承同一个抽象基类。尤其是项目已经上线、类是第三方库提供的时候,你更不愿意为了一个接口去动继承关系。
这就是 typing.Protocol 的用武之地。它在 Python 3.8 正式引入,对应 PEP 544,实现了"结构子类型"——只要结构一致,就算满足协议,跟继承关系无关。
3.1 定义协议并对接任意类
看代码:
python复制from typing import Protocol
class Speaker(Protocol):
def speak(self) -> str: ...
这个 Speaker 只是声明了一个行为形状:有 speak() 方法且返回 str。它不需要实现方法体,函数体用 ... 占位即可。
然后定义一个和它毫无继承关系的普通类:
python复制class Dog:
def speak(self) -> str:
return "汪汪"
class Alarm:
def speak(self) -> str:
return "滴滴滴"
再用 Speaker 来做类型标注:
python复制def trigger(item: Speaker) -> None:
print(item.speak())
在类型检查器(比如 mypy、pyright)眼中,Dog 和 Alarm 都满足 Speaker 协议,调用不会报错。这就是结构化类型的核心价值:trigger 不再要求传入某个抽象基类的子类,只要求对象具备 speak 方法。
这种思路非常契合现代 Python 的组合大于继承倾向,也适应接口自动化、API Client 这类需要模拟外部依赖的场景。
3.2 让 Protocol 支持 isinstance 运行时检查
默认情况下,Protocol 只是给类型检查器看的,isinstance 不认识它。想让 isinstance 也能判断,可以加 @runtime_checkable:
python复制from typing import Protocol, runtime_checkable
@runtime_checkable
class Speaker(Protocol):
def speak(self) -> str: ...
class Bird:
def speak(self) -> str:
return "叽叽喳喳"
print(isinstance(Bird(), Speaker)) # True
这个功能很香,但有个重要前提:isinstance 只检查 "方法/属性是否存在于对象上",不检查签名和类型。比如有个类:
python复制class BadSpeaker:
def speak(self, volume: int) -> str:
return "x"
它也能通过 isinstance(BadSpeaker(), Speaker),因为 speak 这个名字存在。只有静态类型检查器才能进一步发现签名不匹配的问题。
3.3 Protocol 和 ABC 的互补关系
我经常跟团队说:ABC 管的是运行时强约束和公共代码复用,Protocol 管的是编译/检查期类型约束和接口语义。两者不是竞争关系。
如果你想给一个第三方类挂上接口,但是又不想让它继承你的库,就用 Protocol。如果你想保证所有子类必须实现几个方法,并且这些方法有公共逻辑,就用 ABC。在一些大型项目里,你还能看到两者配合:对外暴露 Protocol 作为"轻接口",对内用一个 ABC 实现该协议并沉淀公共能力。
4. 真实项目选型:到底选 ABC、Protocol 还是鸭子类型
概念讲完了,关键问题是:我写代码时到底用哪个?别急,先看一张我常用的对比表。
| 维度 | 鸭子类型 | ABC 抽象基类 | Protocol 协议 |
|---|---|---|---|
| 依赖关系 | 无 | 必须继承或注册 | 无,结构匹配 |
| 运行时强制 | 无 | 强,未实现抽象方法无法实例化 | 弱,仅在 @runtime_checkable 下做存在性检查 |
| 类型检查器支持 | 不支持 | 支持,但依赖继承关系 | 支持,且更灵活 |
| 是否提供公共逻辑 | 否 | 是,可在父类写普通方法 | 否,协议不实现逻辑 |
| 修改已有类成本 | 无 | 高,要改继承关系 | 无 |
| 适用场景 | 小脚本、原型 | 框架、模板方法、标准库 | 库 API、依赖注入、插件接口 |
这个表格看起来清晰,但实际项目中往往更微妙,我拆开说几个典型场景。
4.1 用 Protocol 定义插件和依赖注入契约
假设你开发一个设备管理程序,需要支持 MQTT、Modbus、串口等多种通信方式。请你定义一个"通信收发器"接口:
python复制from typing import Protocol, Optional
class Transceiver(Protocol):
def connect(self, host: str, port: int) -> None: ...
def send(self, payload: bytes) -> None: ...
def close(self) -> None: ...
然后你可以写一个 MQTT 实现,也可以写一个本地模拟实现:
python复制class MqttTransceiver:
def __init__(self) -> None:
self.client = None
def connect(self, host: str, port: int) -> None:
# 这里创建真实 MQTT 客户端连接
pass
def send(self, payload: bytes) -> None:
# 发送 payload 到主题
pass
def close(self) -> None:
pass
class MockTransceiver:
def __init__(self) -> None:
self.send_count = 0
def connect(self, host: str, port: int) -> None:
self.send_count = 0
def send(self, payload: bytes) -> None:
self.send_count += 1
print(f"mock send: {payload!r}")
def close(self) -> None:
pass
这个例子里的 MockTransceiver 特别适合接口自动化测试:不用连真设备,只要实现协议中声明的方法,就能注入到业务代码里。如果你用 ABC,这两种实现就必须继承同一个基类,集成成本更高。
4.2 用 ABC 做需要公共逻辑的基类
反过来,如果你需要所有实现共享日志、异常处理、超时重试这些逻辑,那么 ABC 是更好的选择。你可以把公共逻辑写在抽象基类的普通方法里,把每一步的具体动作留给子类实现:
python复制from abc import ABC, abstractmethod
from loguru import logger
class HttpApi(ABC):
def call(self, endpoint: str, params: dict = None) -> dict:
logger.info(f"request {endpoint} with {params}")
try:
return self._do_request(endpoint, params or {})
except Exception:
logger.exception("request failed")
raise
@abstractmethod
def _do_request(self, endpoint: str, params: dict) -> dict:
...
子类只需要专心实现 _do_request,call 里的日志和异常处理都能复用。你用 Protocol 做这件事会很别扭,因为 Protocol 不擅长提供默认实现。
4.3 API 接口设计中的另一个"协议"层次
你可能会奇怪:热词里那些 MQTT、Modbus、SPI/IIC、API 接口、接口幂等性,跟《Python 中的协议》有什么关系?其实关系在于:任何跨模块、跨对象、跨服务的交互,都需要明确"契约"。通信协议是线上传输的字节契约,Protocol 是代码层面的行为契约。
我写 API 客户端时,习惯先把外部接口的 HTTP 调用逻辑抽象成一个协议:
python复制class WeatherAPI(Protocol):
def get_today(self, city: str) -> dict: ...
def get_week(self, city: str) -> list[dict]: ...
再用真实客户端和测试桩分别实现:
python复制class WeatherService:
def get_today(self, city: str) -> dict:
# 真实调用 https 接口
return {"city": city, "temp": 28}
class WeatherStub:
def get_today(self, city: str) -> dict:
return {"city": city, "temp": 20}
def get_week(self, city: str) -> list[dict]:
return [{"day": "Mon", "temp": 20}]
这样在接口自动化用例里注入 WeatherStub,CI 不依赖外网;生产环境注入 WeatherService,切换成本为零。Protocol 在这里扮演的角色,就是让 mock 和真实实现都遵循同一套签名。
4.4 选型心法
我给团队的规则很简单:
- 如果你在写框架,需要强制用户继承并实现必需方法,用 ABC。
- 如果你希望接口足够轻、不打扰用户类继承关系,用 Protocol。
- 如果代码只有你自己用,且规模很小,鸭子类型完全够用,不折腾。
- 如果既有公共逻辑又要灵活接入,可以定义一个 Protocol 作为对外契约,再提供 ABC 基类实现该契约中的公共逻辑。
5. 容易翻车的细节与我的落地经验
最后这部分,是我这几年踩坑踩出来的。别小看这些细节,它们往往决定了接口抽象是好用还是灾难。
5.1 抽象基类不检查方法签名
ABC 能保证子类实现了同名方法,但不会检查参数列表。比如抽象基类要求 save(name: str, data: bytes),子类写成 save(name, data, overwrite=True),看起来没问题,调用时却可能因为少传参数或者参数顺序不对而炸开。Protocol 在运行时检查同样只看"方法是否存在",签名检查必须交给 mypy / pyright。
所以我的建议是:在启用 ABC 或 Protocol 的同时,给项目配置类型检查器,并且跑在 CI 里。否则你真的会在运行时才发现问题。
5.2 不要为了抽象而抽象
我一度喜欢把所有方法都定义成 @abstractmethod,觉得这样才够"规范"。结果接口发布后,每新增一个抽象方法,所有子类就集体编译失败。这在库开发里是破坏性变更。
更好的做法是:把核心行为抽象出来,非核心行为提供默认实现。如果一个方法大多数子类都不需要,就不要把它放进抽象基类,而是让需要支持的子类自行扩展。用 Protocol 定义最精简的契约,通常比堆一长串抽象方法更健康。
5.3 虚拟子类注册要克制
register() 和 __subclasshook__ 很强大,但容易被误用。最大的问题在于:isinstance(obj, SomeABC) 返回 True,但 obj 可能并没有实现对应的逻辑。等于门卫放行了没票的人,进场后到处出错。
标准库自己也会使用 __subclasshook__,但都是非常明确、非常轻量的检查。如果你准备用 __subclasshook__ 去匹配复杂接口,建议先问自己:是不是直接改用 Protocol 更合适?
5.4 Protocol 的 runtime_checkable 只查"有没有",不查"对不对"
这是很多人踩的坑。@runtime_checkable 虽然让 isinstance 能认识协议,但它只是检查对象有没有这些属性,不会检查 read 是不是可调用,也不会检查返回类型。因此:
python复制class FakeReader:
read = "not callable"
isinstance(FakeReader(), SupportsRead) # True
这会让单元测试通过,但业务代码一调用就报 TypeError。我把这称作"假阴性完全不存在,假阳性遍地都是"。如果能用类型检查器在静态阶段解决问题,就不要把希望寄托在运行时检查上。
5.5 慎用 hasattr 判断接口
早期我为了不引入 ABC/Protocol,直接用 hasattr(obj, "read") 判断接口。这个做法看起来很轻量,但遇到 property 属性时会有问题,因为访问属性可能触发异常;遇到方法名存在但类型不对时,也会误判。
统一用 isinstance / issubclass + ABC 或 Protocol 会安全得多。虽然要多写几行定义,但换来的是清晰、可搜索、可静态检查的契约。
5.6 我现在的落地套路
经过大量项目磨合,我目前的项目里是这样分工的:
- 对外公开的库 API 用
Protocol定义行为,用户无需继承任何东西,只要结构对得上就兼容。 - 内部有公共逻辑的组件用
ABC做基类,把日志、重试、统计等统一收口。 - 单独的小脚本、原型代码直接用鸭子类型,少写框架,快速验证。
- 全项目启用 mypy 或者 pyright,让协议/抽象基类在 merge 之前就经过类型检查。
这套做法在动态和严谨之间找到了平衡点。Python 的接口设计不是一门硬性规定,而是一种取舍:你在运行时保护、静态检查和开发灵活度之间,永远要选一个适合自己的组合。
如果你现在也被类之间的继承关系折腾得头疼,建议先从 Protocol 起步。先定义"行为长什么样",再谈"谁来继承谁"。等到需要公共逻辑时,再引入 ABC 做具体基类,反而更自然。
