2073 字
约 6 分钟
17
S10-A24 · `Protocol`:结构化子类型能解决什么,不能解决什么

一个代码库想"让第三方对象也能当我们的接口用",于是用 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. 自测

  1. Protocol 与继承的三点差别是什么?哪一点是它"最大的价值"?
  2. "形状匹配"的三个条件里,哪一项"不检查"?为什么"名字对就行"有风险?
  3. 为什么"Any 会让约束全部失效"?边界处该怎么补?
  4. @runtime_checkable 的 isinstance "只查什么"?它为什么能"通过但调用失败"?
  5. Protocol 与泛型组合时,最典型的用法是什么?
  6. 判断"用 Protocol 还是 ABC"的那个问题是什么?
S10-A24 · `Protocol`:结构化子类型能解决什么,不能解决什么
https://www.clxhxhhr.top/posts/6664/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。