Python 类型系统:Protocol 与 ABC 到底怎么选?从结构化子类型到领域接口的工程实践

Python 类型系统第 :Protocol 与 ABC 到底怎么选?从结构化子类型到领域接口的工程实践

Python 有一种非常独特的气质。

它从诞生之初就没有要求程序员把所有对象塞进严格的继承树里。很多时候,我们并不关心一个对象“到底是什么类”,而只关心:

它能不能完成我要它完成的事情?

这也是 Python 著名的 Duck Typing 思想:

If it walks like a duck and quacks like a duck, then it is a duck.

如果一个对象能够:

read()

我们就把它当成可读对象。

如果它能够:

write(...)

我们就把它当成可写对象。

如果它能够:

__iter__()

我们就把它当成可迭代对象。

这种灵活性让 Python 成为了 Web 开发、自动化、数据处理、人工智能以及系统集成领域极具生命力的“胶水语言”。

但当项目从:

3 个文件

增长到:

300 个模块

甚至:

几十万行代码

我们自然会希望 IDE 和静态类型检查器也能理解这些 Duck Typing 契约。

于是问题出现了:

如果我定义:

from typing import Protocol


class Writable(Protocol):
    def write(self, data: bytes) -> int:
        ...

那么下面这个类:

class FileWriter:
    def write(self, data: bytes) -> int:
        print(f"write {len(data)} bytes")
        return len(data)

没有继承 Writable

class FileWriter:
    ...

而不是:

class FileWriter(Writable):
    ...

那么它到底算不算一个:

Writable

答案是:

算。

只要它的结构满足 Writable 所要求的成员和类型签名,静态类型检查器就可以把它视为 Writable 的实现。

Python 官方类型系统把这种机制称为:

Structural Subtyping——结构化子类型。

这也正是 Protocol 与传统 ABC 最大的思想分界。Python 类型系统规范明确指出,结构类型通过对象拥有的属性、方法等结构来判断兼容性,而不要求存在继承关系;Protocol 正是 Python 中最重要的结构类型机制之一。(Python Typing)

而理解这一点,几乎就是理解 Protocol 的全部入口。


一、先用一个例子彻底看懂 Protocol

假设我们正在写一个日志系统。

日志最终可能写到:

文件
内存
Socket
云存储
测试桩
压缩流

核心业务其实只需要一个能力:

write(data: bytes) -> int

于是定义:

from typing import Protocol


class Writable(Protocol):
    def write(self, data: bytes) -> int:
        ...

现在实现一个文件 Writer:

class FileWriter:
    def write(self, data: bytes) -> int:
        print(f"writing {len(data)} bytes to file")
        return len(data)

再实现一个内存 Writer:

class MemoryWriter:
    def __init__(self) -> None:
        self.buffer = bytearray()

    def write(self, data: bytes) -> int:
        self.buffer.extend(data)
        return len(data)

注意:

FileWriter

没有继承:

Writable

MemoryWriter 也没有。

但我们可以写:

def save_report(
    output: Writable,
    data: bytes,
) -> None:
    output.write(data)

然后:

save_report(
    FileWriter(),
    b"hello",
)

save_report(
    MemoryWriter(),
    b"hello",
)

对于支持 Protocol 的静态类型检查器来说,这两次调用都可以是合法的。

原因不是:

FileWriter IS-A Writable

而是:

FileWriter HAS write(bytes) -> int

也就是说:

class hierarchy 不重要

object structure 才重要

官方 Python 3.14 文档给出的 Protocol 示例同样说明:一个没有继承 Protocol 的普通类,只要提供兼容的方法,就可以作为该 Protocol 使用。(Python documentation)

这就是:

Static Duck Typing

静态鸭子类型。


二、传统继承到底在检查什么?

为了理解 Protocol,我们先看普通类。

class Animal:
    pass


class Cat(Animal):
    pass

这里:

Cat <: Animal

成立的理由是:

Cat

明确继承了:

Animal

这叫:

Nominal Subtyping

即:

名义子类型。

判断关系的重点是:

“你是谁?”

或者更准确地说:

“你的类继承关系是什么?”

所以:

class Cat(Animal):
    ...

成立。

但是:

class RobotCat:
    ...

哪怕它具有与 Animal 完全一样的方法,只要:

RobotCat

没有继承:

Animal

传统名义类型系统依然不会因为“长得一样”就把它视为 Animal


三、Protocol 检查的不是“你是谁”,而是“你会什么”

现在换成:

from typing import Protocol


class Speaker(Protocol):
    def speak(self) -> str:
        ...

然后:

class Cat:
    def speak(self) -> str:
        return "meow"


class Robot:
    def speak(self) -> str:
        return "beep"


class Person:
    def speak(self) -> str:
        return "hello"

