Python 基础体系 · 第 26/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python ABC 与 Protocol:名义子类型、结构化类型和接口设计

在 Python 中,“接口”不是单一机制。abc.ABC@abstractmethodregister()__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. 静态类型系统中的可赋值关系

静态类型检查器通常不执行函数,而是判断一个表达式的类型能否赋给另一个类型。

可以把这种关系写成:

XTX \preceq T

其中:

  • XX 表示表达式或对象的静态类型;
  • TT 表示变量、参数或返回值声明的目标类型;
  • XTX \preceq T 表示“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):
    ...

那么可以写成:

DnominalBD \leq_{\text{nominal}} 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()

JsonSerializerSerializer 的名义子类型:

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. ABCABCMeta

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

这里有两个独立事实:

  1. IncompleteGateway 仍然可以定义;
  2. 因为抽象方法未实现,它不能被实例化。

@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__() 必须返回 TrueFalseNotImplemented

  • True:把候选类视为子类;
  • False:拒绝其子类资格;
  • NotImplemented:交给正常的继承和注册逻辑继续判断。

它可以让 ABC 具有某种结构化判断能力,但这种能力是由 ABC 作者手动编写的,并不是完整的静态结构类型系统。官方示例也特别说明,__subclasshook__() 只改变子类判断,不会把 ABC 的其他方法添加到候选类上。(docs.python.org)


六、结构化类型:类型身份来自成员集合

1. 什么是结构化类型

结构化类型(structural typing)不要求类显式继承接口。它只要求类型具备目标接口规定的成员,并且这些成员的类型满足可赋值条件。

设协议 PP 的成员集合为:

M(P)={m1,m2,,mn}M(P) = \{m_1, m_2, \ldots, m_n\}

一个具体类型 XX 满足协议 PP,当且仅当:

XP    mM(P),typeX(m)typeP(m)X \models P \iff \forall m \in M(P),\quad \operatorname{type}_X(m) \preceq \operatorname{type}_P(m)

直觉上:

  1. 找到协议要求的每个成员;
  2. 检查具体类型是否提供该成员;
  3. 检查方法参数、返回值和属性类型是否兼容;
  4. 全部成立时,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>"

显式继承的意义主要有两个:

  1. 子类可以复用 Protocol 中的默认实现;
  2. 类型检查器可以直接检查该类是否正确实现了 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

多重继承在这里表达的是接口交集:

M(SizedAndClosable)=M(Sized)M(SupportsClose)M(\text{SizedAndClosable}) = M(\text{Sized}) \cup M(\text{SupportsClose})

因此,一个类型必须同时满足两个协议。(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:
        ...

intfloat 的子类型关系,生产 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[int]Box[float]Box[int] \leq Box[float]

那么调用者可能通过 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 的成员。

而静态类型检查器检查的是:

valueclose 是否以兼容的参数和返回类型满足 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")

EmailSenderSmsSender 都没有继承 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")

这里的因果关系是:

  1. 业务函数要求 send()
  2. 第三方对象只有 publish()
  3. 适配器把 send() 映射为 publish()
  4. 业务层不需要知道第三方库的命名和生命周期。

如果直接让业务函数接受 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

它表达:

HashableFloats=Iterable[float]HashableHashableFloats = Iterable[float] \cap Hashable

也就是说,候选类型必须同时满足可迭代浮点值和可哈希两个条件。协议规范将多个 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,当你需要:

  1. 明确的类型分类;
  2. 继承关系和 isinstance() 语义;
  3. 抽象方法未实现时禁止实例化;
  4. 基类提供共享实现;
  5. 统一生命周期或模板流程;
  6. 使用 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,当你需要:

  1. 描述调用方真正需要的最小能力;
  2. 接受来自不同继承体系的对象;
  3. 为第三方类、内置类或测试替身提供接口;
  4. 降低业务代码对具体实现的依赖;
  5. 让静态类型检查器验证结构和签名;
  6. 描述函数对象、回调、资源能力或外部服务客户端。

例如:

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)

方法名一致,但返回类型不兼容。不要只对照成员名,应逐项对照:

(参数类型,返回类型,属性可变性)(\text{参数类型}, \text{返回类型}, \text{属性可变性})

第三步:确认是否需要共享实现

如果调用者只需要调用方法,Protocol 通常足够。

如果接口还要求:

  • 初始化顺序;
  • 资源打开和关闭;
  • 模板方法;
  • 基类辅助函数;
  • 抽象类不能被实例化;

那么仅定义 Protocol 不会自动提供这些运行时行为,应考虑 ABC 或显式适配器。

第四步:检查边界值和异常路径

接口不仅由方法签名组成,还包括方法行为。例如:

class Cache(Protocol):
    def get(self, key: str) -> bytes | None:
        ...

这里 None 可能表示缓存未命中。如果某个实现改为未命中时抛出 KeyError,即使签名仍然兼容,业务语义也可能已经不兼容。

因此,Protocol 能表达的是结构和类型,不能自动表达所有行为契约。超出类型系统的部分仍需要文档、测试、运行时校验或更高层的领域封装。


二十二、结语:接口是关系,不只是方法列表

ABC 和 Protocol 的区别可以归纳为两个问题:

ABC:这个类是否被声明为某类对象?\text{ABC:这个类是否被声明为某类对象?}

Protocol:这个对象是否具备调用者需要的结构?\text{Protocol:这个对象是否具备调用者需要的结构?}

ABC 以名义关系为中心,强调类型身份、继承、抽象性、共享实现和运行时分类。Protocol 以结构关系为中心,强调成员集合、签名兼容、低耦合和静态鸭子类型。

工程中的接口设计通常可以遵循这样的分层:

  • 对外部依赖和调用方需求,优先描述最小 Protocol;
  • 对内部实现骨架、资源生命周期和共享行为,使用 ABC;
  • 对第三方类与内部接口之间的差异,使用适配器;
  • 对运行时输入,不能把 @runtime_checkable 当成完整验证器;
  • 对行为语义、错误条件和生命周期,不能只依赖类型签名。

当“类型身份”和“可用能力”被分别建模,Python 的继承体系、静态类型系统和运行时行为就不会互相替代,也更容易形成稳定的接口边界。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。