Python 的 typing.Protocol 是什么?和抽象基类、鸭子类型有什么区别?
简化版
**typing.Protocol(Python 3.8+)是「结构化子类型(structural subtyping)」的实现——它让你定义一个「协议(接口)」,任何「碰巧实现了协议里那些方法/属性」的类就自动算「符合这个协议」,不需要显式继承。**它是「静态类型检查版的鸭子类型」:鸭子类型是「运行时看有没有那个方法」,Protocol 是「让类型检查器(mypy)在静态阶段就能检查『这个对象有没有协议要求的方法』」。用法:class Drawable(Protocol): def draw(self) -> None: ...——任何有 draw 方法的类(哪怕没继承 Drawable)都被 mypy 认为是 Drawable。和抽象基类(ABC)的区别:① ABC 是「名义子类型」——必须显式继承(class C(MyABC))才算子类,是「明确的、有继承关系的」契约;② Protocol 是「结构子类型」——不用继承,只要「结构匹配(有那些方法)」就算,更灵活、更符合鸭子类型精神。运行时检查:给 Protocol 加 @runtime_checkable 后可以用 isinstance(obj, MyProtocol)(但只检查方法「存不存在」、不检查签名)。核心记忆:Protocol 是结构化子类型(有那些方法就算符合、不用继承),是静态类型检查版的鸭子类型;ABC 要显式继承(名义子类型)、Protocol 只看结构。
详细版
Protocol vs ABC vs 鸭子类型:
| 维度 | 鸭子类型 | ABC(抽象基类) | Protocol |
|---|---|---|---|
| 检查时机 | 运行时(调用时) | 实例化时 + isinstance | 静态(mypy)+ 可选 isinstance |
| 是否要继承 | 不要 | 要(名义) | 不要(结构) |
| 类型 | 无 | 名义子类型 | 结构子类型 |
| 静态检查 | 无 | 有限 | 强(mypy) |
from typing import Protocol, runtime_checkable
# 定义协议(结构化接口)
class Drawable(Protocol):
def draw(self) -> None: ... # 协议要求:有 draw 方法
# 任何有 draw 方法的类,都被 mypy 认为是 Drawable(不用继承!)
class Circle: # 没有继承 Drawable
def draw(self) -> None:
print("画圆")
class Square: # 也没继承
def draw(self) -> None:
print("画方")
def render(shape: Drawable) -> None: # 接受任何 Drawable
shape.draw()
render(Circle()) # ✓ mypy 认可(Circle 有 draw)
render(Square()) # ✓ mypy 认可(结构匹配)
# render(42) # mypy 报错:int 没有 draw 方法
# @runtime_checkable:支持 isinstance(运行时检查)
@runtime_checkable
class Sized(Protocol):
def __len__(self) -> int: ...
print(isinstance([1,2,3], Sized)) # True(list 有 __len__)
print(isinstance("abc", Sized)) # True
print(isinstance(42, Sized)) # False
# 注意:isinstance 只检查方法"存在",不检查签名/参数
# 对比 ABC:必须显式继承
from abc import ABC, abstractmethod
class DrawableABC(ABC):
@abstractmethod
def draw(self): ...
class Triangle(DrawableABC): # 必须显式继承才算子类
def draw(self): ...
# 协议也可以有属性、多个方法
class Comparable(Protocol):
def __lt__(self, other) -> bool: ...
def __eq__(self, other) -> bool: ...
# 显式继承 Protocol(可选,用于明确表达意图 + 检查实现)
class MyShape(Drawable, Protocol): ... # 扩展协议
class Explicit(Drawable): # 显式声明遵守(会检查实现)
def draw(self) -> None: ...
⚠️ 核心概念:「名义子类型(nominal)」vs「结构子类型(structural)」——名义类型看「你声明继承了谁」(是不是显式的子类),结构类型看「你长什么样」(有没有那些方法/属性);
ABC是名义的(必须继承),Protocol是结构的(有那些方法就算),Python 的鸭子类型本来就是「结构化」的思想,Protocol只是把它带到了「静态类型检查」层面。为什么需要 Protocol?因为「鸭子类型 + 类型注解」有矛盾:鸭子类型说「只要有read方法就能当文件用」,但如果你写def process(f: IOBase)(用具体类型注解),就把「能接受的对象」限死成「必须是 IOBase 的子类」,违背了鸭子类型的灵活——很多「有 read 方法但没继承 IOBase」的对象被拒绝。Protocol 解决这个矛盾:def process(f: Readable)(Readable 是个 Protocol,只要求有 read 方法),任何有 read 方法的对象都能传、且 mypy 能静态检查。所以 Protocol 是「给鸭子类型加上静态类型检查」的工具——保留了鸭子类型的灵活(不用继承、只看结构),又获得了静态检查的安全(mypy 在写代码时就能发现「传了个没有 read 方法的对象」)。标准库typing里有很多现成的 Protocol(SupportsInt、SupportsLen、Iterable等)。加@runtime_checkable能用 isinstance,但只检查「方法名存在」、不检查签名,所以运行时检查有限。
完整版教学
一、结构化子类型 vs 名义子类型
先理解两种「子类型」的哲学:
两种"子类型(subtype)"的判定方式:
名义子类型(nominal subtyping):
看"声明"——你显式继承了谁,你就是谁的子类
class Dog(Animal): ... # Dog 是 Animal,因为"声明继承了"
→ 靠"名字/声明"确定关系(Java、C++、Python 的 ABC)
结构子类型(structural subtyping):
看"结构"——你有哪些方法/属性,就算符合什么
只要有 Animal 要求的所有方法 → 就算 Animal(不用继承)
→ 靠"长什么样"确定关系(Go 的 interface、TypeScript、Python Protocol)
鸭子类型(duck typing):
"如果它走路像鸭子、叫声像鸭子,那它就是鸭子"
→ 运行时不看类型、只看"有没有那个方法"
→ 本质是"运行时的结构化判定"
三者的关系:
鸭子类型 = 运行时的结构化(不检查、直接调)
Protocol = 静态的结构化(mypy 检查结构)
ABC = 名义的(要显式继承)
例子:
名义(ABC):class Circle(Drawable): ... 必须继承
结构(Protocol):class Circle: def draw(): ... 有 draw 就算 Drawable
Python 的传统:鸭子类型(结构化思想)
→ Protocol 把这个思想带到静态类型检查
所以名义子类型看声明(继承谁)、结构子类型看结构(有哪些方法);鸭子类型是运行时结构化
两种「子类型(subtype)」的判定方式:名义子类型(nominal):看「声明」——显式继承了谁就是谁的子类(class Dog(Animal)、靠名字/声明确定关系、Java/C++/Python 的 ABC);结构子类型(structural):看「结构」——有哪些方法/属性就算符合什么(只要有要求的所有方法就算、不用继承、Go 的 interface/TypeScript/Python Protocol)。鸭子类型(duck typing):「走路像鸭子叫声像鸭子就是鸭子」(运行时不看类型只看有没有那个方法、本质是运行时的结构化判定)。三者的关系:鸭子类型 = 运行时的结构化、Protocol = 静态的结构化(mypy 检查)、ABC = 名义的(要显式继承)。Python 的传统:鸭子类型(结构化思想)——Protocol 把这个思想带到静态类型检查。理解「名义子类型看声明(继承谁、ABC/Java)、结构子类型看结构(有哪些方法、Protocol/Go);鸭子类型是运行时的结构化;Protocol=静态的结构化(mypy 检查)、ABC=名义的(要继承)」,就理解了两种子类型。
二、Protocol 的用法
理解怎么定义和使用协议:
定义协议:继承 typing.Protocol
from typing import Protocol
class Readable(Protocol):
def read(self, size: int = -1) -> bytes: ...
→ 定义了"协议":符合的类型要有 read 方法
用作类型注解:
def process(f: Readable) -> None:
data = f.read()
→ 参数标注为 Readable,接受任何有 read 方法的对象
结构匹配(不用继承):
class MyFile: # 没继承 Readable
def read(self, size=-1): return b"..."
process(MyFile()) # ✓ mypy 认可(有 read)
协议可以要求方法 + 属性:
class Named(Protocol):
name: str # 要求有 name 属性
def greet(self) -> str: ... # 要求有 greet 方法
协议方法体用 ...(省略):
def method(self) -> int: ... # 只声明签名,不实现
→ 协议只定义"接口",不提供实现
显式继承 Protocol(可选):
class Impl(Readable): # 显式声明"我遵守这个协议"
def read(self, size=-1): ...
→ 好处:mypy 会检查 Impl 是否真的实现了协议(提前发现漏实现)
→ 也表达"我有意实现这个协议"的意图
泛型协议:
class Container(Protocol[T]):
def get(self) -> T: ...
所以 Protocol 定义结构接口(方法体用...),用作类型注解;有那些方法就匹配(不用继承)
定义协议:继承 typing.Protocol(class Readable(Protocol): def read(self, size=-1) -> bytes: ...、定义了协议:符合的类型要有 read 方法)。用作类型注解:def process(f: Readable)(接受任何有 read 方法的对象)。结构匹配(不用继承):class MyFile: def read(...)(没继承 Readable、process(MyFile()) mypy 认可)。协议可以要求方法 + 属性:class Named(Protocol): name: str; def greet(self) -> str: ...。协议方法体用 ...(省略):只声明签名不实现(协议只定义接口)。显式继承 Protocol(可选):class Impl(Readable)(显式声明遵守、mypy 会检查是否真的实现了协议、也表达意图)。泛型协议:class Container(Protocol[T])。理解「Protocol 定义结构接口(继承 typing.Protocol、方法体用…);用作类型注解 def f(x:MyProtocol);有那些方法就匹配(不用继承);可要求方法+属性;显式继承 Protocol 可选(会检查实现)」,就掌握了 Protocol 的用法。
三、Protocol 解决什么问题
理解 Protocol 的动机:
问题:鸭子类型 + 类型注解的矛盾
鸭子类型的灵活:
def process(f):
data = f.read() # 只要有 read 方法就行(不管什么类型)
→ 任何有 read 的对象都能传(文件、网络流、StringIO、自定义...)
加了类型注解就限死了:
def process(f: IOBase): # 用具体类型注解
data = f.read()
→ mypy 只接受 IOBase 的子类
→ 但很多"有 read 但没继承 IOBase"的对象被拒绝(违背鸭子类型)
Protocol 解决:用结构接口注解
class Readable(Protocol):
def read(self, size=-1) -> bytes: ...
def process(f: Readable): # 只要求"有 read 方法"
data = f.read()
→ 任何有 read 方法的对象都能传(保留鸭子类型灵活)
→ mypy 能静态检查(获得类型安全)
Protocol 的价值:
① 保留鸭子类型的灵活(不用继承、只看结构)
② 获得静态类型检查(mypy 提前发现错误)
③ 解耦——调用方和实现方不用共享基类/继承层次
对比 ABC 注解:
ABC:def process(f: ReadableABC) → 对象必须继承 ReadableABC
→ 侵入性(要改对象的继承)、耦合
Protocol:def process(f: Readable) → 对象只要有 read
→ 非侵入(不改对象)、解耦
标准库现成 Protocol:
typing.SupportsInt/SupportsFloat/SupportsIndex/SupportsAbs
collections.abc 的很多也支持结构检查
所以 Protocol 解决鸭子类型+注解的矛盾:结构接口注解(有方法就行)+静态检查,非侵入解耦
问题:鸭子类型 + 类型注解的矛盾。鸭子类型的灵活:def process(f): f.read()(任何有 read 的对象都能传)。加了类型注解就限死了:def process(f: IOBase)(mypy 只接受 IOBase 子类、很多有 read 但没继承 IOBase 的对象被拒绝、违背鸭子类型)。Protocol 解决:用结构接口注解:class Readable(Protocol): def read(...) + def process(f: Readable)(只要求有 read 方法、任何有 read 的对象都能传保留鸭子类型灵活、mypy 能静态检查获得类型安全)。Protocol 的价值:① 保留鸭子类型的灵活(不用继承只看结构)、② 获得静态类型检查、③ 解耦(调用方和实现方不用共享基类)。对比 ABC 注解:ABC 对象必须继承(侵入性、耦合)、Protocol 对象只要有方法(非侵入、解耦)。标准库现成 Protocol:typing.SupportsInt/SupportsIndex/SupportsAbs。理解「Protocol 解决鸭子类型+注解的矛盾:具体类型注解会限死(违背鸭子类型)、Protocol 用结构接口注解(有方法就行)+静态检查;价值保留灵活+静态安全+解耦(非侵入不用继承);vs ABC 注解 ABC 要继承侵入、Protocol 非侵入」,就理解了 Protocol 的动机。
四、Protocol vs ABC
理解两者的详细区别和选择:
Protocol vs ABC(抽象基类):
ABC(名义子类型):
① 必须显式继承(class C(MyABC))
② 强制实现(子类没实现抽象方法不能实例化)
③ 侵入——要改对象的继承层次
④ 适合:你控制所有实现类、想强制契约
⑤ isinstance 检查(继承关系)
Protocol(结构子类型):
① 不用继承(有那些方法就算)
② 静态检查(mypy 检查结构匹配)
③ 非侵入——对象不用知道协议的存在
④ 适合:接受第三方对象、鸭子类型、解耦
⑤ @runtime_checkable 后可 isinstance(只查方法存在)
选择:
你定义框架、控制所有子类、想强制实现 → ABC
接受任意"符合结构"的对象(含第三方)、解耦 → Protocol
只要静态类型检查 → Protocol
需要运行时强制实现 → ABC
关键差异——第三方对象:
ABC:第三方类没继承你的 ABC → 不算(除非 register)
Protocol:第三方类只要有那些方法 → 自动算(结构匹配)
→ 想让"你不能修改的类"符合接口 → Protocol 天然支持
组合:ABC 也能用作 Protocol?
Protocol 更适合"结构",ABC 更适合"强制继承 + 混入方法"
→ collections.abc 的类既是 ABC 也支持结构检查(__subclasshook__)
历史:
ABC(Python 2.6+)名义
Protocol(Python 3.8, PEP 544)结构(补上了静态鸭子类型)
所以 ABC 名义(要继承、强制实现、侵入)、Protocol 结构(有方法就算、非侵入、静态检查)
Protocol vs ABC(抽象基类):ABC(名义子类型):① 必须显式继承、② 强制实现(子类没实现不能实例化)、③ 侵入(要改继承层次)、④ 适合你控制所有实现类想强制契约、⑤ isinstance 检查继承关系。Protocol(结构子类型):① 不用继承(有那些方法就算)、② 静态检查(mypy 检查结构匹配)、③ 非侵入(对象不用知道协议存在)、④ 适合接受第三方对象/鸭子类型/解耦、⑤ @runtime_checkable 后可 isinstance(只查方法存在)。选择:定义框架/控制所有子类/强制实现用 ABC、接受任意符合结构的对象(含第三方)/解耦/只要静态检查用 Protocol。关键差异——第三方对象:ABC 第三方类没继承你的 ABC 不算(除非 register)、Protocol 第三方类只要有那些方法自动算(想让不能修改的类符合接口用 Protocol)。理解「ABC 名义(要继承、强制实现、侵入、控制所有子类)、Protocol 结构(有方法就算、非侵入、静态检查、接受第三方);第三方对象 Protocol 天然支持(结构匹配)、ABC 要 register」,就掌握了两者区别。
五、runtime_checkable 与限制
理解运行时检查和 Protocol 的限制:
@runtime_checkable:让 Protocol 支持 isinstance
from typing import Protocol, runtime_checkable
@runtime_checkable
class Sized(Protocol):
def __len__(self) -> int: ...
isinstance([1,2], Sized) # True(list 有 __len__)
限制1:isinstance 只检查"方法存在"、不检查签名
@runtime_checkable
class P(Protocol):
def do(self, x: int) -> str: ...
class Bad:
def do(self): return None # 签名完全不符
isinstance(Bad(), P) # True!(只看有没有 do 方法名)
→ 运行时检查不可靠(不验证参数、返回类型)
→ 静态检查(mypy)才检查签名
限制2:只有 @runtime_checkable 才能 isinstance
没加装饰器的 Protocol 用 isinstance → TypeError
限制3:属性检查(runtime)有限
@runtime_checkable 主要检查方法、属性检查不完善
限制4:Protocol 不能实例化
Drawable() # 报错(协议是抽象的)
Protocol 的主要价值在"静态检查"(mypy):
→ 写代码时 mypy 检查结构匹配(含签名)
→ 运行时 isinstance 是补充(只查方法存在、不严谨)
最佳实践:
① 主要用于静态类型检查(配合 mypy)
② isinstance 检查用 @runtime_checkable(但知道它只查方法存在)
③ 需要严格运行时检查 → 别只靠 Protocol 的 isinstance
所以@runtime_checkable 支持 isinstance(但只查方法存在不查签名);Protocol 主要价值在静态检查
@runtime_checkable:让 Protocol 支持 isinstance(@runtime_checkable class Sized(Protocol): def __len__... 后 isinstance([1,2], Sized) 为 True)。限制1:isinstance 只检查「方法存在」、不检查签名(Bad 的 do 签名完全不符但 isinstance(Bad(), P) 仍为 True、只看有没有 do 方法名、运行时检查不可靠、静态检查 mypy 才检查签名)。限制2:只有 @runtime_checkable 才能 isinstance(没加装饰器的 Protocol 用 isinstance 报 TypeError)。限制3:属性检查(runtime)有限。限制4:Protocol 不能实例化。Protocol 的主要价值在「静态检查」(mypy):写代码时 mypy 检查结构匹配(含签名)、运行时 isinstance 是补充(只查方法存在、不严谨)。最佳实践:主要用于静态类型检查、isinstance 用 @runtime_checkable(但知道它只查方法存在)、需要严格运行时检查别只靠 Protocol 的 isinstance。理解「@runtime_checkable 支持 isinstance(但只查方法名存在、不查签名/参数,不可靠);没加装饰器不能 isinstance;Protocol 不能实例化;主要价值在静态检查(mypy 检查含签名)、isinstance 是补充」,就掌握了运行时检查和限制。
六、总结与实践
总结 Protocol:
核心:
Protocol = 结构化子类型(有那些方法就算符合、不用继承)
= 静态类型检查版的鸭子类型
用法:
class MyProtocol(Protocol):
def method(self) -> ...: ... # 定义结构接口
def f(x: MyProtocol): ... # 用作注解
→ 任何有 method 的对象都能传(mypy 检查)
三种子类型判定:
鸭子类型:运行时结构(不检查、直接调)
Protocol:静态结构(mypy 检查结构)
ABC:名义(要显式继承)
Protocol vs ABC:
ABC:名义、要继承、强制实现、侵入、可运行时强制
Protocol:结构、不用继承、静态检查、非侵入、接受第三方
解决的问题:
鸭子类型 + 类型注解的矛盾
→ 结构接口注解(有方法就行)+ 静态检查
runtime_checkable:
加它才能 isinstance(但只查方法存在、不查签名)
选择:
控制所有实现、强制契约、要混入方法 → ABC
接受任意符合结构的对象、解耦、第三方 → Protocol
主要是静态类型检查 → Protocol
核心总结:
Protocol 结构化子类型(有方法就算、不用继承)
静态鸭子类型(mypy 检查)
ABC 名义(要继承)、Protocol 结构(看结构)
所以 Protocol 结构化子类型(有方法就算、静态鸭子类型)、ABC 名义(要继承)
核心:Protocol = 结构化子类型(有那些方法就算符合、不用继承)= 静态类型检查版的鸭子类型。用法:class MyProtocol(Protocol): def method... + def f(x: MyProtocol)(任何有 method 的对象都能传、mypy 检查)。三种子类型判定:鸭子类型(运行时结构)、Protocol(静态结构、mypy 检查)、ABC(名义、要显式继承)。Protocol vs ABC:ABC 名义/要继承/强制实现/侵入/可运行时强制、Protocol 结构/不用继承/静态检查/非侵入/接受第三方。解决的问题:鸭子类型 + 类型注解的矛盾。runtime_checkable:加它才能 isinstance(但只查方法存在)。选择:控制所有实现/强制契约/要混入方法用 ABC、接受任意符合结构的对象/解耦/第三方/主要静态检查用 Protocol。理解「Protocol 结构化子类型(有方法就算、不用继承、静态鸭子类型 mypy 检查);ABC 名义(要继承强制实现);Protocol 非侵入接受第三方、解决鸭子类型+注解矛盾;runtime_checkable 才能 isinstance(只查方法存在);控制实现用 ABC、接受结构匹配用 Protocol」,就掌握了总结与实践。
记忆钩子:「typing.Protocol(Python 3.8+/PEP 544)是『结构化子类型(structural subtyping)』——定义一个协议(接口),任何『碰巧实现了协议里那些方法/属性』的类就自动算『符合这个协议』、不需要显式继承(class Drawable(Protocol):def draw(self):…,任何有 draw 方法的类哪怕没继承 Drawable 都被 mypy 认为是 Drawable);★它是『静态类型检查版的鸭子类型』:鸭子类型是运行时看有没有那个方法、Protocol 是让类型检查器(mypy)在静态阶段就检查对象有没有协议要求的方法;★关键区分名义 vs 结构子类型:ABC(抽象基类)是『名义子类型』——必须显式继承(class C(MyABC))才算子类、强制实现、侵入(要改对象继承层次);Protocol 是『结构子类型』——不用继承、只要结构匹配(有那些方法)就算、非侵入;★Protocol 解决『鸭子类型+类型注解的矛盾』:用具体类型注解(def f(x:IOBase))会把能接受的对象限死成必须是 IOBase 子类、违背鸭子类型,而 Protocol 注解(def f(x:Readable)只要求有 read 方法)保留了鸭子类型的灵活(不用继承、含第三方对象)又获得静态检查的安全;@runtime_checkable 后能用 isinstance(obj,MyProtocol),但★只检查方法『存不存在』、不检查签名/参数(不可靠),Protocol 主要价值在静态检查(mypy);选择:控制所有实现/强制契约/要混入方法用 ABC、接受任意符合结构的对象/解耦/第三方用 Protocol」。
七、常见误区与追问
- 误区:要让一个类符合 Protocol,必须继承这个 Protocol。 不用——这正是 Protocol(结构化子类型)区别于 ABC(名义子类型)的核心:只要一个类「碰巧实现了协议里要求的那些方法/属性」,它就自动被认为符合这个协议,完全不需要显式继承;
class Circle: def draw(self): ...没有继承Drawable(Protocol),但因为它有 draw 方法,mypy 就认为 Circle 符合 Drawable;这让 Protocol 能作用于「你无法修改的第三方类」(只要结构匹配就行)。 - 误区:Protocol 和 ABC 只是写法不同、作用一样。 本质不同——ABC 是「名义子类型」:必须显式继承(
class C(MyABC))才算子类、可以强制子类实现抽象方法(否则不能实例化)、有侵入性(要改对象的继承层次);Protocol 是「结构子类型」:不用继承、只看有没有那些方法、非侵入、主要在静态类型检查(mypy)层面工作;简单说 ABC 看「你声明继承了谁」,Protocol 看「你长什么样(有哪些方法)」;用途也不同——ABC 适合你控制所有实现类、想强制契约,Protocol 适合接受任意符合结构的对象(含第三方)、解耦。 - 误区:加了 @runtime_checkable 后,isinstance 检查就和静态检查一样严格。 不一样——
@runtime_checkable让 Protocol 支持isinstance,但运行时的 isinstance「只检查方法名是否存在」、不检查方法的签名(参数、返回类型):一个类哪怕do方法的参数完全不符协议要求,只要它有个叫do的方法,isinstance(obj, Protocol)就返回 True;真正检查签名匹配的是静态类型检查器(mypy);所以 Protocol 的主要价值在静态检查,@runtime_checkable的 isinstance 只是一个「宽松的、只看方法名的」补充,不能替代严格的运行时校验。 - 误区:Protocol 只是给 mypy 用的、运行时没有意义。 主要给静态检查用、但也有运行时用途——Protocol 的核心价值确实是「让 mypy 在写代码阶段就能检查结构匹配(含签名)」,这是它的主战场;但加上
@runtime_checkable装饰器后,也能在运行时用isinstance(obj, MyProtocol)做「宽松的能力检查」(只看方法是否存在);此外即使不用 mypy,用 Protocol 定义接口本身也是一种好的「文档/契约表达」(清晰说明函数期望对象具备什么能力);只是运行时的 isinstance 不检查签名、要知道它的局限。 - 追问:Protocol、ABC、鸭子类型三者是什么关系? 三者都关乎「一个对象能不能当某种接口用」,区别在「怎么判定」和「什么时候判定」:① 鸭子类型是「运行时的结构化判定」——不检查类型、直接调用方法,能调通就行、调不通才报错(
if it walks like a duck...),最灵活但没有任何提前检查;② Protocol 是「静态的结构化判定」——把鸭子类型的「看结构」思想带到静态类型检查层面,定义一个协议(要求哪些方法),mypy 在写代码时就检查「传入的对象有没有这些方法(且签名匹配)」,既保留了鸭子类型的灵活(不用继承)又增加了静态安全;③ ABC 是「名义判定」——必须显式继承才算子类、靠继承关系确定,是明确的、有强制力的契约;关系上:鸭子类型和 Protocol 都是「结构化」的(看有没有方法),只是一个运行时一个静态;ABC 是「名义化」的(看继承声明);Protocol 可以说是「给鸭子类型加上静态类型检查」。 - 追问:Protocol 具体解决了什么问题,为什么鸭子类型加上类型注解会有矛盾? 矛盾在于:Python 的鸭子类型让函数很灵活——
def process(f): f.read()能接受任何「有 read 方法」的对象(文件、网络流、StringIO、自定义类……),不管它们是什么类型、有没有共同基类;但一旦你想给参数加类型注解(为了类型检查和文档),用具体类型def process(f: IOBase)就把「能接受的对象」限死成「必须是 IOBase 的子类」,那些「有 read 方法但没继承 IOBase」的对象就被 mypy 拒绝了——这违背了鸭子类型的初衷(只要有 read 就行);Protocol 解决这个矛盾:定义一个只要求「有 read 方法」的协议class Readable(Protocol): def read(...),然后def process(f: Readable)——这样任何有 read 方法的对象都能传(保留鸭子类型的灵活、包括第三方对象),同时 mypy 又能静态检查「传入的对象是否真的有 read 方法(且签名匹配)」(获得类型安全);所以 Protocol 让你「既享受鸭子类型的灵活解耦,又享受静态类型检查的安全」。 - 追问:什么时候用 Protocol、什么时候用 ABC? 用 ABC 的场景:① 你控制所有的实现类、想「强制」它们实现某组方法(子类没实现就不能实例化,运行时强制契约);② 想让基类提供一些「混入方法」(子类实现少数核心方法就白得一堆派生方法,如继承 collections.abc.Sequence);③ 需要明确的、有继承关系的类型层次(表达「is-a」关系);用 Protocol 的场景:① 想接受「任意符合结构的对象」,尤其是「你无法修改继承关系的第三方对象」(只要它有那些方法就行、非侵入);② 想给鸭子类型的代码加静态类型检查(保留灵活 + 获得 mypy 安全);③ 想「解耦」——调用方定义它需要的接口(Protocol),实现方不需要知道这个 Protocol 的存在、也不用继承它;④ 主要目的是静态类型检查而非运行时强制;简单说:你「拥有并想强制」实现类用 ABC,你「想接受各种符合结构的对象(含别人的)」用 Protocol;两者也可以配合——用 Protocol 定义对外接受的接口、用 ABC 组织你自己的实现层次。
八、加强记忆
typing.Protocol(Python 3.8+/PEP 544)是「结构化子类型(structural subtyping)」——定义一个协议(接口),任何「碰巧实现了协议里那些方法/属性」的类就自动算「符合这个协议」、不需要显式继承(class Drawable(Protocol): def draw(self): ...,任何有 draw 方法的类哪怕没继承 Drawable 都被 mypy 认为是 Drawable)。它是「静态类型检查版的鸭子类型」:鸭子类型是运行时看有没有那个方法、Protocol 是让类型检查器(mypy)在静态阶段就检查对象有没有协议要求的方法。关键区分名义 vs 结构子类型:ABC(抽象基类)是「名义子类型」——必须显式继承(class C(MyABC))才算子类、强制实现、侵入(要改对象继承层次);Protocol 是「结构子类型」——不用继承、只要结构匹配(有那些方法)就算、非侵入。Protocol 解决「鸭子类型 + 类型注解的矛盾」:用具体类型注解(def f(x: IOBase))会把能接受的对象限死成必须是 IOBase 子类、违背鸭子类型,而 Protocol 注解(def f(x: Readable) 只要求有 read 方法)保留了鸭子类型的灵活(不用继承、含第三方对象)又获得静态检查的安全。@runtime_checkable 后能用 isinstance(obj, MyProtocol),但只检查方法「存不存在」、不检查签名/参数(不可靠),Protocol 主要价值在静态检查(mypy)。选择:控制所有实现/强制契约/要混入方法用 ABC、接受任意符合结构的对象/解耦/第三方用 Protocol。一句话「Protocol 结构化子类型(有那些方法就算符合、不用继承)、是静态鸭子类型(mypy 检查);ABC 名义(要显式继承、强制实现、侵入)、Protocol 结构(非侵入、接受第三方);解决鸭子类型+注解矛盾;@runtime_checkable 才能 isinstance(只查方法存在)」。