它们彼此完全没有继承关系:

Cat

Robot

Person

但三者都满足:

Speaker

因为:

          ┌── Cat
          │    └── speak() -> str
          │
Speaker ──┼── Robot
          │    └── speak() -> str
          │
          └── Person
               └── speak() -> str

因此:

def announce(speaker: Speaker) -> None:
    print(speaker.speak())

下面都可以:

announce(Cat())
announce(Robot())
announce(Person())

这就是结构化类型最大的魅力:

接口的兼容关系不再依赖继承树,而依赖真实能力。


四、那么 ABC 是什么?

来看传统 Abstract Base Class:

from abc import ABC, abstractmethod


class Writable(ABC):

    @abstractmethod
    def write(
        self,
        data: bytes,
    ) -> int:
        ...

实现类通常需要明确继承:

class FileWriter(Writable):

    def write(
        self,
        data: bytes,
    ) -> int:
        print("write file")
        return len(data)

这里表达的是:

FileWriter
    ↓
明确属于
    ↓
Writable hierarchy

而不只是:

FileWriter 恰好有 write()

这就是一个非常重要的区别。

我们可以先粗略地概括:

Protocol

“只要满足这个结构,
你就可以作为这个类型使用。”

而:

ABC

“你需要明确加入这个抽象体系。”

虽然 ABC 本身还支持 register()__subclasshook__() 等更灵活的运行时机制,但普通用户自定义 ABC 的核心模型仍然更偏向 nominal hierarchy。Python 的 abc 模块允许直接继承、注册 virtual subclass,以及通过 __subclasshook__() 自定义运行时子类判断。(Python documentation)


五、一个没有继承 Writable 的类,究竟什么时候满足 Protocol?

假设:

class Writable(Protocol):

    def write(
        self,
        data: bytes,
    ) -> int:
        ...

下面满足:

class A:

    def write(
        self,
        data: bytes,
    ) -> int:
        return len(data)

因为方法结构兼容。

但是:

class B:

    def save(
        self,
        data: bytes,
    ) -> int:
        return len(data)

不满足。

原因不是:

B 没有继承 Writable

而是:

B 没有 write()

下面也可能不满足:

class C:

    def write(
        self,
        data: str,
    ) -> int:
        return len(data)

因为 Protocol 要求:

bytes

而这里定义的是:

str

同理:

class D:

    def write(
        self,
        data: bytes,
    ) -> str:
        return str(len(data))

返回值也与:

-> int

不兼容。

所以 Protocol 并不是简单的:

hasattr(obj, "write")

静态类型检查器真正检查的是:

member exists
+
member type compatible
+
call signature compatible

这也是:

static structural typing

与普通运行时 Duck Typing 的重要区别。


六、Structural Typing 最大的优势:解耦

现在假设我们正在写一个库:

def export(
    destination: Writable,
    payload: bytes,
) -> None:
    destination.write(payload)

使用 ABC 时,你可能要求所有调用方:

class MyOutput(Writable):
    ...

问题来了。

如果用户手里已经有:

io.BytesIO

或者第三方 SDK 对象:

ThirdPartyStorage

或者遗留系统中的:

LegacyWriter

它们本来就已经拥有:

write(bytes) -> int

你真的应该要求:

“请修改那个类,让它继承我这个接口。”

很多时候根本做不到。

尤其是第三方类:

你不能修改源码。

这时 Protocol 极其优雅:

第三方对象
      │
      │ 不认识你的 Protocol
      │
      ▼
具有兼容的 write()
      │
      ▼
自动满足 Writable

双方甚至:

不需要知道彼此存在。

这就是结构化类型最强大的地方之一:

Decoupling

解耦。


七、Protocol 对第三方库尤其友好

设想你开发一个图像处理库。

只需要一个对象能够:

read(size: int) -> bytes

你可以定义:

class Readable(Protocol):

    def read(
        self,
        size: int,
    ) -> bytes:
        ...

你的函数:

def decode_image(
    stream: Readable,
) -> None:
    header = stream.read(16)

于是:

File
BytesIO
SocketWrapper
CloudObject
TestStream

只要拥有兼容接口,都可以直接接入。

不要求:

继承你的类

不要求:

register

不要求:

adapter

这对于 library boundary 极其重要。

Protocol 让库作者可以表达:

“我只要求调用方拥有这一小部分能力。”

而不是:

“调用方必须进入我的类型体系。”

这就是低耦合 API 非常理想的形态。


八、Protocol 让接口遵循“最小能力原则”

假设一个函数只需要:

read()

却声明:

def parse(
    file: MyHugeFileClass,
) -> Result:
    ...

这意味着调用方必须拥有:

read
write
seek
close
flush
metadata
compress
upload
download
...

尽管函数实际上只使用:

read

这会产生:

API over-specification

