《会写 Python,更要会设计接口:Protocol、鸭子类型与静态检查的工程实践》

Python 最迷人的地方之一,是它从不强迫你一开始就把世界建模得很沉重。你可以写脚本、做自动化、搭 Web 服务、跑数据分析,也可以把它用在 AI、科学计算和后端系统中。Python 官网列出的应用领域就覆盖 Web 开发、AI 与机器学习、科学计算、系统管理等方向;Stack Overflow 2025 开发者调查也显示,Python 在 2024 到 2025 年的采用率增长明显,并与 AI、数据科学、后端开发紧密相关。(Python.org)

但 Python 项目越写越大,你会遇到一个很现实的问题:鸭子类型很灵活,可大型工程又需要类型检查、IDE 补全、重构安全和团队协作约束。

于是,Protocol 出场了。

它不是为了让 Python 变得死板,而是为了把 Python 原本的鸭子类型精神,翻译成静态类型检查器能理解的语言。


一、从一个 Writer 开始:什么是 Protocol?

你给出的代码非常经典:

from typing import Protocol

class Writer(Protocol):
    def write(self, data: str) -> int: ...

这段代码定义的不是一个“必须继承的父类”,而是一个行为契约

只要一个对象有 write(data: str) -> int 方法,它就可以被看作 Writer

例如:

from typing import Protocol
from io import StringIO


class Writer(Protocol):
    def write(self, data: str) -> int: ...


class FileWriter:
    def __init__(self, path: str):
        self.path = path

    def write(self, data: str) -> int:
        with open(self.path, "a", encoding="utf-8") as f:
            return f.write(data)


class ConsoleWriter:
    def write(self, data: str) -> int:
        print(data, end="")
        return len(data)


def save_report(writer: Writer, content: str) -> None:
    writer.write(content)


save_report(ConsoleWriter(), "hello protocol\n")
save_report(StringIO(), "hello memory\n")

注意:ConsoleWriter 没有继承 WriterStringIO 也没有继承你的 Writer。但它们都拥有兼容的 write() 方法,所以静态类型检查器可以认为它们满足 Writer 协议。

这就是 Protocol 的核心:不问你是谁,只问你能做什么。

Python 类型系统文档把这种方式称为结构化子类型:兼容性取决于对象能执行哪些操作,而不是取决于类继承关系;它也被描述为鸭子类型的静态等价物。(Typing Documentation)


二、鸭子类型:Python 的老朋友

Python 程序员很早就熟悉一句话:

如果它走起来像鸭子,叫起来像鸭子,那它就可以被当作鸭子。

在 Python 里,我们经常这样写:

def dump(writer, text):
    writer.write(text)

这个函数并不关心 writer 的具体类型。它只关心一件事:你有没有 write() 方法。

这非常 Pythonic。但问题是,当项目变大之后,纯动态鸭子类型会让工具很难提前发现错误:

class BadWriter:
    def write(self, data: bytes) -> None:
        print(data)


dump(BadWriter(), "hello")

运行前,IDE 或类型检查器很难知道 BadWriter.write() 的参数和返回值是否符合预期。Protocol 的价值就在这里:它保留鸭子类型的自由,同时把“鸭子应该具备什么行为”写成可检查的契约。

Python 官方 typing 文档也提醒,类型注解不会在运行时自动强制执行,主要由类型检查器、IDE、linter 等工具使用。(Python documentation) 所以 Protocol 的重点不是“运行时拦截一切错误”,而是让设计意图更早、更清楚地暴露出来。


三、Protocol 和抽象基类 ABC 的区别

Protocol 之前,很多人会用抽象基类定义接口:

from abc import ABC, abstractmethod


class WriterABC(ABC):
    @abstractmethod
    def write(self, data: str) -> int:
        raise NotImplementedError


class ConsoleWriter(WriterABC):
    def write(self, data: str) -> int:
        print(data, end="")
        return len(data)

这没有错。abc 模块本来就是 Python 标准库中用于定义抽象基类的基础设施;ABC 可以被直接继承,也可以注册虚拟子类。(Python documentation)

