登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  python教程

Python typing.Protocol 约束鸭子类型接口

来源:17golang原创

时间:2026-09-29 05:39:36 246浏览 收藏

想保留 Python 鸭子类型,又希望编辑器和类型检查器能提前发现接口不匹配,可以把调用方真正依赖的成员写成 typing.Protocol。实现类不必继承这个 Protocol;只要方法、属性及其类型结构兼容,就能作为该接口使用。这种方式叫结构子类型,也常被称为静态鸭子类型。

官方文档:https://docs.python.org/3/library/typing.html#typing.Protocol

使用时记住三点
  • Protocol 描述“对象能做什么”,普通基类描述“对象属于什么继承体系”。
  • 约束主要由静态类型检查器执行,Python 运行时不会因为参数注解自动校验对象。
  • @runtime_checkable 只适合粗粒度成员存在性判断,不检查方法签名是否正确。

Protocol 到底解决什么问题?

假设业务函数只需要一个能够保存和读取字符串的对象。直接把参数标成某个具体存储类,会让调用方绑定实现;写成 Any 又会失去拼写、参数和返回值检查。Protocol 位于两者之间:只声明业务实际使用的最小能力。

from typing import Protocol


class TextStore(Protocol):
    # Protocol 只声明调用方依赖的方法签名。
    def save(self, key: str, value: str) -> None:
        ...

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


def cache_greeting(store: TextStore, user_id: str) -> str:
    # 业务函数只依赖 TextStore 的两个成员,不关心具体存储方式。
    key = f"greeting:{user_id}"
    store.save(key, "你好")
    return store.load(key) or ""

这不是运行时包装器,也不会生成代理对象。TextStore 主要给 Pyright、mypy 和 IDE 提供结构契约:传入对象必须同时具备兼容的 save 与 load。

Python Protocol、业务函数与多个结构兼容实现的静态关系说明图
图1:说明图,查看 TextStore 成员、业务函数和不同实现类之间的结构兼容关系;这不是运行截图。

实现类需要继承 Protocol 吗?

不需要。下面两个类没有声明 TextStore 为父类,但它们提供了匹配的公开成员,因此都能通过结构类型检查。已有代码、第三方对象和测试替身可以在不改继承树的情况下接入,这是 Protocol 对鸭子类型最有价值的地方。

class MemoryStore:
    def __init__(self) -> None:
        # 内存实现用普通字典保存数据。
        self._data: dict[str, str] = {}

    def save(self, key: str, value: str) -> None:
        self._data[key] = value

    def load(self, key: str) -> str | None:
        return self._data.get(key)


class ReadOnlyStore:
    def load(self, key: str) -> str | None:
        # 故意缺少 save,用来展示静态检查失败。
        return None


memory = MemoryStore()
cache_greeting(memory, "u-100")  # 类型结构完整,可以使用。

readonly = ReadOnlyStore()
cache_greeting(readonly, "u-100")  # 类型检查器会报告缺少 save。

常见误区是只检查同名方法是否存在。静态检查器还会比较参数和返回类型。例如把 save 的 value 写成 bytes,或者让 load 返回 int,都不满足这个 Protocol。对象即使在某次运行中“恰好能调用”,也不代表接口长期兼容。

属性和泛型怎么写才不会过度约束?

Protocol 中的普通属性默认既可读又可写,这会影响兼容性。如果调用方只需要读取名称,优先用只读 @property,避免强迫实现暴露可写字段。接口还可以使用类型变量,让输入输出关系保持精确。

from typing import Protocol, TypeVar

T_co = TypeVar("T_co", covariant=True)


class Provider(Protocol[T_co]):
    @property
    def name(self) -> str:
        # 只读属性允许实现使用属性或兼容的描述符。
        ...

    def get(self) -> T_co:
        # 协变类型变量描述只产生、不接收的返回值。
        ...


class NumberProvider:
    @property
    def name(self) -> str:
        return "primary-number"

    def get(self) -> int:
        return 42


def describe(provider: Provider[object]) -> str:
    # 调用方只读 name,并把 get 结果当 object 使用。
    return f"{provider.name}: {provider.get()}"


describe(NumberProvider())  # Provider[int] 可用于 Provider[object]。

接口越大,隐式实现越难维护。一个“万能服务” Protocol 同时要求日志、缓存、网络和序列化,通常说明边界提炼得还不够。更稳妥的做法是按调用场景拆成小 Protocol,再在确实需要时组合。

runtime_checkable 能不能当运行时接口校验?

默认 Protocol 不能直接作为 isinstance() 的第二个参数。加上 @runtime_checkable 后可以做运行时结构检查,但官方文档明确说明:它只看所需成员是否存在,不核对成员类型和方法签名。它也可能比普通类的 isinstance() 更慢。

from typing import Protocol, runtime_checkable


@runtime_checkable
class Closable(Protocol):
    def close(self) -> None:
        ...


class WrongCloser:
    def close(self, force: bool) -> str:
        # 名字存在,但参数和返回值都不满足静态协议。
        return "closed" if force else "skipped"


candidate: object = WrongCloser()
print(isinstance(candidate, Closable))
# 运行时可能得到 True,因为检查不会比较 close 的签名。

因此,runtime_checkable 适合插件入口的粗筛、联合类型缩窄等有限场景,不适合替代静态检查或完整输入验证。Python 3.12 起,运行时协议成员在类创建后用于检查的集合会被冻结,并改用 inspect.getattr_static() 查找属性;依赖动态补成员的代码尤其不应把它当成强保证。

Python Protocol 静态签名检查与 runtime_checkable 成员存在性检查的边界说明图
图2:结构说明图,查看静态类型检查、runtime_checkable 与目标对象成员之间的能力边界;这不是运行截图。

怎样给 Protocol 做接口回归?

Protocol 的价值来自类型检查阶段,所以最直接的回归方式,是在测试或类型专用模块中写一条显式赋值。实现类的方法签名变化后,检查器会在这条边界上给出集中错误,不必等到每个业务调用点分别报错。

def verify_store_contract() -> None:
    # 显式赋值让类型检查器在固定位置核对完整结构。
    store: TextStore = MemoryStore()

    # 这次调用同时确认业务入口只依赖协议类型。
    result = cache_greeting(store, "contract-check")
    assert result == "你好"

这段代码里的赋值负责静态接口回归,断言负责业务行为测试,两者职责不同。不要为了让错误消失而把关键成员改成 Any;那相当于撤掉接口约束。确实无法描述的动态边界,可以把 Any 限制在适配层,再转换成明确的 Protocol。

相关问题

Protocol 和 ABC 应该选哪个?

需要共享实现、注册机制或明确继承身份时,ABC 更合适;只想描述调用方需要的能力,并接纳互不相关的已有类时,Protocol 通常更轻。

类必须导入 Protocol 才能被识别吗?

不必。实现类甚至可以不知道 Protocol 的存在,只要公开成员及其类型结构兼容,静态检查器就能识别。

Protocol 会在函数调用时自动抛出类型错误吗?

不会。类型注解通常不改变 Python 的运行时调用行为;真正的签名核对由静态类型检查器执行。

什么时候应该显式继承 Protocol?

希望清楚表达设计意图、复用协议的默认实现,或让缺失抽象成员在实例化阶段暴露时可以显式继承。仅为获得结构兼容,不需要继承。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>