即:

接口要求过度。

Protocol 可以把契约缩到真正需要的能力:

class Reader(Protocol):

    def read(
        self,
        size: int,
    ) -> bytes:
        ...

然后:

def parse(
    source: Reader,
) -> Result:
    ...

现在依赖关系变成:

parse
  │
  ▼
Reader
  │
  └── 只要求 read()

这和 SOLID 中的:

Interface Segregation Principle

非常契合。

接口越小:

适配者越多
耦合越低
测试越简单
复用能力越强

九、Protocol 对测试尤其舒服

来看一个真实项目中很常见的依赖。

class EmailSender(Protocol):

    def send(
        self,
        address: str,
        content: str,
    ) -> None:
        ...

生产环境:

class SmtpSender:

    def send(
        self,
        address: str,
        content: str,
    ) -> None:
        print("sending SMTP mail")

业务:

def register_user(
    sender: EmailSender,
    email: str,
) -> None:

    # create user...

    sender.send(
        email,
        "Welcome!",
    )

测试时完全不需要继承任何东西:

class FakeSender:

    def __init__(self) -> None:
        self.messages: list[
            tuple[str, str]
        ] = []

    def send(
        self,
        address: str,
        content: str,
    ) -> None:
        self.messages.append(
            (address, content)
        )

测试:

fake = FakeSender()

register_user(
    fake,
    "alice@example.com",
)

assert fake.messages == [
    (
        "alice@example.com",
        "Welcome!",
    )
]

这就是一个非常漂亮的依赖反转:

Business Logic
      ↓
EmailSender Protocol
      ↑
 ┌────┴────┐
 │         │
SMTP      Fake

生产代码和测试代码:

都只满足结构

而不需要建立人为继承关系。


十、那么是不是 Protocol 永远比 ABC 好?

不是。

这是学习 Protocol 后很容易走向的另一个极端:

“既然结构化类型这么灵活,以后全部 Protocol。”

并不合适。

Protocol 和 ABC 解决的是两类不同的问题。

最重要的判断不是:

哪个技术更新?

而是:

这个接口表达的是一种“能力”,还是一种“领域身份与契约”?

这句话非常重要。


十一、“能力型接口”通常更适合 Protocol

下面这些名字,通常天然像 Protocol:

Readable
Writable
Closable
Serializable
Comparable
Renderable
Logger
Notifier
Handler
Callback
SupportsXYZ

它们都在描述:

“这个对象会做什么?”

例如:

class Closable(Protocol):

    def close(self) -> None:
        ...

我们不关心对象到底是:

File
Socket
DatabaseConnection
Browser
Camera
Session

只需要:

close()

即可。

这种接口:

Protocol 非常自然。


十二、“领域身份型接口”很多时候 ABC 更自然

假设我们设计支付系统。

class PaymentMethod(ABC):

    @abstractmethod
    def authorize(
        self,
        amount: Money,
    ) -> Authorization:
        ...

    @abstractmethod
    def capture(
        self,
        authorization: Authorization,
    ) -> Receipt:
        ...

这时我们可能不是简单地表达:

“有 authorize 和 capture 就行。”

而是在表达:

这是系统认可的一种 PaymentMethod。

这是一种:

domain identity

领域身份。

我们希望:

class CreditCardPayment(
    PaymentMethod
):
    ...

明确声明:

我是 PaymentMethod

而不是让一个偶然拥有:

authorize()
capture()

的方法组合的对象自动混进来。

这时候:

ABC 往往更加合适。


十三、ABC 可以表达“你必须主动加入这个领域”

再看一个订单系统。

class OrderPolicy(ABC):

    @abstractmethod
    def validate(
        self,
        order: Order,
    ) -> None:
        ...

实现:

class RetailOrderPolicy(
    OrderPolicy
):

    def validate(
        self,
        order: Order,
    ) -> None:
        ...

这个继承关系本身就在传递重要信息:

RetailOrderPolicy
      IS-A
OrderPolicy

开发者看到类声明就知道:

这是订单规则体系的一部分

这种显式归属关系本身就是领域模型的一部分。

如果全部使用 Protocol:

class SomethingRandom:

    def validate(
        self,
        order: Order,
    ) -> None:
        ...

它也可能结构上满足:

OrderPolicy

有时这是灵活性。

有时却是:

过度宽松。

所以:

当“属于这个体系”本身有业务语义时,nominal typing 往往更有价值。


十四、ABC 可以阻止不完整实现被实例化

这是 ABC 非常重要的运行时能力。

例如:

from abc import ABC, abstractmethod


class Storage(ABC):

    @abstractmethod
    def load(
        self,
        key: str,
    ) -> bytes:
        ...

    @abstractmethod
    def save(
        self,
        key: str,
        value: bytes,
    ) -> None:
        ...