但 ABC 的设计偏向名义子类型:你是不是某个接口的实现者,通常取决于你是否显式继承或注册了这个接口。

Protocol 偏向结构化子类型:只要结构匹配,就可以通过类型检查。

对比一下:

# ABC:更像“你必须加入这个组织”
class ConsoleWriter(WriterABC):
    ...


# Protocol:更像“你只要具备这个能力”
class ConsoleWriter:
    def write(self, data: str) -> int:
        ...

PEP 544 解释过引入 Protocol 的动机:传统 ABC 需要类显式标记、继承或注册,这与 Python 惯用的动态风格不完全一致;Protocol 的目标是让用户无需显式继承,也能让静态类型检查器识别结构化子类型。(Python Enhancement Proposals (PEPs))


四、Protocol 灵活在哪里?

1. 适配第三方类,不需要改源码

假设你写了一个日志系统,希望支持任何能 write(str) 的对象:

from typing import Protocol


class Writer(Protocol):
    def write(self, data: str) -> int: ...


def log_error(writer: Writer, message: str) -> None:
    writer.write(f"[ERROR] {message}\n")

调用者可以传文件对象、StringIO、自定义网络写入器,甚至测试里的 fake 对象:

class FakeWriter:
    def __init__(self):
        self.lines: list[str] = []

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


fake = FakeWriter()
log_error(fake, "database timeout")

assert fake.lines == ["[ERROR] database timeout\n"]

你没有要求 FakeWriter 继承任何东西。它只要长得像 Writer,就能用。这对测试特别友好。

2. 降低库之间的耦合

如果你的库要求调用者必须继承你的基类,调用者就被你的类层次绑定了。

class MyFrameworkWriter:
    ...

但现实中的工程对象经常已经继承了别的类,或者来自第三方库,不能轻易修改继承关系。Protocol 避免了这种侵入式设计:

def export(writer: Writer, rows: list[str]) -> None:
    for row in rows:
        writer.write(row + "\n")

这个函数只表达自己真正需要的能力:能写字符串。

3. 更符合 Python 的接口设计哲学

好的 Python 接口通常不是问:

你是不是我的子类?

而是问:

你能不能完成我需要的操作?

这就是“鸭子类型 + 静态检查”的世界。运行时保持灵活,开发时获得检查。


五、从基础语法看 Protocol 的接口边界

Protocol 本质上仍然是 Python 类语法,所以你可以定义方法、属性、泛型、回调接口。

1. 方法协议

from typing import Protocol


class Serializer(Protocol):
    def dumps(self, obj: object) -> str: ...


class JsonSerializer:
    def dumps(self, obj: object) -> str:
        import json
        return json.dumps(obj, ensure_ascii=False)


def send_payload(serializer: Serializer, payload: dict[str, object]) -> str:
    return serializer.dumps(payload)

这里的 send_payload() 不依赖 JSON,也不依赖某个父类,只依赖“能把对象转成字符串”的能力。

2. 属性协议

from typing import Protocol


class HasName(Protocol):
    name: str


class User:
    def __init__(self, name: str):
        self.name = name


def greeting(obj: HasName) -> str:
    return f"你好,{obj.name}"


print(greeting(User("Cindy")))

如果你的函数只需要 .name 属性,就不要要求调用者继承 PersonBase

3. 泛型协议

from typing import Protocol, TypeVar

T = TypeVar("T")


class Repository(Protocol[T]):
    def get(self, id: int) -> T: ...
    def save(self, item: T) -> None: ...


class User:
    def __init__(self, id: int, name: str):
        self.id = id
        self.name = name


class InMemoryUserRepo:
    def __init__(self):
        self.data: dict[int, User] = {}

    def get(self, id: int) -> User:
        return self.data[id]

    def save(self, item: User) -> None:
        self.data[item.id] = item

泛型 Protocol 很适合仓储层、缓存层、消息队列、插件系统。Python 官方文档也说明,Protocol 可以是泛型协议。(Python documentation)

4. 回调协议

