Python 类型系统第 :Protocol 与 ABC 到底怎么选?从结构化子类型到领域接口的工程实践
Python 有一种非常独特的气质。
它从诞生之初就没有要求程序员把所有对象塞进严格的继承树里。很多时候,我们并不关心一个对象“到底是什么类”,而只关心:
它能不能完成我要它完成的事情?
这也是 Python 著名的 Duck Typing 思想:
If it walks like a duck and quacks like a duck, then it is a duck.
如果一个对象能够:
read()
我们就把它当成可读对象。
如果它能够:
write(...)
我们就把它当成可写对象。
如果它能够:
__iter__()
我们就把它当成可迭代对象。
这种灵活性让 Python 成为了 Web 开发、自动化、数据处理、人工智能以及系统集成领域极具生命力的“胶水语言”。
但当项目从:
3 个文件
增长到:
300 个模块
甚至:
几十万行代码
我们自然会希望 IDE 和静态类型检查器也能理解这些 Duck Typing 契约。
于是问题出现了:
如果我定义:
from typing import Protocol
class Writable(Protocol):
def write(self, data: bytes) -> int:
...
那么下面这个类:
class FileWriter:
def write(self, data: bytes) -> int:
print(f"write {len(data)} bytes")
return len(data)
它没有继承 Writable:
class FileWriter:
...
而不是:
class FileWriter(Writable):
...
那么它到底算不算一个:
Writable
?
答案是:
算。
只要它的结构满足 Writable 所要求的成员和类型签名,静态类型检查器就可以把它视为 Writable 的实现。
Python 官方类型系统把这种机制称为:
Structural Subtyping——结构化子类型。
这也正是 Protocol 与传统 ABC 最大的思想分界。Python 类型系统规范明确指出,结构类型通过对象拥有的属性、方法等结构来判断兼容性,而不要求存在继承关系;Protocol 正是 Python 中最重要的结构类型机制之一。(Python Typing)
而理解这一点,几乎就是理解 Protocol 的全部入口。
一、先用一个例子彻底看懂 Protocol
假设我们正在写一个日志系统。
日志最终可能写到:
文件
内存
Socket
云存储
测试桩
压缩流
核心业务其实只需要一个能力:
write(data: bytes) -> int
于是定义:
from typing import Protocol
class Writable(Protocol):
def write(self, data: bytes) -> int:
...
现在实现一个文件 Writer:
class FileWriter:
def write(self, data: bytes) -> int:
print(f"writing {len(data)} bytes to file")
return len(data)
再实现一个内存 Writer:
class MemoryWriter:
def __init__(self) -> None:
self.buffer = bytearray()
def write(self, data: bytes) -> int:
self.buffer.extend(data)
return len(data)
注意:
FileWriter
没有继承:
Writable
MemoryWriter 也没有。
但我们可以写:
def save_report(
output: Writable,
data: bytes,
) -> None:
output.write(data)
然后:
save_report(
FileWriter(),
b"hello",
)
save_report(
MemoryWriter(),
b"hello",
)
对于支持 Protocol 的静态类型检查器来说,这两次调用都可以是合法的。
原因不是:
FileWriter IS-A Writable
而是:
FileWriter HAS write(bytes) -> int
也就是说:
class hierarchy 不重要
object structure 才重要
官方 Python 3.14 文档给出的 Protocol 示例同样说明:一个没有继承 Protocol 的普通类,只要提供兼容的方法,就可以作为该 Protocol 使用。(Python documentation)
这就是:
Static Duck Typing
静态鸭子类型。
二、传统继承到底在检查什么?
为了理解 Protocol,我们先看普通类。
class Animal:
pass
class Cat(Animal):
pass
这里:
Cat <: Animal
成立的理由是:
Cat
明确继承了:
Animal
这叫:
Nominal Subtyping
即:
名义子类型。
判断关系的重点是:
“你是谁?”
或者更准确地说:
“你的类继承关系是什么?”
所以:
class Cat(Animal):
...
成立。
但是:
class RobotCat:
...
哪怕它具有与 Animal 完全一样的方法,只要:
RobotCat
没有继承:
Animal
传统名义类型系统依然不会因为“长得一样”就把它视为 Animal。
三、Protocol 检查的不是“你是谁”,而是“你会什么”
现在换成:
from typing import Protocol
class Speaker(Protocol):
def speak(self) -> str:
...
然后:
class Cat:
def speak(self) -> str:
return "meow"
class Robot:
def speak(self) -> str:
return "beep"
class Person:
def speak(self) -> str:
return "hello"
它们彼此完全没有继承关系:
Cat
Robot
Person
但三者都满足:
Speaker
因为:
┌── Cat
│ └── speak() -> str
│
Speaker ──┼── Robot
│ └── speak() -> str
│
└── Person
└── speak() -> str
因此:
def announce(speaker: Speaker) -> None:
print(speaker.speak())
下面都可以:
announce(Cat())
announce(Robot())
announce(Person())
这就是结构化类型最大的魅力:
接口的兼容关系不再依赖继承树,而依赖真实能力。
四、那么 ABC 是什么?
来看传统 Abstract Base Class:
from abc import ABC, abstractmethod
class Writable(ABC):
@abstractmethod
def write(
self,
data: bytes,
) -> int:
...
实现类通常需要明确继承:
class FileWriter(Writable):
def write(
self,
data: bytes,
) -> int:
print("write file")
return len(data)
这里表达的是:
FileWriter
↓
明确属于
↓
Writable hierarchy
而不只是:
FileWriter 恰好有 write()
这就是一个非常重要的区别。
我们可以先粗略地概括:
Protocol
“只要满足这个结构,
你就可以作为这个类型使用。”
而:
ABC
“你需要明确加入这个抽象体系。”
虽然 ABC 本身还支持 register()、__subclasshook__() 等更灵活的运行时机制,但普通用户自定义 ABC 的核心模型仍然更偏向 nominal hierarchy。Python 的 abc 模块允许直接继承、注册 virtual subclass,以及通过 __subclasshook__() 自定义运行时子类判断。(Python documentation)
五、一个没有继承 Writable 的类,究竟什么时候满足 Protocol?
假设:
class Writable(Protocol):
def write(
self,
data: bytes,
) -> int:
...
下面满足:
class A:
def write(
self,
data: bytes,
) -> int:
return len(data)
因为方法结构兼容。
但是:
class B:
def save(
self,
data: bytes,
) -> int:
return len(data)
不满足。
原因不是:
B 没有继承 Writable
而是:
B 没有 write()
下面也可能不满足:
class C:
def write(
self,
data: str,
) -> int:
return len(data)
因为 Protocol 要求:
bytes
而这里定义的是:
str
同理:
class D:
def write(
self,
data: bytes,
) -> str:
return str(len(data))
返回值也与:
-> int
不兼容。
所以 Protocol 并不是简单的:
hasattr(obj, "write")
静态类型检查器真正检查的是:
member exists
+
member type compatible
+
call signature compatible
这也是:
static structural typing
与普通运行时 Duck Typing 的重要区别。
六、Structural Typing 最大的优势:解耦
现在假设我们正在写一个库:
def export(
destination: Writable,
payload: bytes,
) -> None:
destination.write(payload)
使用 ABC 时,你可能要求所有调用方:
class MyOutput(Writable):
...
问题来了。
如果用户手里已经有:
io.BytesIO
或者第三方 SDK 对象:
ThirdPartyStorage
或者遗留系统中的:
LegacyWriter
它们本来就已经拥有:
write(bytes) -> int
你真的应该要求:
“请修改那个类,让它继承我这个接口。”
很多时候根本做不到。
尤其是第三方类:
你不能修改源码。
这时 Protocol 极其优雅:
第三方对象
│
│ 不认识你的 Protocol
│
▼
具有兼容的 write()
│
▼
自动满足 Writable
双方甚至:
不需要知道彼此存在。
这就是结构化类型最强大的地方之一:
Decoupling
解耦。
七、Protocol 对第三方库尤其友好
设想你开发一个图像处理库。
只需要一个对象能够:
read(size: int) -> bytes
你可以定义:
class Readable(Protocol):
def read(
self,
size: int,
) -> bytes:
...
你的函数:
def decode_image(
stream: Readable,
) -> None:
header = stream.read(16)
于是:
File
BytesIO
SocketWrapper
CloudObject
TestStream
只要拥有兼容接口,都可以直接接入。
不要求:
继承你的类
不要求:
register
不要求:
adapter
这对于 library boundary 极其重要。
Protocol 让库作者可以表达:
“我只要求调用方拥有这一小部分能力。”
而不是:
“调用方必须进入我的类型体系。”
这就是低耦合 API 非常理想的形态。
八、Protocol 让接口遵循“最小能力原则”
假设一个函数只需要:
read()
却声明:
def parse(
file: MyHugeFileClass,
) -> Result:
...
这意味着调用方必须拥有:
read
write
seek
close
flush
metadata
compress
upload
download
...
尽管函数实际上只使用:
read
这会产生:
API over-specification
即:
接口要求过度。
Protocol 可以把契约缩到真正需要的能力:
class Reader(Protocol):
def read(
self,
size: int,
) -> bytes:
...
然后:
def parse(
source: Reader,
) -> Result:
...
现在依赖关系变成:
parse
│
▼
Reader
│
└── 只要求 read()
这和 SOLID 中的:
Interface Segregation Principle
非常契合。
接口越小:
适配者越多
耦合越低
测试越简单
复用能力越强
九、Protocol 对测试尤其舒服
来看一个真实项目中很常见的依赖。
class EmailSender(Protocol):
def send(
self,
address: str,
content: str,
) -> None:
...
生产环境:
class SmtpSender:
def send(
self,
address: str,
content: str,
) -> None:
print("sending SMTP mail")
业务:
def register_user(
sender: EmailSender,
email: str,
) -> None:
# create user...
sender.send(
email,
"Welcome!",
)
测试时完全不需要继承任何东西:
class FakeSender:
def __init__(self) -> None:
self.messages: list[
tuple[str, str]
] = []
def send(
self,
address: str,
content: str,
) -> None:
self.messages.append(
(address, content)
)
测试:
fake = FakeSender()
register_user(
fake,
"alice@example.com",
)
assert fake.messages == [
(
"alice@example.com",
"Welcome!",
)
]
这就是一个非常漂亮的依赖反转:
Business Logic
↓
EmailSender Protocol
↑
┌────┴────┐
│ │
SMTP Fake
生产代码和测试代码:
都只满足结构
而不需要建立人为继承关系。
十、那么是不是 Protocol 永远比 ABC 好?
不是。
这是学习 Protocol 后很容易走向的另一个极端:
“既然结构化类型这么灵活,以后全部 Protocol。”
并不合适。
Protocol 和 ABC 解决的是两类不同的问题。
最重要的判断不是:
哪个技术更新?
而是:
这个接口表达的是一种“能力”,还是一种“领域身份与契约”?
这句话非常重要。
十一、“能力型接口”通常更适合 Protocol
下面这些名字,通常天然像 Protocol:
Readable
Writable
Closable
Serializable
Comparable
Renderable
Logger
Notifier
Handler
Callback
SupportsXYZ
它们都在描述:
“这个对象会做什么?”
例如:
class Closable(Protocol):
def close(self) -> None:
...
我们不关心对象到底是:
File
Socket
DatabaseConnection
Browser
Camera
Session
只需要:
close()
即可。
这种接口:
Protocol 非常自然。
十二、“领域身份型接口”很多时候 ABC 更自然
假设我们设计支付系统。
class PaymentMethod(ABC):
@abstractmethod
def authorize(
self,
amount: Money,
) -> Authorization:
...
@abstractmethod
def capture(
self,
authorization: Authorization,
) -> Receipt:
...
这时我们可能不是简单地表达:
“有 authorize 和 capture 就行。”
而是在表达:
这是系统认可的一种 PaymentMethod。
这是一种:
domain identity
领域身份。
我们希望:
class CreditCardPayment(
PaymentMethod
):
...
明确声明:
我是 PaymentMethod
而不是让一个偶然拥有:
authorize()
capture()
的方法组合的对象自动混进来。
这时候:
ABC 往往更加合适。
十三、ABC 可以表达“你必须主动加入这个领域”
再看一个订单系统。
class OrderPolicy(ABC):
@abstractmethod
def validate(
self,
order: Order,
) -> None:
...
实现:
class RetailOrderPolicy(
OrderPolicy
):
def validate(
self,
order: Order,
) -> None:
...
这个继承关系本身就在传递重要信息:
RetailOrderPolicy
IS-A
OrderPolicy
开发者看到类声明就知道:
这是订单规则体系的一部分
这种显式归属关系本身就是领域模型的一部分。
如果全部使用 Protocol:
class SomethingRandom:
def validate(
self,
order: Order,
) -> None:
...
它也可能结构上满足:
OrderPolicy
有时这是灵活性。
有时却是:
过度宽松。
所以:
当“属于这个体系”本身有业务语义时,nominal typing 往往更有价值。
十四、ABC 可以阻止不完整实现被实例化
这是 ABC 非常重要的运行时能力。
例如:
from abc import ABC, abstractmethod
class Storage(ABC):
@abstractmethod
def load(
self,
key: str,
) -> bytes:
...
@abstractmethod
def save(
self,
key: str,
value: bytes,
) -> None:
...
然后:
class BrokenStorage(Storage):
def load(
self,
key: str,
) -> bytes:
return b""
它忘记实现:
save()
此时:
BrokenStorage()
不能正常实例化。
ABCMeta 配合 @abstractmethod 会在运行时阻止仍然存在未实现抽象方法的正规子类实例化。(Python documentation)
这意味着 ABC 不只是:
type checker contract
它还是:
runtime class construction contract
这在框架代码和领域模型中非常有用。
十五、ABC 还能提供共享实现
ABC 并不意味着:
所有方法都只能 ...
实际上它完全可以提供公共行为。
例如:
from abc import ABC, abstractmethod
class Serializer(ABC):
@abstractmethod
def serialize(
self,
obj: object,
) -> bytes:
...
def save(
self,
obj: object,
filename: str,
) -> None:
payload = self.serialize(obj)
with open(
filename,
"wb",
) as f:
f.write(payload)
子类只需要负责:
serialize()
公共流程:
save()
由 ABC 提供。
例如:
class JsonSerializer(
Serializer
):
def serialize(
self,
obj: object,
) -> bytes:
...
继承在这里承担了两种作用:
接口契约
+
行为复用
这时 ABC 的存在非常自然。
十六、但不要为了“复用两个方法”就滥用 ABC
这里也必须提醒一个常见问题。
很多项目最后出现这样的继承树:
BaseService
↓
AbstractService
↓
CommonService
↓
BusinessService
↓
UserService
最后一个类想复用上面:
12 行代码
却背上了:
五层继承关系
这种时候:
composition
通常比继承更加健康。
ABC 应该表达:
真正稳定的抽象契约
而不是:
“我不知道代码放哪儿,
先扔父类。”
十七、现在聊最容易误解的 @runtime_checkable
默认情况下:
class Writable(Protocol):
...
主要是为了:
静态类型检查
你不能简单期待:
isinstance(
obj,
Writable,
)
像普通类一样工作。
如果希望 Protocol 可以参与部分运行时检查,需要:
from typing import (
Protocol,
runtime_checkable,
)
@runtime_checkable
class Writable(Protocol):
def write(
self,
data: bytes,
) -> int:
...
然后:
writer = FileWriter()
if isinstance(
writer,
Writable,
):
print("writable")
这可以工作。
但是——
这里有一个非常巨大的“但是”。
@runtime_checkable 不是运行时类型验证器。
官方文档甚至直接把这种检查描述为一种比较简单的 runtime protocol check。(Python documentation)
十八、@runtime_checkable 只检查“有没有”,不完整检查签名
考虑:
@runtime_checkable
class Writable(Protocol):
def write(
self,
data: bytes,
) -> int:
...
然后定义一个奇怪的类:
class StrangeWriter:
def write(
self,
x: str,
y: int,
) -> dict[str, int]:
return {}
它虽然有:
write
但签名完全不是我们想要的:
Writable:
write(bytes) -> int
实际:
StrangeWriter:
write(str, int) -> dict[str, int]
静态类型检查器有机会发现这种不兼容。
但是:
isinstance(
StrangeWriter(),
Writable,
)
不能被理解为:
“Python 已经证明 write 的参数和返回值完全符合 Protocol。”
Python 官方文档明确说明,@runtime_checkable 的运行时检查关注所要求成员是否存在,并不会验证这些成员的类型签名。(Python documentation)
所以:
static Protocol check
和:
runtime isinstance check
完全不是同等级别的保证。
可以粗略理解成:
静态检查:
有没有 write?
参数兼容吗?
返回值兼容吗?
整体类型契约成立吗?
而 runtime protocol check 更接近:
有没有 write?
因此绝对不要把:
isinstance(
obj,
Writable,
)
理解成一个完整 Schema Validator。
十九、所以不要用 runtime_checkable 验证外部不可信数据
例如你从插件系统加载了未知对象:
plugin = load_plugin()
然后:
if isinstance(
plugin,
Writable,
):
plugin.write(b"hello")
这最多说明:
它看起来有相关成员
并不等于:
它保证按照你的签名工作
更不说明:
业务语义正确
例如一个插件可以拥有:
write
但:
接受的是 str
返回的是 None
调用时直接抛异常
甚至 write 只是一个奇怪属性
Protocol 的核心价值始终是:
Static Structural Typing
不是构建完整运行时契约系统。
PEP 544 本身就明确指出,Protocol 的主要目标是静态 structural subtyping,而不是提供复杂的运行时接口验证机制。(Python Enhancement Proposals (PEPs))
二十、runtime_checkable 还有性能成本
还有一个经常被忽略的问题:
isinstance(
obj,
SomeRuntimeProtocol,
)
可能比普通:
isinstance(
obj,
SomeClass,
)
慢得多。
Python 官方文档明确提醒:
runtime-checkable protocol 的
isinstance()检查可能明显慢于普通类检查,在性能敏感代码中可以考虑其他方案。(Python documentation)
所以不要在:
for item in millions_of_items:
里面不假思索地:
isinstance(
item,
HugeProtocol,
)
尤其不要把它当成便宜的标签判断。
二十一、Python 3.12 以后 runtime Protocol 还有两个值得知道的变化
从 Python 3.12 开始,runtime-checkable protocol 的内部属性查找改用了:
inspect.getattr_static()
而不是过去的:
hasattr()
因此某些拥有动态属性行为的对象,在 3.12 前后可能出现不同的 isinstance() 结果。(Python documentation)
另外,从 Python 3.12 开始:
runtime Protocol 创建后,其用于运行时检查的成员集合会被冻结。
也就是说:
@runtime_checkable
class P(Protocol):
def foo(self) -> None:
...
类创建后再 monkey patch:
P.bar = ...
不会简单地把:
bar
加入后续 isinstance() 所依据的 Protocol 成员集合。(Python documentation)
工程上最简单的原则就是:
不要依赖动态修改 runtime Protocol 来改变运行时契约。
把 Protocol 当成稳定接口定义即可。
二十二、issubclass() 也不是所有 Protocol 都能随便用
再深入一点。
PEP 544 区分:
data protocol
和:
non-data protocol
例如:
@runtime_checkable
class Runner(Protocol):
def run(self) -> None:
...
只有方法。
这是典型:
non-data protocol
而:
@runtime_checkable
class Named(Protocol):
name: str
则包含实例数据成员。
这是:
data protocol
运行时:
isinstance()
可以用于 runtime-checkable data/non-data protocols。
但:
issubclass()
对 data protocol 存在限制。
原因很好理解:
实例属性可能只在 __init__() 运行后出现。
仅观察一个 class:
SomeClass
并不总能知道实例最终会不会拥有:
name
因此不能简单依赖:
issubclass()
判断这类结构。
PEP 544 对此有明确限制。(Python Enhancement Proposals (PEPs))
二十三、Protocol 和 ABC 最重要的对比
现在可以整理成一张工程表。
| 维度 | Protocol | ABC |
|---|---|---|
| 核心类型模型 | Structural | Nominal 为主 |
| 是否要求显式继承 | 不要求 | 通常要求 |
| 第三方类适配 | 非常方便 | 较强耦合 |
| Duck Typing 友好度 | 极高 | 相对较低 |
| 静态类型检查 | 强 | 强 |
| 强制子类实现抽象方法 | 主要依赖静态检查语义 | abstractmethod 可运行时阻止实例化 |
| 共享实现 | 可以,但通常不是重点 | 很自然 |
| 表达能力型接口 | 非常适合 | 可以 |
| 表达领域身份 | 有时过于宽松 | 很适合 |
isinstance() | 需 runtime_checkable,且检查有限 | 天然支持正规继承 |
| 与未知第三方对象协作 | 非常优秀 | 通常需要继承/注册/适配 |
| 耦合程度 | 低 | 更显式、更强 |
| 最适合 | 能力、边界、插件、callback、依赖注入 | 领域模型、框架基类、生命周期、模板方法 |
二十四、一个最实用的判断标准:Capability vs Identity
以后看到一个接口时,可以问:
“我是在描述能力,还是描述身份?”
例如:
Writable
Readable
Closable
Serializable
Renderable
Comparable
Notifier
这些都明显是在表达:
Capability
即:
“你会什么?”
优先考虑:
Protocol
但是:
PaymentMethod
DomainEvent
AggregateRoot
PricingPolicy
WorkflowStep
GameEntity
CompilerPass
这些名称往往不仅是在表达:
方法集合
它们通常还表达:
领域身份
生命周期
设计意图
架构归属
这时候:
ABC
可能更合适。
二十五、案例:第三方 Storage 应该用 Protocol
假设你正在写一个通用缓存库。
只需要:
class CacheBackend(Protocol):
def get(
self,
key: str,
) -> bytes | None:
...
def set(
self,
key: str,
value: bytes,
) -> None:
...
你的系统:
def cache_result(
backend: CacheBackend,
) -> None:
...
现在调用方可以直接接:
Redis wrapper
Memcached wrapper
Fake backend
Local dictionary adapter
第三方缓存 SDK
只要结构兼容即可。
这是非常典型的:
library boundary
使用 Protocol。
因为你没有理由要求整个世界:
继承你的 CacheBackend。
二十六、案例:领域聚合根可能更适合 ABC
假设我们有领域模型:
from abc import ABC, abstractmethod
class AggregateRoot(ABC):
def __init__(self) -> None:
self._events: list[
DomainEvent
] = []
def record(
self,
event: DomainEvent,
) -> None:
self._events.append(event)
def pull_events(
self,
) -> list[DomainEvent]:
events = self._events[:]
self._events.clear()
return events
@abstractmethod
def validate(self) -> None:
...
这里:
AggregateRoot
不是简单表示:
“只要有 validate() 就算聚合根。”
它还可能承担:
统一生命周期
事件记录
领域不变量
共享机制
框架识别
明确架构身份
那么:
class Order(AggregateRoot):
...
这个继承关系本身就是设计信息。
这种场景如果为了“解耦”强行 Protocol 化:
class AggregateRoot(
Protocol
):
...
反而可能丢失重要语义。
二十七、Protocol 和 ABC 甚至可以一起用
现实项目不一定需要二选一。
例如你可以对外暴露:
class Writable(Protocol):
def write(
self,
data: bytes,
) -> int:
...
核心函数只依赖这个最小能力:
def export(
output: Writable,
) -> None:
...
而你的官方实现体系内部则使用:
class BaseWriter(ABC):
@abstractmethod
def write(
self,
data: bytes,
) -> int:
...
def write_header(self) -> int:
return self.write(
b"HEADER\n"
)
例如:
class FileWriter(BaseWriter):
def write(
self,
data: bytes,
) -> int:
...
结构是:
export()
│
▼
Writable
Protocol
▲
┌───────┼────────┐
│ │ │
│ │ │
FileWriter BytesIO ThirdPartyWriter
│
▼
BaseWriter
ABC
这样做非常漂亮:
外部 API:
低耦合 structural interface
内部实现:
明确 nominal hierarchy
这往往比强迫所有地方统一使用同一种抽象机制更成熟。
二十八、Protocol 最适合 Architecture Boundary
如果你的架构是:
┌───────────────────────────┐
│ Business Core │
└────────────┬──────────────┘
│
Protocol
│
┌───────────┼──────────────┐
│ │ │
▼ ▼ ▼
Redis PostgreSQL HTTP API
Adapter Adapter Adapter
例如:
class UserRepository(
Protocol
):
def find(
self,
user_id: int,
) -> User | None:
...
业务代码:
class UserService:
def __init__(
self,
repo: UserRepository,
) -> None:
self.repo = repo
数据库实现:
class SqlUserRepository:
def find(
self,
user_id: int,
) -> User | None:
...
测试实现:
class MemoryUserRepository:
def find(
self,
user_id: int,
) -> User | None:
...
这里 Protocol 可以让:
业务核心
完全不要求基础设施:
加入自己的继承体系。
在 Clean Architecture、Hexagonal Architecture、Ports & Adapters 这类设计中,这种写法尤其自然。
二十九、但是 Domain Interface 不要机械地全写 Protocol
这一点非常值得资深开发者注意。
假设:
class DiscountPolicy(Protocol):
def calculate(
self,
order: Order,
) -> Money:
...
是否应该 Protocol?
答案取决于业务。
如果你希望:
任何能 calculate(Order) -> Money
的对象都可以充当折扣策略
Protocol 很好。
但如果系统需要:
策略注册
统一 ID
生命周期
共享状态
校验逻辑
版本机制
框架 discovery
强制基类行为
那么:
class DiscountPolicy(ABC):
...
可能更加合适。
所以不要问:
“Repository 应该 Protocol 还是 ABC?”
而应该问:
“我的架构希望 Repository 是一种开放能力契约,还是一个封闭、明确的领域族?”
答案不同,选择自然不同。
三十、一个容易被忽视的问题:名字碰巧相同,不代表语义相同
Structural Typing 的优点,同时也是它潜在的危险。
假设:
class Deletable(Protocol):
def delete(
self,
id: int,
) -> None:
...
而某个完全无关的类:
class Cache:
def delete(
self,
id: int,
) -> None:
...
结构上:
Cache
可能满足:
Deletable
但是两边的:
delete
业务含义可能根本不是一回事。
例如:
一个删除用户
另一个删除缓存 key
这暴露出 structural typing 的重要边界:
它可以证明形状兼容,却不能证明业务语义相同。
静态类型系统能看到:
method name
parameter type
return type
但它看不到:
业务意图。
这也是为什么核心领域模型有时更需要 ABC 等显式 nominal relationship。
三十一、Protocol 设计的一个重要原则:接口要小
不建议这样:
class SuperService(
Protocol
):
def create(...): ...
def update(...): ...
def delete(...): ...
def search(...): ...
def export(...): ...
def import_(...): ...
def sync(...): ...
def validate(...): ...
def notify(...): ...
def archive(...): ...
Protocol 越大:
偶然满足的人越少
mock 越难
实现成本越高
耦合越严重
更好的方式通常是拆成:
class Reader(Protocol):
def read(...) -> ...:
...
class Writer(Protocol):
def write(...) -> ...:
...
class Deleter(Protocol):
def delete(...) -> ...:
...
调用方只依赖自己真正需要的:
def report(
repo: Reader,
) -> None:
...
这就是:
consumer defines the interface
消费者定义自己需要的最小接口。
这是大型 Python 项目非常值得采用的最佳实践。
三十二、不要把 Protocol 当 Java Interface 的机械翻译
Protocol 虽然看起来像:
interface
但 Python 的设计思路不同。
Java 风格通常强调:
class Foo implements Bar
显式声明:
我实现 Bar。
Protocol 更强调:
你不需要认识 Bar。
只要你自然具备 Bar 所要求的结构,
就可以被视为 Bar。
这是一种更加开放的设计。
PEP 544 当初设计 Protocol 的重要目标之一,就是把 Python 原本自然的 duck typing 习惯带进静态类型系统,而不是要求所有代码都改造成显式继承体系。(Python Enhancement Proposals (PEPs))
所以:
class FileWriter(Writable):
并不是错。
但如果你选择 Protocol 的主要原因恰恰是:
structural typing
那么很多实现根本不需要显式继承它。
三十三、ABC 也不是绝对“必须直接继承”
这里补充一个高级细节。
Python ABC 支持:
ABC.register(SomeClass)
把一个没有真正继承 ABC 的类注册为:
virtual subclass
例如官方文档展示了类似:
MyABC.register(tuple)
之后:
issubclass(
tuple,
MyABC,
)
可以返回 True。(Python documentation)
ABC 还可以通过:
__subclasshook__()
自定义:
issubclass()
的判定逻辑。
所以现实中的区别并不是:
Protocol = 绝对不继承
ABC = 绝对必须继承
而应该理解成:
Protocol:
静态类型系统以 structural compatibility
为核心模型。
ABC:
运行时 class hierarchy / nominal abstraction
是核心模型,同时提供 virtual subclass 等扩展机制。
这样理解更加准确。
三十四、什么时候优先 Protocol?
如果满足以下特征:
接口描述的是能力
例如:
Readable
Writable
Serializable
Logger
Handler
或者:
需要适配第三方类型
或者:
不希望实现方依赖接口定义模块
或者:
测试中会大量使用 Fake / Stub
或者:
你在设计 library boundary
或者:
依赖注入只需要极小能力集合
那么:
优先考虑 Protocol。
三十五、什么时候优先 ABC?
如果:
属于该类型体系
本身就有语义,
或者你需要:
运行时阻止不完整实现实例化
或者需要:
共享实现
或者:
Template Method
或者需要:
明确领域继承层级
或者:
框架要求统一生命周期
或者:
所有实现本来就由你控制
那么:
ABC 往往更加自然。
三十六、实际项目中的决策流程
以后遇到:
Protocol or ABC?
可以按下面流程判断:
这个接口只是描述“能力”吗?
│
┌──┴──┐
Yes No
│ │
▼ ▼
Protocol “属于这个体系”
│
▼
ABC
再继续问:
需要适配你无法修改的第三方类型?
│
Yes
│
▼
Protocol
再问:
需要运行时禁止不完整子类实例化?
│
Yes
│
▼
ABC
再问:
只为了 isinstance() 才想加 Protocol?
│
Yes
│
▼
重新考虑设计
因为:
runtime_checkable
并不是 Protocol 最主要的价值。
三十七、代码 Review 时看到 Protocol,我通常会问什么?
看到:
class Something(
Protocol
):
...
可以问:
- 这是“能力”还是“领域身份”?
- 接口是否足够小?
- 实现方是否需要完全不知道这个接口存在?
- 是否需要适配第三方对象?
- 是否只因为“想用 interface”而机械选择 Protocol?
- 是否错误依赖
runtime_checkable做完整验证? - 这个 Protocol 是否包含过多业务行为?
- 是否应该拆成多个更小的 capability protocols?
看到:
class Something(
ABC
):
...
则可以问:
- 继承关系本身是否真的有业务意义?
- 是否真的需要运行时 abstractmethod 约束?
- 是否真的存在值得共享的实现?
- 能否使用 composition 替代继承?
- 是否把本来只需要一个小能力的 API 强行绑到了巨大基类?
- 第三方实现是否会因此变得困难?
这些问题,比机械讨论:
Protocol 好还是 ABC 好
有价值得多。
三十八、回到开头的问题
我们最开始定义:
class Writable(Protocol):
def write(
self,
data: bytes,
) -> int:
...
然后:
class MyWriter:
def write(
self,
data: bytes,
) -> int:
return len(data)
尽管:
MyWriter
没有写:
class MyWriter(Writable):
它依然可以静态满足:
Writable
因为 Protocol 使用的是:
Structural Subtyping
即:
看结构
而不是看继承关系。
如果改成:
class Writable(ABC):
@abstractmethod
def write(
self,
data: bytes,
) -> int:
...
那么我们通常是在表达:
Nominal Domain Contract
希望实现方明确加入:
Writable hierarchy
并且可以利用:
abstractmethod
运行时实例化约束
共享实现
MRO
super()
等传统面向对象机制。
三十九、最后用一句话区分它们
如果只记一句:
Protocol 问“你能做什么?”;ABC 更倾向问“你是不是这个体系的一员?”
进一步展开:
Protocol
↓
Capability
↓
Structural typing
↓
Loose coupling
↓
Library boundary
而:
ABC
↓
Identity / hierarchy
↓
Nominal typing
↓
Explicit contract
↓
Domain model / framework
这就是二者真正值得记住的区别。
四十、结语:Python 最强大的地方,不是“不要接口”,而是接口可以很轻
Python 的动态性曾经让很多人觉得:
“既然是 Duck Typing,还需要接口吗?”
而静态类型系统成熟以后,我们发现还有第三条路:
既不需要把 Python 改造成一个处处:
extends
implements
的语言,
也不必放弃:
IDE 推导
API 契约
重构安全
静态检查
Protocol 给出的答案是:
保留 Duck Typing 的开放性,同时让类型检查器理解这个 Duck。
而 ABC 则继续承担另一种重要角色:
当继承关系本身就是设计语言时,用明确的类型层级告诉整个团队:“这些类属于同一个概念体系。”
成熟的 Python 项目通常不会只选择其中一个。
它更可能是:
Architecture Boundary
↓
Protocol
Domain Hierarchy
↓
ABC
Concrete Implementation
↓
普通 class
甚至:
对外使用 Protocol
对内使用 ABC
两者互补。
真正优秀的 Python 类型设计,不是追求:
“所有东西都有一个基类”
也不是追求:
“所有东西都是 Protocol”
而是理解:
什么时候应该通过能力建立关系,什么时候应该通过身份建立关系。
当你面对一个:
class Interface(...):
...
真正值得问的已经不再是:
“这里应该写
Protocol还是ABC?”
而是:
“我希望调用方因为它拥有某种能力而满足这个接口,还是因为它明确属于这个领域体系而满足这个接口?”
当这个问题能够回答清楚时,Protocol 与 ABC 的选择,通常也就清楚了。
附:Protocol / ABC 工程速查表
第三方适配 → Protocol
Duck Typing → Protocol
Callback / Handler → Protocol
Readable / Writable → Protocol
依赖注入边界 → Protocol
Fake / Stub 测试 → Protocol
领域身份 → ABC
框架基类 → ABC
Template Method → ABC
共享实现 → ABC
运行时抽象约束 → ABC
明确继承体系 → ABC
最重要的一条:
Capability → 优先考虑 Protocol
Identity → 优先考虑 ABC
留给你的三个思考题
第一,如果一个:
UserRepository
只定义:
find()
save()
你会把它设计成:
Protocol
还是:
ABC
?
如果 Repository 实现来自 SQL、MongoDB、第三方 SDK 和测试内存仓库,你的答案会不会变化?
第二,如果你定义:
@runtime_checkable
class JsonSerializable(Protocol):
def to_json(self) -> str:
...
然后:
isinstance(
obj,
JsonSerializable,
)
返回 True,
你是否敢断言:
obj.to_json()
一定满足完整的静态签名和业务语义?
答案应该非常谨慎:
不能。
因为 runtime Protocol check 并不是完整运行时类型验证。(Python documentation)
第三,如果一个核心领域接口既需要:
明确领域身份
又希望:
第三方 adapter 可以低耦合接入
是否可以:
内部 ABC
+
边界 Protocol
同时存在?
答案不仅是:
可以。
在大型 Python 系统里,这往往反而是非常漂亮的设计。
当你真正理解这一点,Protocol 与 ABC 就不再是两个竞争的语法工具,而变成了两种不同的建模语言:
一个描述能力,
一个描述归属。
知道什么时候使用哪一种,才是真正掌握 Python 类型设计的开始。

977

被折叠的 条评论
为什么被折叠?