然后:

class BrokenStorage(Storage):

    def load(
        self,
        key: str,
    ) -> bytes:
        return b""

它忘记实现:

save()

此时:

BrokenStorage()

不能正常实例化。

ABCMeta 配合 @abstractmethod 会在运行时阻止仍然存在未实现抽象方法的正规子类实例化。(Python documentation)

这意味着 ABC 不只是:

type checker contract

它还是:

runtime class construction contract

这在框架代码和领域模型中非常有用。


十五、ABC 还能提供共享实现

ABC 并不意味着:

所有方法都只能 ...

实际上它完全可以提供公共行为。

例如:

from abc import ABC, abstractmethod


class Serializer(ABC):

    @abstractmethod
    def serialize(
        self,
        obj: object,
    ) -> bytes:
        ...

    def save(
        self,
        obj: object,
        filename: str,
    ) -> None:

        payload = self.serialize(obj)

        with open(
            filename,
            "wb",
        ) as f:
            f.write(payload)

子类只需要负责:

serialize()

公共流程:

save()

由 ABC 提供。

例如:

class JsonSerializer(
    Serializer
):

    def serialize(
        self,
        obj: object,
    ) -> bytes:
        ...

继承在这里承担了两种作用:

接口契约
+
行为复用

这时 ABC 的存在非常自然。


十六、但不要为了“复用两个方法”就滥用 ABC

这里也必须提醒一个常见问题。

很多项目最后出现这样的继承树:

BaseService
    ↓
AbstractService
    ↓
CommonService
    ↓
BusinessService
    ↓
UserService

最后一个类想复用上面:

12 行代码

却背上了:

五层继承关系

这种时候:

composition

通常比继承更加健康。

ABC 应该表达:

真正稳定的抽象契约

而不是:

“我不知道代码放哪儿,
先扔父类。”

十七、现在聊最容易误解的 @runtime_checkable

默认情况下:

class Writable(Protocol):
    ...

主要是为了:

静态类型检查

你不能简单期待:

isinstance(
    obj,
    Writable,
)

像普通类一样工作。

如果希望 Protocol 可以参与部分运行时检查,需要:

from typing import (
    Protocol,
    runtime_checkable,
)


@runtime_checkable
class Writable(Protocol):

    def write(
        self,
        data: bytes,
    ) -> int:
        ...

然后:

writer = FileWriter()

if isinstance(
    writer,
    Writable,
):
    print("writable")

这可以工作。

但是——

这里有一个非常巨大的“但是”。

@runtime_checkable 不是运行时类型验证器。

官方文档甚至直接把这种检查描述为一种比较简单的 runtime protocol check。(Python documentation)


十八、@runtime_checkable 只检查“有没有”,不完整检查签名

考虑:

@runtime_checkable
class Writable(Protocol):

    def write(
        self,
        data: bytes,
    ) -> int:
        ...

然后定义一个奇怪的类:

class StrangeWriter:

    def write(
        self,
        x: str,
        y: int,
    ) -> dict[str, int]:
        return {}

它虽然有:

write

但签名完全不是我们想要的:

Writable:
write(bytes) -> int

实际:

StrangeWriter:
write(str, int) -> dict[str, int]

静态类型检查器有机会发现这种不兼容。

但是:

isinstance(
    StrangeWriter(),
    Writable,
)

不能被理解为:

“Python 已经证明 write 的参数和返回值完全符合 Protocol。”

Python 官方文档明确说明,@runtime_checkable 的运行时检查关注所要求成员是否存在,并不会验证这些成员的类型签名。(Python documentation)

所以:

static Protocol check

和:

runtime isinstance check

完全不是同等级别的保证。

可以粗略理解成:

静态检查:

有没有 write?
参数兼容吗?
返回值兼容吗?
整体类型契约成立吗?

而 runtime protocol check 更接近:

有没有 write?

因此绝对不要把:

isinstance(
    obj,
    Writable,
)

理解成一个完整 Schema Validator。


十九、所以不要用 runtime_checkable 验证外部不可信数据

例如你从插件系统加载了未知对象:

plugin = load_plugin()

然后:

if isinstance(
    plugin,
    Writable,
):
    plugin.write(b"hello")

这最多说明:

它看起来有相关成员

并不等于:

它保证按照你的签名工作

更不说明:

业务语义正确

例如一个插件可以拥有:

write

但:

接受的是 str
返回的是 None
调用时直接抛异常
甚至 write 只是一个奇怪属性

Protocol 的核心价值始终是:

Static Structural Typing

不是构建完整运行时契约系统。

PEP 544 本身就明确指出,Protocol 的主要目标是静态 structural subtyping,而不是提供复杂的运行时接口验证机制。(Python Enhancement Proposals (PEPs))