普通 Callable 有时表达不了复杂签名,尤其是带关键字参数的回调。Python 官方文档也给出过用 Protocol__call__() 表达复杂函数签名的方式。(Python documentation)

from typing import Protocol


class RetryHandler(Protocol):
    def __call__(self, *, attempt: int, error: Exception) -> bool: ...


def should_retry(*, attempt: int, error: Exception) -> bool:
    return attempt < 3


def run_with_retry(handler: RetryHandler) -> None:
    for attempt in range(1, 5):
        try:
            raise TimeoutError("timeout")
        except Exception as exc:
            if not handler(attempt=attempt, error=exc):
                raise


run_with_retry(should_retry)

六、一个完整实战:插件式导出系统

假设你正在写一个报表导出工具,支持导出到文件、控制台、内存、远程服务。初学者可能会写成这样:

class FileExporter:
    def export_to_file(self, rows: list[str], path: str) -> None:
        with open(path, "w", encoding="utf-8") as f:
            for row in rows:
                f.write(row + "\n")

这能跑,但扩展性一般。更好的做法是把“写入能力”抽象出来:

from typing import Protocol
from io import StringIO


class Writer(Protocol):
    def write(self, data: str) -> int: ...


def export_rows(writer: Writer, rows: list[str]) -> None:
    for row in rows:
        writer.write(row)
        writer.write("\n")


class ConsoleWriter:
    def write(self, data: str) -> int:
        print(data, end="")
        return len(data)


class HttpWriter:
    def __init__(self):
        self.buffer: list[str] = []

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

    def flush_to_server(self) -> None:
        body = "".join(self.buffer)
        print(f"send to server: {body!r}")


rows = ["id,name", "1,Alice", "2,Bob"]

export_rows(ConsoleWriter(), rows)

memory = StringIO()
export_rows(memory, rows)
print(memory.getvalue())

http_writer = HttpWriter()
export_rows(http_writer, rows)
http_writer.flush_to_server()

这段代码体现了一个重要设计原则:

业务逻辑依赖协议,不依赖具体实现。

导出逻辑不关心数据写到哪里。它只关心对象能否 write(str) -> int

这种设计带来的收益很实在:

收益说明
易扩展新增写入目标时,不需要修改 export_rows()
易测试StringIOFakeWriter 就能测试
低耦合业务层不绑定文件、HTTP、数据库等实现
类型安全静态检查器能提前发现 write() 签名不匹配

七、Protocol 不是运行时校验器

很多人第一次学 Protocol 时,会误以为它类似 Java 的 interface,运行时会自动检查对象是否满足协议。实际上不是。

默认情况下,Protocol 主要服务于静态类型检查。Python 文档明确说明,未使用 @runtime_checkable 的协议不能作为 isinstance()issubclass() 的第二个参数。(Python documentation)

如果你确实需要运行时检查,可以这样写:

from typing import Protocol, runtime_checkable


@runtime_checkable
class Writer(Protocol):
    def write(self, data: str) -> int: ...


class ConsoleWriter:
    def write(self, data: str) -> int:
        print(data, end="")
        return len(data)


print(isinstance(ConsoleWriter(), Writer))  # True

但要小心:runtime_checkable 的检查比较“浅”。官方文档提醒,它只检查所需方法或属性是否存在,不检查类型签名是否正确;并且对运行时可检查协议做 isinstance() 可能比普通类检查慢,性能敏感路径可以考虑 hasattr() 等方式。(Python documentation)

例如:

@runtime_checkable
class Writer(Protocol):
    def write(self, data: str) -> int: ...


class BadWriter:
    def write(self, data: bytes) -> None:
        pass


print(isinstance(BadWriter(), Writer))  # 可能为 True,但签名并不真的匹配

所以请记住:

Protocol 主要是静态契约;
runtime_checkable 只是有限的运行时结构检查。

八、什么时候用 Protocol,什么时候用 ABC?

不要把 Protocol 和 ABC 看成谁取代谁。它们适合不同场景。

需要定义接口

是否需要共享实现或强制继承体系?

使用 ABC

是否只关心对象具备某些方法/属性?

