一个代码库想"让第三方对象也能当我们的接口用",于是用 typing.Protocol 定义了接口——不要求继承,只要"有那几个方法"就算实现。半年后有人发现"某对象少了一个方法",而 mypy 也没报错——因为它只检查"显式标注的类型",而那个对象是"从 JSON 里反序列化出来的 Any"。
Protocol 解决的是"结构化子类型":不靠继承,靠"形状匹配"。而它有三条边界:只在静态检查时生效、只对"被标注的地方"生效、这三条边界"同时成立"才安全:只要有一条被绕过(例如值来自 Any),Protocol 就从"约束"退化成"注释"。
不做运行时校验。
1. Protocol 与继承的三点差别
| 维度 | 继承(ABC) | Protocol |
|---|---|---|
| 关系 | 名义(显式声明) | 结构(形状匹配) |
| 运行时检查 | isinstance 可用 |
需 @runtime_checkable |
| 侵入性 | 要改对方的类 | 不改对方 |
第三项是它"最大的价值":"第三方库的对象"也能满足你的接口第三项的"实践意义"是"不用给第三方对象写适配器":只要形状对,它就能被传进去——于是"依赖倒置"第一次变得"零成本"。
——而那正是"依赖倒置"想要的。
2. "形状匹配"的三个条件
| 条件 | 说明 |
|---|---|
| 方法名与签名兼容 | 参数名/默认值都算 |
| 属性可以是"属性或方法" | 视声明方式 |
| 不检查"语义" | 名字对就行 |
这个"语义不同"的代价在运行期:with obj: 调用 __exit__,而第三方对象的 __exit__ 可能"只清自己的一半状态"——于是"看起来关了,实际留了线程"。
第三项是"边界":close() 与 shutdown() 都可能"看起来满足"——而它们的语义可能完全不同。
3. "只在静态检查时生效"意味着什么
| 场景 | 是否检查 |
|---|---|
| 明确标注的参数 | 是 |
Any / 未标注 |
否 |
| 运行时 | 否(除非 runtime_checkable) |
第二项是"最常见漏洞":**"从 JSON 来的数据是 Any,于是 Protocol 的约束全部失效"**第二项的"无声"很关键:mypy 对 Any 的规则是"放行"——于是"加了 Protocol 也没报错",而你以为"类型系统在保护"。
——而它"看起来类型正确"。
// 反例:Any 让约束失效
data = json.loads(payload) # Any
handle(data) # handle(x: Reader) → mypy 不报(Any 兼容一切)
// 正解:边界处"校验 + 转换"
from pydantic import TypeAdapter
reader: Reader = TypeAdapter(ReaderImpl).validate_python(payload) # 运行时校验
"声明一个协议"只要四行,而它"够用"的关键是"方法少而准":
from typing import Protocol, runtime_checkable
@runtime_checkable
class Reader(Protocol):
def read(self, n: int = -1) -> bytes: ... # 只声明"要用的那一个方法"
// 判据:协议里"多一个方法",就多一批"形状不匹配"的对象被排除
4. "运行时检查"的三种做法
| 做法 | 特点 |
|---|---|
@runtime_checkable + isinstance |
只查"方法名存在" |
显式 hasattr 检查 |
灵活(但样板多) |
| 用 pydantic 校验 | 最严(能查类型) |
第一项的"后果"是"isinstance 通过、调用 TypeError":因为它只查 hasattr,不查"参数个数/名字"——于是"检查通过"反而给了"假信心"。
第一项要注意:它"只检查方法是否存在",不检查签名——于是"参数个数不对"也能通过。
"边界校验"可以用"一行 pydantic"完成,而它比"手写 hasattr"可靠:
// 三种"把 Any 变成受约束对象"的方式
1 cast(Reader, data) # 只是"骗过检查器",运行时不变(不推荐)
2 TypeAdapter(ReaderImpl).validate(...) # 运行时真校验(推荐)
3 手写 if not hasattr(...) # 灵活但要自己维护
// 判据:数据"来自外部"就必须走 2;只有"内部已保证"才可用 1
5. "Protocol 与泛型"的组合
| 组合 | 用途 |
|---|---|
Protocol + 泛型参数 |
表达"容器" |
Protocol + @abstractmethod 混用 |
不推荐(语义混乱) |
只 Protocol |
推荐 |
第一项是"强力用法":"Iterable[T] 这类内置协议已经这么做"第一项的"写法"要"带约束":Protocol[T] 里用 T,于是"返回类型随输入变"——这与"泛型函数"是同一套机制。
——你要自己定义时,照它的形状写。
"协议别定太宽"这条可以用"最小接口"原则落地:
// 反例:一个"大协议"(谁都不满足)
class Store(Protocol):
def get(self, k: str) -> bytes: ...
def put(self, k: str, v: bytes) -> None: ...
def delete(self, k: str) -> None: ...
def scan(self, prefix: str) -> list[str]: ...
// 正解:按"使用方需要"拆成小协议(Readable / Writable)
// 判据:调用方"只用 get"时,就只要求 Readable
6. 排错:五种"Protocol 相关问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| mypy 不报"缺方法" | 值来自 Any |
边界处校验 |
isinstance 报错 |
未 runtime_checkable |
加装饰器 |
isinstance 通过但调用失败 |
只查方法名 | 显式检查签名 |
| 两边都"实现了"但语义不同 | 命名未对齐语义 | 改名或加文档 |
| 继承与 Protocol 混用混乱 | 两套关系并存 | 选一套 |
"协议与实现"的对应关系可以"反过来"建:先有"多个不同来源的对象",再抽协议。
// 三种"多实现"的来源(都适合 Protocol)
1 第三方 SDK 的客户端(boto3 / redis / requests)
2 测试替身(Fake 与真实实现"形状相同")
3 多后端抽象(本地文件 / S3 / 内存)
// 判据:这些对象"我改不了",但"调用方式一致" → 抽协议
7. 边界:什么情况不必用 Protocol
| 场景 | 为什么 | 做法 |
|---|---|---|
| 你自己写的类(可控) | 继承更明确 | 用 ABC |
| 只有一两个实现 | 结构匹配收益小 | 直接传具体类型 |
| 需要运行时强校验 | Protocol 做不到 | 用 pydantic |
| 极小的脚本 | 类型检查成本高于收益 | 不标注 |
最后一条与"演进"有关的边界:Protocol 是"向后兼容"的工具:你可以"先定义协议、让新代码按它写",而"老类不用改"——于是"接口收敛"能在"不动存量"的前提下推进。
判断口径一句话:"这个对象是我能改的,还是别人的"。别人的(第三方、内置),Protocol 是正解;自己的,ABC/基类更清楚——因为"显式声明"比"形状碰巧匹配"更可维护。
补一条"测试"上的好处:Protocol 让"测试替身"不必继承任何基类——一个"有同样方法的假对象"就能替,于是"测试与生产代码"之间少了一层耦合。这也是"结构化子类型"在动态语言里最实际的收益:接口是"用法"定义的,不是"声明"定义的。
而这条收益,正是"显式继承"给不了的——因为"假对象"不该为了"被当替身"而"继承一个它并不属于的类"。
这就是"鸭子类型"在类型系统里的正式写法:不问你是什么,只问你能做什么。
而它对"运行时"依旧一句话都不说。
所以"边界处校验"这件事,不能靠协议,只能靠校验器。
8. 自测
Protocol与继承的三点差别是什么?哪一点是它"最大的价值"?- "形状匹配"的三个条件里,哪一项"不检查"?为什么"名字对就行"有风险?
- 为什么"
Any会让约束全部失效"?边界处该怎么补? @runtime_checkable的isinstance"只查什么"?它为什么能"通过但调用失败"?Protocol与泛型组合时,最典型的用法是什么?- 判断"用 Protocol 还是 ABC"的那个问题是什么?