二十、runtime_checkable 还有性能成本

还有一个经常被忽略的问题:

isinstance(
    obj,
    SomeRuntimeProtocol,
)

可能比普通:

isinstance(
    obj,
    SomeClass,
)

慢得多。

Python 官方文档明确提醒:

runtime-checkable protocol 的 isinstance() 检查可能明显慢于普通类检查,在性能敏感代码中可以考虑其他方案。(Python documentation)

所以不要在:

for item in millions_of_items:

里面不假思索地:

isinstance(
    item,
    HugeProtocol,
)

尤其不要把它当成便宜的标签判断。


二十一、Python 3.12 以后 runtime Protocol 还有两个值得知道的变化

从 Python 3.12 开始,runtime-checkable protocol 的内部属性查找改用了:

inspect.getattr_static()

而不是过去的:

hasattr()

因此某些拥有动态属性行为的对象,在 3.12 前后可能出现不同的 isinstance() 结果。(Python documentation)

另外,从 Python 3.12 开始:

runtime Protocol 创建后,其用于运行时检查的成员集合会被冻结。

也就是说:

@runtime_checkable
class P(Protocol):
    def foo(self) -> None:
        ...

类创建后再 monkey patch:

P.bar = ...

不会简单地把:

bar

加入后续 isinstance() 所依据的 Protocol 成员集合。(Python documentation)

工程上最简单的原则就是:

不要依赖动态修改 runtime Protocol 来改变运行时契约。

把 Protocol 当成稳定接口定义即可。


二十二、issubclass() 也不是所有 Protocol 都能随便用

再深入一点。

PEP 544 区分:

data protocol

和:

non-data protocol

例如:

@runtime_checkable
class Runner(Protocol):

    def run(self) -> None:
        ...

只有方法。

这是典型:

non-data protocol

而:

@runtime_checkable
class Named(Protocol):

    name: str

则包含实例数据成员。

这是:

data protocol

运行时:

isinstance()

可以用于 runtime-checkable data/non-data protocols。

但:

issubclass()

对 data protocol 存在限制。

原因很好理解:

实例属性可能只在 __init__() 运行后出现。

仅观察一个 class:

SomeClass

并不总能知道实例最终会不会拥有:

name

因此不能简单依赖:

issubclass()

判断这类结构。

PEP 544 对此有明确限制。(Python Enhancement Proposals (PEPs))


二十三、Protocol 和 ABC 最重要的对比

现在可以整理成一张工程表。

维度ProtocolABC
核心类型模型StructuralNominal 为主
是否要求显式继承不要求通常要求
第三方类适配非常方便较强耦合
Duck Typing 友好度极高相对较低
静态类型检查
强制子类实现抽象方法主要依赖静态检查语义abstractmethod 可运行时阻止实例化
共享实现可以,但通常不是重点很自然
表达能力型接口非常适合可以
表达领域身份有时过于宽松很适合
isinstance()runtime_checkable,且检查有限天然支持正规继承
与未知第三方对象协作非常优秀通常需要继承/注册/适配
耦合程度更显式、更强
最适合能力、边界、插件、callback、依赖注入领域模型、框架基类、生命周期、模板方法

二十四、一个最实用的判断标准:Capability vs Identity

以后看到一个接口时,可以问:

“我是在描述能力,还是描述身份?”

例如:

Writable
Readable
Closable
Serializable
Renderable
Comparable
Notifier

这些都明显是在表达:

Capability

即:

“你会什么?”

优先考虑:

Protocol

但是:

PaymentMethod
DomainEvent
AggregateRoot
PricingPolicy
WorkflowStep
GameEntity
CompilerPass

这些名称往往不仅是在表达:

方法集合

它们通常还表达:

领域身份
生命周期
设计意图
架构归属

这时候:

ABC

可能更合适。


二十五、案例:第三方 Storage 应该用 Protocol

假设你正在写一个通用缓存库。

只需要:

class CacheBackend(Protocol):

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

    def set(
        self,
        key: str,
        value: bytes,
    ) -> None:
        ...

你的系统:

def cache_result(
    backend: CacheBackend,
) -> None:
    ...

现在调用方可以直接接:

Redis wrapper
Memcached wrapper
Fake backend
Local dictionary adapter
第三方缓存 SDK

只要结构兼容即可。

这是非常典型的:

library boundary

使用 Protocol。

因为你没有理由要求整个世界:

继承你的 CacheBackend。

二十六、案例:领域聚合根可能更适合 ABC

假设我们有领域模型:

from abc import ABC, abstractmethod


class AggregateRoot(ABC):

    def __init__(self) -> None:
        self._events: list[
            DomainEvent
        ] = []

    def record(
        self,
        event: DomainEvent,
    ) -> None:
        self._events.append(event)

    def pull_events(
        self,
    ) -> list[DomainEvent]:

        events = self._events[:]
        self._events.clear()

        return events

    @abstractmethod
    def validate(self) -> None:
        ...

