Python 基础体系 · 第 26/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。
Python ABC 与 Protocol:名义子类型、结构化类型和接口设计
在 Python 中,“接口”不是单一机制。abc.ABC、@abstractmethod、register()、__subclasshook__() 和 typing.Protocol 都能表达某种接口关系,但它们回答的问题不同:
- 名义子类型关心:类是否通过继承或注册声明“我是这个类型”。
- 结构化类型关心:对象是否具备目标接口所要求的成员。
- ABC 既能约束继承层次,又能提供运行时抽象基类语义和共享实现。
- Protocol 主要为静态类型检查器描述结构化接口,也可以通过
@runtime_checkable提供有限的运行时检查。
如果混淆这些概念,就容易出现几个典型错误:把 Protocol 当作运行时验证器,把 ABC 当作纯类型标注,把 register() 当作继承,或者认为“有同名方法”就一定满足接口。
一、先建立三层模型:继承、类型可赋值和运行时检查
分析 ABC 和 Protocol,最好把三个层次分开。
1. Python 运行时的对象关系
运行时可以直接观察:
class Animal:
pass
class Dog(Animal):
pass
dog = Dog()
print(isinstance(dog, Dog))
print(isinstance(dog, Animal))
print(Dog.__mro__)
预期输出:
True
True
(<class '__main__.Dog'>, <class '__main__.Animal'>, <class 'object'>)
这里体现的是继承关系。Dog 出现在 Animal 的继承链中,因此 Dog 的实例也是 Animal 的实例。
2. 静态类型系统中的可赋值关系
静态类型检查器通常不执行函数,而是判断一个表达式的类型能否赋给另一个类型。
可以把这种关系写成:
其中:
- 表示表达式或对象的静态类型;
- 表示变量、参数或返回值声明的目标类型;
- 表示“
X可赋值给T”。
例如:
class Animal:
def move(self) -> None:
print("move")
class Dog(Animal):
def bark(self) -> None:
print("bark")
def move_animal(animal: Animal) -> None:
animal.move()
dog = Dog()
move_animal(dog)
Dog 能赋给 Animal,因为 Dog 名义上继承了 Animal。
但是,静态类型系统中的关系不一定完全等同于 Python 运行时的 isinstance() 关系。Protocol 就是典型例子:一个没有继承 Protocol 的类,也可能在静态类型上满足该 Protocol。Python 运行时默认不会强制检查类型标注;类型标注主要由类型检查器、IDE 和 lint 工具使用。(docs.python.org)
3. 运行时检查关系
isinstance() 和 issubclass() 是运行时操作:
isinstance(value, TargetType)
issubclass(ConcreteType, TargetType)
它们只能依据运行时可观察到的信息判断,不能完整执行静态类型系统中的赋值规则。
例如,下面的返回类型注解不会自动在运行时验证:
def parse_port(value: str) -> int:
return value # Python 不会因为注解而自动抛出类型错误
因此,理解 ABC 与 Protocol 的第一步是:
静态可赋值、运行时实例检查和继承关系,分别属于不同层次。
二、名义子类型:类型身份来自声明
1. 什么是名义子类型
名义子类型(nominal subtyping)是指:一个类型是否为另一个类型的子类型,取决于显式的类型声明,通常是继承关系。
形式化地说,如果类 D 显式继承 B:
class D(B):
...
那么可以写成:
这里的关键不是 D 是否恰好实现了 B 的方法,而是 D 是否在声明层面把 B 放入了继承关系。
from abc import ABC, abstractmethod
class Serializer(ABC):
@abstractmethod
def serialize(self, value: object) -> bytes:
raise NotImplementedError
class JsonSerializer(Serializer):
def serialize(self, value: object) -> bytes:
return str(value).encode()
JsonSerializer 是 Serializer 的名义子类型:
serializer = JsonSerializer()
print(isinstance(serializer, Serializer))
print(issubclass(JsonSerializer, Serializer))
预期输出:
True
True
2. 名义关系不仅表达方法,还表达类型身份
假设有一个没有继承 Serializer 的类:
class ThirdPartySerializer:
def serialize(self, value: object) -> bytes:
return repr(value).encode()
从方法形状看,它似乎可以作为序列化器使用,但它不是 Serializer 的名义子类型:
print(issubclass(ThirdPartySerializer, Serializer))
预期输出:
False
这并不一定是缺点。名义类型除了描述“能调用什么”,还可以表达:
- 这是某个领域概念;
- 该类接受某组设计约束;
- 该类参与某个继承层次;
- 该类可以复用基类实现;
- 该类愿意承担基类的语义契约。
例如,两个类都可能有 save() 方法,但一个保存到数据库,另一个保存到临时缓存。仅凭方法名无法证明它们具有相同的业务语义。
三、ABC:运行时可见的抽象基类
1. ABC 和 ABCMeta
abc 模块中的 ABC 是抽象基类(Abstract Base Class)。最常见的写法是继承 ABC:
from abc import ABC, abstractmethod
class PaymentGateway(ABC):
@abstractmethod
def charge(self, cents: int) -> str:
raise NotImplementedError
ABC 本质上使用 ABCMeta 作为元类。也可以显式写成:
from abc import ABCMeta, abstractmethod
class PaymentGateway(metaclass=ABCMeta):
@abstractmethod
def charge(self, cents: int) -> str:
raise NotImplementedError
ABCMeta 负责维护抽象方法状态、虚拟子类注册以及相关的 issubclass() 逻辑。(docs.python.org)
2. @abstractmethod 的作用
被 @abstractmethod 标记的方法表示:显式继承该 ABC 的具体类必须提供实现。
class StripeGateway(PaymentGateway):
def charge(self, cents: int) -> str:
return f"stripe:{cents}"
class IncompleteGateway(PaymentGateway):
pass
运行:
print(StripeGateway().charge(199))
try:
IncompleteGateway()
except TypeError as exc:
print(type(exc).__name__)
print(exc)
预期结果类似:
stripe:199
TypeError
Can't instantiate abstract class IncompleteGateway with abstract method charge
这里有两个独立事实:
IncompleteGateway仍然可以定义;- 因为抽象方法未实现,它不能被实例化。
@abstractmethod 也可以用于抽象属性、类方法和静态方法;使用多个装饰器时,通常应把 @abstractmethod 放在内部。(docs.python.org)
3. 抽象方法可以有实现
抽象方法不必只有 ...。它可以提供一个供子类调用的默认实现:
from abc import ABC, abstractmethod
class Document(ABC):
@abstractmethod
def render_body(self) -> str:
return "empty body"
def render(self) -> str:
return "<document>" + self.render_body() + "</document>"
class Report(Document):
def render_body(self) -> str:
return "monthly report"
调用:
print(Report().render())
输出:
<document>monthly report</document>
与纯接口不同,ABC 的重要能力之一是:它既能规定子类必须提供的操作,又能提供共享行为。
四、register():虚拟子类仍是名义机制
ABC 允许把一个没有继承它的类注册为虚拟子类:
from abc import ABC
class Closeable(ABC):
pass
class SocketLike:
def close(self) -> None:
print("closed")
Closeable.register(SocketLike)
value = SocketLike()
print(isinstance(value, Closeable))
print(issubclass(SocketLike, Closeable))
print(SocketLike.__mro__)
预期输出:
True
True
(<class '__main__.SocketLike'>, <class 'object'>)
注意最后一点:Closeable 没有出现在 SocketLike.__mro__ 中。注册只影响 ABC 的子类判断,不会修改原类的继承链,也不会把 ABC 中的方法注入到被注册类中。(docs.python.org)
因此:
class Base(ABC):
def helper(self) -> str:
return "from base"
class External:
pass
Base.register(External)
obj = External()
print(isinstance(obj, Base))
try:
print(obj.helper())
except AttributeError as exc:
print(type(exc).__name__)
输出类似:
True
AttributeError
这说明:
register()建立的是 ABC 认可的虚拟名义关系,不是继承,也不是能力注入。
如果某个类只是“碰巧有几个方法”,通常不应随意对 ABC 使用 register()。注册更适合表达一种经过确认的类型分类关系,例如标准库中的抽象集合关系。
五、__subclasshook__():ABC 中的受控结构判断
ABC 还可以通过 __subclasshook__() 自定义 issubclass() 的判断:
from abc import ABC, abstractmethod
class IterableLike(ABC):
@abstractmethod
def __iter__(self):
raise NotImplementedError
@classmethod
def __subclasshook__(cls, candidate):
if cls is IterableLike:
if any("__iter__" in base.__dict__ for base in candidate.__mro__):
return True
return NotImplemented
现在:
class Data:
def __iter__(self):
return iter([1, 2, 3])
print(issubclass(Data, IterableLike))
print(isinstance(Data(), IterableLike))
预期输出:
True
True
__subclasshook__() 必须返回 True、False 或 NotImplemented:
True:把候选类视为子类;False:拒绝其子类资格;NotImplemented:交给正常的继承和注册逻辑继续判断。
它可以让 ABC 具有某种结构化判断能力,但这种能力是由 ABC 作者手动编写的,并不是完整的静态结构类型系统。官方示例也特别说明,__subclasshook__() 只改变子类判断,不会把 ABC 的其他方法添加到候选类上。(docs.python.org)
六、结构化类型:类型身份来自成员集合
1. 什么是结构化类型
结构化类型(structural typing)不要求类显式继承接口。它只要求类型具备目标接口规定的成员,并且这些成员的类型满足可赋值条件。
设协议 的成员集合为:
一个具体类型 满足协议 ,当且仅当:
直觉上:
- 找到协议要求的每个成员;
- 检查具体类型是否提供该成员;
- 检查方法参数、返回值和属性类型是否兼容;
- 全部成立时,
X可以作为P使用。
这与“是否写过 class X(P)”无关。
2. 用 Protocol 声明结构化接口
from typing import Protocol
class SupportsClose(Protocol):
def close(self) -> None:
...
下面两个类都没有继承 SupportsClose:
class FileResource:
def close(self) -> None:
print("file closed")
class NetworkResource:
def close(self) -> None:
print("network closed")
但它们都满足该 Protocol 的结构要求:
def close_all(resources: list[SupportsClose]) -> None:
for resource in resources:
resource.close()
close_all([FileResource(), NetworkResource()])
预期输出:
file closed
network closed
静态类型检查器会根据 close() 的成员和签名判断两个类是否可赋值给 SupportsClose。Protocol 的规范将这种关系称为隐式实现或隐式可赋值:类不必把 Protocol 写入 MRO,只要提供所有必需成员即可。(typing.python.org)
七、方法名相同并不等于满足 Protocol
结构化类型检查的是完整成员签名,不是仅仅检查名字。
from typing import Protocol
class Encoder(Protocol):
def encode(self, value: str) -> bytes:
...
class CorrectEncoder:
def encode(self, value: str) -> bytes:
return value.encode()
class WrongEncoder:
def encode(self, value: bytes) -> str:
return value.decode()
CorrectEncoder 满足接口,而 WrongEncoder 不满足,因为:
- 参数类型从
str变成了bytes; - 返回类型从
bytes变成了str。
一个调用者如果根据 Encoder 编写代码:
def encode_message(encoder: Encoder, message: str) -> bytes:
return encoder.encode(message)
传入 WrongEncoder() 后,运行时可能在调用点直接失败:
TypeError: ...
静态检查器的作用正是在运行前发现这类签名不兼容。Protocol 规范使用“可赋值类型”判断协议成员,而不是只比较成员名称。(typing.python.org)
可调用对象也可以用 Protocol 描述
当 Callable 无法表达复杂签名时,可以给 Protocol 定义 __call__():
from typing import Protocol
class Combiner(Protocol):
def __call__(
self,
*values: bytes,
max_length: int | None = None,
) -> list[bytes]:
...
def combine(*values: bytes, max_length: int | None = None) -> list[bytes]:
result = list(values)
if max_length is not None:
result = result[:max_length]
return result
def run_combiner(combiner: Combiner) -> list[bytes]:
return combiner(b"a", b"b", max_length=1)
print(run_combiner(combine))
这种写法把“函数对象应该接受什么参数、返回什么结果”表达成了一个可复用接口。typing 文档也将 Protocol.__call__() 作为表达复杂可调用签名的方式。(docs.python.org)
八、ABC 与 Protocol 的核心差异
可以用下面的维度对比两者:
| 维度 | ABC | Protocol |
|---|---|---|
| 主要关系 | 名义继承,也支持虚拟子类 | 结构化可赋值 |
| 是否要求显式继承 | 通常要求;也可 register() |
静态检查通常不要求 |
| 是否能提供共享实现 | 能 | 显式继承时能;隐式实现时不能获得默认实现 |
| 是否阻止抽象类实例化 | @abstractmethod 可以 |
显式子类中的抽象成员可以参与抽象性;Protocol 本身不能实例化 |
默认运行时 isinstance() |
支持 | 不支持 |
| 适合表达 | 类型分类、生命周期、共享骨架、运行时身份 | 外部依赖、能力接口、低耦合适配 |
| 继承关系是否是核心语义 | 是 | 不是,成员结构才是核心 |
例如:
from abc import ABC, abstractmethod
from typing import Protocol
class ABCLogger(ABC):
@abstractmethod
def write(self, message: str) -> None:
raise NotImplementedError
def write_error(self, message: str) -> None:
self.write("ERROR: " + message)
class ProtocolLogger(Protocol):
def write(self, message: str) -> None:
...
如果某个类显式继承 ABCLogger,它可以直接复用 write_error()。但一个只提供 write() 的外部类,即使静态上满足 ProtocolLogger,也不会自动获得任何默认方法。Protocol 规范明确区分了显式继承和隐式实现:隐式实现只获得类型关系,不改变 Python 的继承语义。(typing.python.org)
九、Protocol 的显式继承:不是必须,但可以换取默认实现和校验
Protocol 也可以被显式继承:
from typing import Protocol
class Renderable(Protocol):
def render(self) -> str:
...
class HtmlRenderable(Renderable):
def render(self) -> str:
return "<html></html>"
显式继承的意义主要有两个:
- 子类可以复用 Protocol 中的默认实现;
- 类型检查器可以直接检查该类是否正确实现了 Protocol。
但这不会改变“结构化类型”的基本用途。下面的类仍然可能隐式满足 Renderable:
class MarkdownDocument:
def render(self) -> str:
return "# document"
它不需要写:
class MarkdownDocument(Renderable):
...
如果一个类从 Protocol 继承,但没有再次把 Protocol 作为直接基类,它会被视为普通 ABC,而不是自动成为新的 Protocol。这样设计是为了避免“某个普通子类因为继承了一个 Protocol,就意外变成开放的结构化接口”。(typing.python.org)
正确的子协议写法是:
from collections.abc import Sized
from typing import Protocol
class SizedAndClosable(Sized, Protocol):
def close(self) -> None:
...
这里 SizedAndClosable 同时要求:
__len__();close()。
也可以合并多个 Protocol:
class SupportsClose(Protocol):
def close(self) -> None:
...
class SizedAndClosable2(Sized, SupportsClose, Protocol):
pass
多重继承在这里表达的是接口交集:
因此,一个类型必须同时满足两个协议。(typing.python.org)
十、Protocol 成员:方法、属性、只读属性和类属性
1. 方法成员
class Reader(Protocol):
def read(self, size: int = -1) -> bytes:
...
任何实现都必须能以兼容的方式调用:
class MemoryReader:
def __init__(self, data: bytes) -> None:
self.data = data
def read(self, size: int = -1) -> bytes:
return self.data if size < 0 else self.data[:size]
2. 可读写属性
class NamedObject(Protocol):
name: str
默认情况下,这表示一个可读写的实例属性。实现类至少应满足:
class User:
def __init__(self, name: str) -> None:
self.name = name
3. 只读属性
如果调用者只能读取属性,应使用属性描述符表达这一点:
from typing import Protocol
class HasName(Protocol):
@property
def name(self) -> str:
...
下面的实现可以把名称计算出来:
class User:
def __init__(self, first_name: str, last_name: str) -> None:
self.first_name = first_name
self.last_name = last_name
@property
def name(self) -> str:
return f"{self.first_name} {self.last_name}"
如果 Protocol 把 name 声明成普通可写属性,而实现类只提供只读 property,二者的可赋值关系可能不成立,因为调用者被允许执行:
value.name = "new name"
接口设计必须表达真实的读写权限,而不能只表达属性名称。Protocol 规范区分了普通变量、ClassVar 和只读属性。(typing.python.org)
4. 类属性
from typing import ClassVar, Protocol
class Plugin(Protocol):
plugin_name: ClassVar[str]
def run(self) -> None:
...
ClassVar[str] 表示该成员属于类,而不是每个实例独立拥有的实例属性。若把类级状态误写成实例属性,类型检查器可能允许调用者通过实例访问或修改它,从而扩大接口契约。
十一、泛型 Protocol 与方差
Protocol 可以是泛型。Python 3.14 推荐使用参数化类语法:
from typing import Protocol
class Producer[T](Protocol):
def produce(self) -> T:
...
1. 返回值产生协变
如果一个对象只产生 T,通常可以把它设计为协变:
from typing import Protocol, TypeVar
T_co = TypeVar("T_co", covariant=True)
class Producer(Protocol[T_co]):
def produce(self) -> T_co:
...
若 int 是 float 的子类型关系,生产 int 的对象可以作为生产 float 的对象使用:
class IntProducer:
def produce(self) -> int:
return 42
def read_float(producer: Producer[float]) -> float:
return producer.produce()
print(read_float(IntProducer()))
直觉是:调用者只读取结果,不会向生产者写入一个任意的 float。因此,结果类型可以更具体。
2. 参数消费逆变
如果对象只消费 T,通常可以设计为逆变:
T_contra = TypeVar("T_contra", contravariant=True)
class Consumer(Protocol[T_contra]):
def consume(self, value: T_contra) -> None:
...
一个能够消费任意 object 的对象,也能够消费 str:
class AnyConsumer:
def consume(self, value: object) -> None:
print(value)
def send_text(consumer: Consumer[str]) -> None:
consumer.consume("hello")
send_text(AnyConsumer())
直觉是:目标函数只会给消费者 str,而能接受任意 object 的消费者能力更强。
3. 可变属性导致不变
如果 Protocol 暴露可读写属性,类型参数通常必须保持不变:
class Box[T](Protocol):
value: T
假设错误地允许:
那么调用者可能通过 Box[float] 写入一个浮点值:
box.value = 3.14
原本只允许整数的 Box[int] 就被破坏了。因此,读写属性必须同时满足生产和消费约束,通常表现为不变。官方协议规范也明确以可变属性说明了这一点。(typing.python.org)
如果只需要读取,应改成只读属性或方法:
class ReadOnlyBox[T_co](Protocol[T_co]):
@property
def value(self) -> T_co:
...
十二、Protocol 与 collections.abc 的关系
Python 中已经有许多可用于类型标注的抽象集合类型:
from collections.abc import Iterable, Iterator, Sequence
def total(values: Iterable[int]) -> int:
return sum(values)
这些类型本身通常具有结构化或虚拟子类语义。例如,一个对象实现了迭代协议,就可能被识别为 Iterable。
但不要把“Protocol”简单理解成“只能从 typing 导入的类”。类型系统中的 Protocol 可以与 collections.abc 中的抽象接口组合:
from collections.abc import Iterable
from typing import Protocol
class NumberStream(Iterable[int], Protocol):
pass
这里的接口要求来自 Iterable[int]。对于代码设计,通常优先使用已有的 collections.abc 抽象类型;只有当领域接口无法由现成抽象描述时,才定义自己的 Protocol。
十三、@runtime_checkable:有限的运行时结构检查
1. 默认不能用于 isinstance()
普通 Protocol 主要服务于静态检查:
from typing import Protocol
class Closable(Protocol):
def close(self) -> None:
...
class Resource:
def close(self) -> None:
pass
resource = Resource()
try:
print(isinstance(resource, Closable))
except TypeError as exc:
print(type(exc).__name__)
预期输出:
TypeError
Protocol 默认不参与 isinstance() 和 issubclass() 的第二参数检查。
2. 用 @runtime_checkable 显式开启
from typing import Protocol, runtime_checkable
@runtime_checkable
class Closable(Protocol):
def close(self) -> None:
...
class Resource:
def close(self) -> None:
print("closed")
print(isinstance(Resource(), Closable))
输出:
True
但是,这个检查只检查成员是否存在,不检查类型签名:
@runtime_checkable
class BadClosable(Protocol):
def close(self, reason: int) -> bytes:
...
class WrongResource:
def close(self) -> None:
pass
print(isinstance(WrongResource(), BadClosable))
它可能输出:
True
因为运行时只确认 close 这个属性存在,并不会验证:
- 是否需要一个
reason参数; - 返回值是否为
bytes; - 注解是否存在;
- 注解是否正确。
Python 3.14 文档明确指出,@runtime_checkable 只检查属性存在性,不检查签名和属性类型。(docs.python.org)
因此,下面两种检查的含义完全不同:
isinstance(value, Closable)
表示:
运行时静态地看,
value上是否存在名为close的成员。
而静态类型检查器检查的是:
value的close是否以兼容的参数和返回类型满足 Protocol。
3. 数据 Protocol 与 issubclass()
只包含方法成员的 Protocol 称为非数据 Protocol:
@runtime_checkable
class Closer(Protocol):
def close(self) -> None:
...
包含实例属性的 Protocol 是数据 Protocol:
@runtime_checkable
class Named(Protocol):
name: str
isinstance() 可以用于两者,但 issubclass() 只能安全用于非数据 Protocol。原因是实例属性可能在构造函数中动态建立,单靠类对象无法可靠判断。(typing.python.org)
4. Python 3.12+ 的运行时行为变化
在 Python 3.12 及之后,运行时 Protocol 检查使用 inspect.getattr_static() 查找成员,而不是以前的 hasattr();同时,Protocol 创建后,其运行时成员集合被冻结。对 Protocol 进行 monkey patch 不会改变后续 isinstance() 检查使用的成员集合。(docs.python.org)
此外,运行时 Protocol 检查可能明显慢于普通类的 isinstance()。在高频路径中,如果只需要检查一个成员,直接使用经过设计的 hasattr() 或显式适配逻辑可能更合适。(docs.python.org)
十四、一个完整的接口设计示例:通知发送器
下面用一个通知系统比较 ABC 和 Protocol 的职责。
1. 用 Protocol 描述调用方真正需要的能力
from typing import Protocol
class MessageSender(Protocol):
def send(self, recipient: str, body: str) -> None:
...
def notify_user(sender: MessageSender, user: str) -> None:
sender.send(user, "Your order is ready")
调用方不关心发送器属于哪个类层次,只关心它能否发送消息。
class EmailSender:
def send(self, recipient: str, body: str) -> None:
print(f"email -> {recipient}: {body}")
class SmsSender:
def send(self, recipient: str, body: str) -> None:
print(f"sms -> {recipient}: {body}")
notify_user(EmailSender(), "alice@example.com")
notify_user(SmsSender(), "+8613800000000")
EmailSender 和 SmsSender 都没有继承 MessageSender,但在静态类型上都满足这个 Protocol。
2. 用 ABC 表达带生命周期和共享流程的实现骨架
如果所有发送器都必须遵循统一生命周期,就更适合使用 ABC:
from abc import ABC, abstractmethod
class ManagedSender(ABC):
def __enter__(self):
self.open()
return self
def __exit__(self, exc_type, exc_value, traceback):
self.close()
@abstractmethod
def open(self) -> None:
raise NotImplementedError
@abstractmethod
def send(self, recipient: str, body: str) -> None:
raise NotImplementedError
@abstractmethod
def close(self) -> None:
raise NotImplementedError
具体实现:
class ConsoleSender(ManagedSender):
def open(self) -> None:
print("open")
def send(self, recipient: str, body: str) -> None:
print(f"{recipient}: {body}")
def close(self) -> None:
print("close")
使用:
with ConsoleSender() as sender:
sender.send("alice", "ready")
输出:
open
alice: ready
close
这里 ABC 的价值不只是“有一个 send() 方法”,而是:
- 强制实现
open()、send()、close(); - 提供统一的上下文管理流程;
- 将资源生命周期固定在基类中;
- 允许未来在基类中加入通用错误处理和日志。
如果一个第三方类已经有兼容的 send() 方法,但没有 open() 和 close(),它可以满足 MessageSender,却不能直接满足 ManagedSender。这正是两个接口的语义边界。
十五、用适配器连接外部类,而不是强迫外部类继承
当外部库的接口与内部 Protocol 不完全一致时,适配器通常比继承更清晰。
from typing import Protocol
class MessageSender(Protocol):
def send(self, recipient: str, body: str) -> None:
...
class ThirdPartyClient:
def publish(self, topic: str, content: str) -> None:
print(f"publish {topic}: {content}")
class ClientAdapter:
def __init__(self, client: ThirdPartyClient) -> None:
self.client = client
def send(self, recipient: str, body: str) -> None:
self.client.publish(recipient, body)
def notify_user(sender: MessageSender, user: str) -> None:
sender.send(user, "ready")
notify_user(ClientAdapter(ThirdPartyClient()), "orders")
这里的因果关系是:
- 业务函数要求
send(); - 第三方对象只有
publish(); - 适配器把
send()映射为publish(); - 业务层不需要知道第三方库的命名和生命周期。
如果直接让业务函数接受 ThirdPartyClient,业务代码就会与具体库绑定;如果试图让第三方类继承内部 ABC,又通常无法修改第三方类。Protocol 描述的是业务方需要的最小能力,适配器负责把外部能力转换成这个结构。
十六、接口应尽量小:Protocol 的成员越多,耦合越强
考虑两个接口:
class LargeRepository(Protocol):
def get(self, key: str) -> bytes | None:
...
def put(self, key: str, value: bytes) -> None:
...
def delete(self, key: str) -> None:
...
def flush(self) -> None:
...
class ReaderRepository(Protocol):
def get(self, key: str) -> bytes | None:
...
一个只读取数据的函数应依赖 ReaderRepository:
def load_config(repository: ReaderRepository, key: str) -> bytes | None:
return repository.get(key)
如果它声明为 LargeRepository,调用者就会被迫依赖写入、删除和刷新能力。结构上虽然仍可能传入完整仓库,但接口表达了过多要求,导致:
- mock 更复杂;
- 实现类必须暴露更多成员;
- 未来修改无关成员时影响更多调用者;
- 接口无法准确表达实际依赖。
这不是“Protocol 一定比 ABC 好”,而是接口粒度应该由调用者真实使用的能力决定。ABC 同样应该避免设计成包含所有操作的巨大基类。
十七、交集、并集与类型收窄
Protocol 可以参与联合类型:
from typing import Protocol
class Exitable(Protocol):
def exit(self) -> int:
...
class Quittable(Protocol):
def quit(self) -> int | None:
...
def finish(task: Exitable | Quittable) -> int:
if isinstance(task, Exitable):
return task.exit()
result = task.quit()
return 0 if result is None else result
不过,这段代码要在运行时使用 isinstance(task, Exitable),就必须让 Exitable 添加 @runtime_checkable。更稳妥的写法是显式区分业务状态,或者使用普通属性标签进行 narrowing,而不是依赖一个只检查属性存在的运行时 Protocol。
Protocol 的多重继承可以表达交集:
from collections.abc import Hashable, Iterable
from typing import Protocol
class HashableFloats(Iterable[float], Hashable, Protocol):
pass
它表达:
也就是说,候选类型必须同时满足可迭代浮点值和可哈希两个条件。协议规范将多个 Protocol 的多重继承作为交集表达方式。(typing.python.org)
十八、类型对象 type[Protocol] 的边界
实例参数和类对象参数不是一回事。
from abc import ABC, abstractmethod
from typing import Protocol
class Handler(Protocol):
@abstractmethod
def handle(self, value: str) -> int:
...
class ConcreteHandler:
def handle(self, value: str) -> int:
return len(value)
def build_handler(cls: type[Handler]) -> Handler:
return cls()
build_handler(ConcreteHandler) 可以通过静态检查,因为 ConcreteHandler 是一个可实例化的、满足 Handler 的具体类。
但不能传入 Protocol 自身:
build_handler(Handler) # 静态类型错误
原因是 type[Handler] 的调用者要执行 cls();Protocol 不能被当作具体实现类实例化。类型规范明确规定,type[Proto] 接受满足该 Protocol 的具体非 Protocol 类,而不是 Protocol 类本身。(typing.python.org)
这个边界经常出现在插件注册、依赖注入和工厂函数中:
class Plugin(Protocol):
def run(self) -> None:
...
def create_plugin(cls: type[Plugin]) -> Plugin:
return cls()
这里还隐含了一个额外前提:实现类必须能以无参数方式构造。如果构造函数需要参数,就应把构造参数也纳入工厂接口,而不能只写 type[Plugin]。
十九、常见误解与失败表现
误解一:Protocol 会在运行时验证参数类型
错误:
@runtime_checkable
class Calculator(Protocol):
def add(self, x: int, y: int) -> int:
...
class BadCalculator:
def add(self, x: str, y: str) -> str:
return x + y
print(isinstance(BadCalculator(), Calculator))
即使输出为 True,也只代表存在 add 属性,不代表参数和返回值正确。真正的签名检查依赖静态类型检查器。
误解二:ABC 注册后,类获得 ABC 的方法
错误理解:
class Base:
def helper(self) -> str:
return "helper"
class External:
pass
Base.register(External) # Base 不是 ABC,且也不会注入方法
只有 ABCMeta 管理的 ABC 才有虚拟子类注册机制;即使注册成功,也不会把基类方法复制到外部类中。(docs.python.org)
误解三:同名属性一定兼容
class ConfigProtocol(Protocol):
timeout: int
class BadConfig:
timeout = "30"
这里属性名称相同,但类型不同。若代码执行:
def connect(config: ConfigProtocol) -> None:
print(config.timeout + 1)
BadConfig 会导致运行时错误。结构化类型要求成员类型兼容,而不是只看名字。
误解四:继承 Protocol 后,所有子类都会自动成为 Protocol
class BaseProtocol(Protocol):
def run(self) -> None:
...
class OrdinaryChild(BaseProtocol):
pass
OrdinaryChild 不是自动开放给结构化实现的 Protocol。若要定义子协议,应显式包含 Protocol:
class ExtendedProtocol(BaseProtocol, Protocol):
def stop(self) -> None:
...
这是为了防止普通继承意外改变类型系统语义。(typing.python.org)
误解五:运行时 isinstance() 成功就说明业务契约满足
一个对象可能拥有名为 close 的字段,但这个字段:
- 可能是整数;
- 可能是不可调用对象;
- 可能需要不同参数;
- 可能具备完全不同的业务语义。
因此,@runtime_checkable 适合做轻量能力探测,不适合代替完整的输入验证、协议握手或业务健康检查。
二十、如何选择:按“需要表达的事实”决策
可以按以下因果顺序选择机制。
选择 ABC,当你需要:
- 明确的类型分类;
- 继承关系和
isinstance()语义; - 抽象方法未实现时禁止实例化;
- 基类提供共享实现;
- 统一生命周期或模板流程;
- 使用
register()或__subclasshook__()建立受控运行时分类。
例如:
from abc import ABC, abstractmethod
class Storage(ABC):
@abstractmethod
def read(self, key: str) -> bytes:
raise NotImplementedError
def read_text(self, key: str) -> str:
return self.read(key).decode()
选择 Protocol,当你需要:
- 描述调用方真正需要的最小能力;
- 接受来自不同继承体系的对象;
- 为第三方类、内置类或测试替身提供接口;
- 降低业务代码对具体实现的依赖;
- 让静态类型检查器验证结构和签名;
- 描述函数对象、回调、资源能力或外部服务客户端。
例如:
from typing import Protocol
class Clock(Protocol):
def now(self) -> float:
...
def measure(clock: Clock) -> float:
return clock.now()
两者可以同时使用
ABC 和 Protocol 不是互斥替代品:
from abc import ABC, abstractmethod
from typing import Protocol
class Readable(Protocol):
def read(self) -> bytes:
...
class ManagedReadable(ABC):
@abstractmethod
def open(self) -> None:
raise NotImplementedError
@abstractmethod
def close(self) -> None:
raise NotImplementedError
@abstractmethod
def read(self) -> bytes:
raise NotImplementedError
业务函数可以只依赖 Readable,资源管理组件则依赖 ManagedReadable。前者描述使用能力,后者描述实现生命周期。把两个需求拆开,接口关系会比单一巨大基类更准确。
二十一、诊断接口问题的顺序
当类型检查失败或运行时出现接口错误时,可以按以下顺序定位。
第一步:确认目标是静态关系还是运行时关系
如果问题出现在类型检查器报告中,检查:
- 成员是否缺失;
- 参数类型是否兼容;
- 返回类型是否兼容;
- 属性是否应为读写或只读;
- 泛型参数的方差是否正确。
如果问题出现在 isinstance() 中,检查:
- 目标是否是 ABC;
- Protocol 是否添加了
@runtime_checkable; - Protocol 是否为参数化泛型;
- 是否误以为运行时会检查签名。
第二步:检查成员的完整类型
例如:
class Expected(Protocol):
def transform(self, value: str) -> bytes:
...
class Actual:
def transform(self, value: object) -> str:
return str(value)
方法名一致,但返回类型不兼容。不要只对照成员名,应逐项对照:
第三步:确认是否需要共享实现
如果调用者只需要调用方法,Protocol 通常足够。
如果接口还要求:
- 初始化顺序;
- 资源打开和关闭;
- 模板方法;
- 基类辅助函数;
- 抽象类不能被实例化;
那么仅定义 Protocol 不会自动提供这些运行时行为,应考虑 ABC 或显式适配器。
第四步:检查边界值和异常路径
接口不仅由方法签名组成,还包括方法行为。例如:
class Cache(Protocol):
def get(self, key: str) -> bytes | None:
...
这里 None 可能表示缓存未命中。如果某个实现改为未命中时抛出 KeyError,即使签名仍然兼容,业务语义也可能已经不兼容。
因此,Protocol 能表达的是结构和类型,不能自动表达所有行为契约。超出类型系统的部分仍需要文档、测试、运行时校验或更高层的领域封装。
二十二、结语:接口是关系,不只是方法列表
ABC 和 Protocol 的区别可以归纳为两个问题:
ABC 以名义关系为中心,强调类型身份、继承、抽象性、共享实现和运行时分类。Protocol 以结构关系为中心,强调成员集合、签名兼容、低耦合和静态鸭子类型。
工程中的接口设计通常可以遵循这样的分层:
- 对外部依赖和调用方需求,优先描述最小 Protocol;
- 对内部实现骨架、资源生命周期和共享行为,使用 ABC;
- 对第三方类与内部接口之间的差异,使用适配器;
- 对运行时输入,不能把
@runtime_checkable当成完整验证器; - 对行为语义、错误条件和生命周期,不能只依赖类型签名。
当“类型身份”和“可用能力”被分别建模,Python 的继承体系、静态类型系统和运行时行为就不会互相替代,也更容易形成稳定的接口边界。
系列导航与关联阅读
- 系列入口:Python 完整学习路线:从语言模型、并发到 Web、数据、AI 与生产交付
- 上一篇:Python 描述符与 property:属性访问、绑定方法和 ORM 基础
- 下一篇:Python dataclass 与 Enum:数据模型、不可变性、比较和序列化
- 延伸:Python 类型标注基础:Union、Literal、TypedDict、Narrowing 与边界
- 延伸:Python 类与继承:实例、类属性、组合、覆盖和边界
官方资料
本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论