使用 Protocol

使用普通类型或具体类

建议如下:

场景推荐
只关心对象行为,比如 write()close()send()Protocol
需要共享默认实现、模板方法、生命周期钩子ABC
需要强制插件继承框架基类ABC
需要适配第三方对象、测试替身、已有类Protocol
参数只需要少量能力Protocol
返回具体业务对象普通类或数据类

例如,框架内部可以用 ABC:

from abc import ABC, abstractmethod


class Command(ABC):
    def run(self) -> None:
        self.before()
        self.execute()
        self.after()

    def before(self) -> None:
        print("prepare")

    def after(self) -> None:
        print("cleanup")

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

这里 ABC 很合适,因为它不仅定义接口,还提供执行模板。

而边界层更适合 Protocol:

from typing import Protocol


class MessageSender(Protocol):
    def send(self, topic: str, payload: bytes) -> None: ...


def publish_event(sender: MessageSender, user_id: int) -> None:
    sender.send("user.created", f"user_id={user_id}".encode())

这里你不需要继承关系,只需要发送能力。


九、Protocol 的最佳实践

第一,协议要小,不要贪大

class Writer(Protocol):
    def write(self, data: str) -> int: ...

比下面这种更好:

class HugeStorage(Protocol):
    def write(self, data: str) -> int: ...
    def read(self) -> str: ...
    def delete(self) -> None: ...
    def reconnect(self) -> None: ...
    def health_check(self) -> bool: ...

接口越大,实现者越难满足,复用性越差。

第二,用动词或能力命名协议

例如:

class SupportsWrite(Protocol):
    def write(self, data: str) -> int: ...


class SupportsClose(Protocol):
    def close(self) -> None: ...

这比 BaseThingCommonObject 更清楚。

第三,参数可以抽象,返回值尽量明确

from typing import Protocol


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


def parse_lines(reader: Reader) -> list[str]:
    return reader.read().splitlines()

参数用协议,表达“我只需要读取能力”;返回 list[str],表达“我确实返回一个列表”。

第四,不要把 Protocol 当作业务模型

UserOrderInvoice 这类实体对象通常应该用 dataclass、普通类或 Pydantic 模型。Protocol 更适合表达能力边界,例如可写、可读、可发送、可序列化。


十、为什么它适合“鸭子类型 + 静态检查”的世界?

因为 Python 的文化一直鼓励灵活组合,而不是过早建立沉重继承树。

在小脚本里,鸭子类型让你写得快:

def process(obj):
    obj.run()

在大项目里,静态检查让你改得稳:

class Runnable(Protocol):
    def run(self) -> None: ...


def process(obj: Runnable) -> None:
    obj.run()

Protocol 把这两者连接起来:
它不要求对象认祖归宗,却能让工具理解“这个对象应该具备什么能力”。

这就是它比继承抽象基类更灵活的地方:它把接口从类继承关系中解放出来,让接口回到行为本身。


总结

Protocol 是 Python 类型系统里非常优雅的一环。它让我们可以用 Python 原生的鸭子类型思维设计接口,又能享受静态类型检查带来的安全感。

你可以这样记:

ABC 关注“你是谁的子类”;
Protocol 关注“你能做什么”。

当你在写 Python 实战项目时,如果一个函数只需要对象具备某个方法或属性,不妨优先考虑 Protocol。它会让你的代码更开放、更可测试、更容易适配第三方库,也更接近 Python 一直以来的精神:简单、灵活、明确。

真正成熟的 Python 编程,不是把所有东西都塞进继承体系,而是在恰当的地方定义恰当的边界。Protocol 给我们的,正是这种边界感。

你在项目中有没有遇到过“为了满足一个接口,被迫继承某个基类”的情况?如果把它改成 Protocol,代码会不会更自由?欢迎把你的案例写下来,也许那正是团队接口设计升级的开始。

Logo

这里是“一人公司”的成长家园。我们提供从产品曝光、技术变现到法律财税的全栈内容,并连接云服务、办公空间等稀缺资源,助你专注创造,无忧运营。

更多推荐