这里:

AggregateRoot

不是简单表示:

“只要有 validate() 就算聚合根。”

它还可能承担:

统一生命周期
事件记录
领域不变量
共享机制
框架识别
明确架构身份

那么:

class Order(AggregateRoot):
    ...

这个继承关系本身就是设计信息。

这种场景如果为了“解耦”强行 Protocol 化:

class AggregateRoot(
    Protocol
):
    ...

反而可能丢失重要语义。


二十七、Protocol 和 ABC 甚至可以一起用

现实项目不一定需要二选一。

例如你可以对外暴露:

class Writable(Protocol):

    def write(
        self,
        data: bytes,
    ) -> int:
        ...

核心函数只依赖这个最小能力:

def export(
    output: Writable,
) -> None:
    ...

而你的官方实现体系内部则使用:

class BaseWriter(ABC):

    @abstractmethod
    def write(
        self,
        data: bytes,
    ) -> int:
        ...

    def write_header(self) -> int:
        return self.write(
            b"HEADER\n"
        )

例如:

class FileWriter(BaseWriter):

    def write(
        self,
        data: bytes,
    ) -> int:
        ...

结构是:

                export()
                   │
                   ▼
               Writable
              Protocol
                  ▲
          ┌───────┼────────┐
          │       │        │
          │       │        │
      FileWriter  BytesIO  ThirdPartyWriter
          │
          ▼
      BaseWriter
         ABC

这样做非常漂亮:

外部 API:
低耦合 structural interface

内部实现:
明确 nominal hierarchy

这往往比强迫所有地方统一使用同一种抽象机制更成熟。


二十八、Protocol 最适合 Architecture Boundary

如果你的架构是:

┌───────────────────────────┐
│       Business Core       │
└────────────┬──────────────┘
             │
          Protocol
             │
 ┌───────────┼──────────────┐
 │           │              │
 ▼           ▼              ▼
Redis       PostgreSQL     HTTP API
Adapter      Adapter        Adapter

例如:

class UserRepository(
    Protocol
):

    def find(
        self,
        user_id: int,
    ) -> User | None:
        ...

业务代码:

class UserService:

    def __init__(
        self,
        repo: UserRepository,
    ) -> None:
        self.repo = repo

数据库实现:

class SqlUserRepository:

    def find(
        self,
        user_id: int,
    ) -> User | None:
        ...

测试实现:

class MemoryUserRepository:

    def find(
        self,
        user_id: int,
    ) -> User | None:
        ...

这里 Protocol 可以让:

业务核心

完全不要求基础设施:

加入自己的继承体系。

在 Clean Architecture、Hexagonal Architecture、Ports & Adapters 这类设计中,这种写法尤其自然。


二十九、但是 Domain Interface 不要机械地全写 Protocol

这一点非常值得资深开发者注意。

假设:

class DiscountPolicy(Protocol):

    def calculate(
        self,
        order: Order,
    ) -> Money:
        ...

是否应该 Protocol?

答案取决于业务。

如果你希望:

任何能 calculate(Order) -> Money
的对象都可以充当折扣策略

Protocol 很好。

但如果系统需要:

策略注册
统一 ID
生命周期
共享状态
校验逻辑
版本机制
框架 discovery
强制基类行为

那么:

class DiscountPolicy(ABC):
    ...

可能更加合适。

所以不要问:

“Repository 应该 Protocol 还是 ABC?”

而应该问:

“我的架构希望 Repository 是一种开放能力契约,还是一个封闭、明确的领域族?”

答案不同,选择自然不同。


三十、一个容易被忽视的问题:名字碰巧相同,不代表语义相同

Structural Typing 的优点,同时也是它潜在的危险。

假设:

class Deletable(Protocol):

    def delete(
        self,
        id: int,
    ) -> None:
        ...

而某个完全无关的类:

class Cache:

    def delete(
        self,
        id: int,
    ) -> None:
        ...

结构上:

Cache

可能满足:

Deletable

但是两边的:

delete

业务含义可能根本不是一回事。

例如:

一个删除用户
另一个删除缓存 key

这暴露出 structural typing 的重要边界:

它可以证明形状兼容,却不能证明业务语义相同。

静态类型系统能看到:

method name
parameter type
return type

但它看不到:

业务意图。

这也是为什么核心领域模型有时更需要 ABC 等显式 nominal relationship。


三十一、Protocol 设计的一个重要原则:接口要小

不建议这样:

class SuperService(
    Protocol
):

    def create(...): ...
    def update(...): ...
    def delete(...): ...
    def search(...): ...
    def export(...): ...
    def import_(...): ...
    def sync(...): ...
    def validate(...): ...
    def notify(...): ...
    def archive(...): ...

Protocol 越大:

偶然满足的人越少
mock 越难
实现成本越高
耦合越严重

更好的方式通常是拆成:

class Reader(Protocol):
    def read(...) -> ...:
        ...


class Writer(Protocol):
    def write(...) -> ...:
        ...


class Deleter(Protocol):
    def delete(...) -> ...:
        ...

调用方只依赖自己真正需要的:

def report(
    repo: Reader,
) -> None:
    ...

这就是:

consumer defines the interface

消费者定义自己需要的最小接口。

这是大型 Python 项目非常值得采用的最佳实践。


三十二、不要把 Protocol 当 Java Interface 的机械翻译

Protocol 虽然看起来像:

interface

但 Python 的设计思路不同。

Java 风格通常强调:

class Foo implements Bar

显式声明:

我实现 Bar。

Protocol 更强调:

你不需要认识 Bar。

只要你自然具备 Bar 所要求的结构,
就可以被视为 Bar。

这是一种更加开放的设计。

PEP 544 当初设计 Protocol 的重要目标之一,就是把 Python 原本自然的 duck typing 习惯带进静态类型系统,而不是要求所有代码都改造成显式继承体系。(Python Enhancement Proposals (PEPs))

所以:

class FileWriter(Writable):

并不是错。

但如果你选择 Protocol 的主要原因恰恰是:

structural typing

那么很多实现根本不需要显式继承它。


三十三、ABC 也不是绝对“必须直接继承”

这里补充一个高级细节。

Python ABC 支持:

ABC.register(SomeClass)

把一个没有真正继承 ABC 的类注册为:

virtual subclass

例如官方文档展示了类似:

MyABC.register(tuple)

之后:

issubclass(
    tuple,
    MyABC,
)

可以返回 True。(Python documentation)

ABC 还可以通过:

__subclasshook__()

自定义:

issubclass()

的判定逻辑。

所以现实中的区别并不是:

Protocol = 绝对不继承

ABC = 绝对必须继承

而应该理解成:

Protocol:
静态类型系统以 structural compatibility
为核心模型。

ABC:
运行时 class hierarchy / nominal abstraction
是核心模型,同时提供 virtual subclass 等扩展机制。

这样理解更加准确。


三十四、什么时候优先 Protocol?

如果满足以下特征:

接口描述的是能力

例如:

Readable
Writable
Serializable
Logger
Handler

或者:

需要适配第三方类型

或者:

不希望实现方依赖接口定义模块

或者:

测试中会大量使用 Fake / Stub

或者:

你在设计 library boundary

或者:

依赖注入只需要极小能力集合

那么:

优先考虑 Protocol。


三十五、什么时候优先 ABC?

如果:

属于该类型体系

本身就有语义,

或者你需要:

运行时阻止不完整实现实例化

或者需要:

共享实现

或者:

Template Method

或者需要:

明确领域继承层级

或者:

框架要求统一生命周期

或者:

所有实现本来就由你控制

那么:

ABC 往往更加自然。


三十六、实际项目中的决策流程

以后遇到:

Protocol or ABC?

可以按下面流程判断:

这个接口只是描述“能力”吗?
          │
       ┌──┴──┐
      Yes    No
       │      │
       ▼      ▼
 Protocol    “属于这个体系”
                 │
                 ▼
                ABC

再继续问:

需要适配你无法修改的第三方类型?
          │
         Yes
          │
          ▼
       Protocol

再问:

需要运行时禁止不完整子类实例化?
          │
         Yes
          │
          ▼
         ABC

再问:

只为了 isinstance() 才想加 Protocol?
          │
         Yes
          │
          ▼
重新考虑设计

因为:

runtime_checkable

并不是 Protocol 最主要的价值。


三十七、代码 Review 时看到 Protocol,我通常会问什么?

看到:

class Something(
    Protocol
):
    ...

可以问:

  1. 这是“能力”还是“领域身份”?
  2. 接口是否足够小?
  3. 实现方是否需要完全不知道这个接口存在?
  4. 是否需要适配第三方对象?
  5. 是否只因为“想用 interface”而机械选择 Protocol?
  6. 是否错误依赖 runtime_checkable 做完整验证?
  7. 这个 Protocol 是否包含过多业务行为?
  8. 是否应该拆成多个更小的 capability protocols?

看到:

class Something(
    ABC
):
    ...

则可以问:

  1. 继承关系本身是否真的有业务意义?
  2. 是否真的需要运行时 abstractmethod 约束?
  3. 是否真的存在值得共享的实现?
  4. 能否使用 composition 替代继承?
  5. 是否把本来只需要一个小能力的 API 强行绑到了巨大基类?
  6. 第三方实现是否会因此变得困难?

这些问题,比机械讨论:

Protocol 好还是 ABC 好

有价值得多。


三十八、回到开头的问题

我们最开始定义:

class Writable(Protocol):

    def write(
        self,
        data: bytes,
    ) -> int:
        ...

然后:

class MyWriter:

    def write(
        self,
        data: bytes,
    ) -> int:
        return len(data)

尽管:

MyWriter

没有写:

class MyWriter(Writable):

它依然可以静态满足:

Writable

因为 Protocol 使用的是:

Structural Subtyping

即:

看结构
而不是看继承关系。

如果改成:

class Writable(ABC):

    @abstractmethod
    def write(
        self,
        data: bytes,
    ) -> int:
        ...

那么我们通常是在表达:

Nominal Domain Contract

希望实现方明确加入:

Writable hierarchy

并且可以利用:

abstractmethod
运行时实例化约束
共享实现
MRO
super()

等传统面向对象机制。


三十九、最后用一句话区分它们

如果只记一句:

Protocol 问“你能做什么?”;ABC 更倾向问“你是不是这个体系的一员?”

进一步展开:

Protocol
    ↓
Capability
    ↓
Structural typing
    ↓
Loose coupling
    ↓
Library boundary

而:

ABC
    ↓
Identity / hierarchy
    ↓
Nominal typing
    ↓
Explicit contract
    ↓
Domain model / framework

这就是二者真正值得记住的区别。


四十、结语:Python 最强大的地方,不是“不要接口”,而是接口可以很轻

Python 的动态性曾经让很多人觉得:

“既然是 Duck Typing,还需要接口吗?”

而静态类型系统成熟以后,我们发现还有第三条路:

既不需要把 Python 改造成一个处处:

extends
implements

的语言,

也不必放弃:

IDE 推导
API 契约
重构安全
静态检查

Protocol 给出的答案是:

保留 Duck Typing 的开放性,同时让类型检查器理解这个 Duck。

而 ABC 则继续承担另一种重要角色:

当继承关系本身就是设计语言时,用明确的类型层级告诉整个团队:“这些类属于同一个概念体系。”

成熟的 Python 项目通常不会只选择其中一个。

它更可能是:

Architecture Boundary
       ↓
    Protocol

Domain Hierarchy
       ↓
      ABC

Concrete Implementation
       ↓
普通 class

甚至:

对外使用 Protocol
对内使用 ABC

两者互补。

真正优秀的 Python 类型设计,不是追求:

“所有东西都有一个基类”

也不是追求:

“所有东西都是 Protocol”

而是理解:

什么时候应该通过能力建立关系,什么时候应该通过身份建立关系。

当你面对一个:

class Interface(...):
    ...

真正值得问的已经不再是:

“这里应该写 Protocol 还是 ABC?”

而是:

“我希望调用方因为它拥有某种能力而满足这个接口,还是因为它明确属于这个领域体系而满足这个接口?”

当这个问题能够回答清楚时,ProtocolABC 的选择,通常也就清楚了。


附:Protocol / ABC 工程速查表

第三方适配          → Protocol

Duck Typing         → Protocol

Callback / Handler  → Protocol

Readable / Writable → Protocol

依赖注入边界        → Protocol

Fake / Stub 测试    → Protocol


领域身份            → ABC

框架基类            → ABC

Template Method     → ABC

共享实现            → ABC

运行时抽象约束      → ABC

明确继承体系        → ABC

最重要的一条:

Capability → 优先考虑 Protocol

Identity   → 优先考虑 ABC

留给你的三个思考题

第一,如果一个:

UserRepository

只定义:

find()
save()

你会把它设计成:

Protocol

还是:

ABC

如果 Repository 实现来自 SQL、MongoDB、第三方 SDK 和测试内存仓库,你的答案会不会变化?

第二,如果你定义:

@runtime_checkable
class JsonSerializable(Protocol):

    def to_json(self) -> str:
        ...

然后:

isinstance(
    obj,
    JsonSerializable,
)

返回 True

你是否敢断言:

obj.to_json()

一定满足完整的静态签名和业务语义?

答案应该非常谨慎:

不能。

因为 runtime Protocol check 并不是完整运行时类型验证。(Python documentation)

第三,如果一个核心领域接口既需要:

明确领域身份

又希望:

第三方 adapter 可以低耦合接入

是否可以:

内部 ABC
+
边界 Protocol

同时存在?

答案不仅是:

可以。

在大型 Python 系统里,这往往反而是非常漂亮的设计。

当你真正理解这一点,ProtocolABC 就不再是两个竞争的语法工具,而变成了两种不同的建模语言:

一个描述能力,
一个描述归属。

知道什么时候使用哪一种,才是真正掌握 Python 类型设计的开始。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

铭渊老黄

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值