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

Python typing.Protocol配合泛型描述可调用对象的方式

来源:17golang原创

时间:2026-09-20 13:30:47 355浏览 收藏

当一个函数既可能接收普通函数,也可能接收带 __call__ 的对象时,单写 Callable[..., T] 往往太宽:输入类型之间的关系表达不出来,关键字参数和自定义调用约束也不好保留。更稳的做法是用 typing.Protocol 定义一个泛型回调协议,把“能接收什么、会返回什么”写成静态接口。

官方地址:https://docs.python.org/3/library/typing.html

泛型可调用协议的核心是一个带类型参数的 __call__:输入类型放在参数位置,返回类型放在结果位置;函数或对象只要满足这份结构,就能被静态类型检查器接受,不需要显式继承协议。
要点速览
  • Protocol + __call__ 描述回调,比无约束的 Callable[..., T] 更能保留参数关系。
  • 可调用对象的输入类型通常标记为逆变,输出类型标记为协变,替换关系才不会反过来。
  • 普通函数、闭包和实现 __call__ 的实例都可以结构化匹配同一个协议。

先把可调用对象抽象成泛型协议

假设业务函数接收一批原始记录,并把每条记录转换成另一种类型。不要把处理函数写成接受任意参数的黑盒,而是把输入和输出的关系放进协议:

from collections.abc import Iterable
from typing import Protocol, TypeVar

InputT = TypeVar("InputT", contravariant=True)
OutputT = TypeVar("OutputT", covariant=True)

class Converter(Protocol[InputT, OutputT]):
    # __call__ 是回调协议的静态入口,描述一次转换的输入和输出。
    def __call__(self, value: InputT) -> OutputT:
        ...

def convert_all(
    values: Iterable[InputT],
    converter: Converter[InputT, OutputT],
) -> list[OutputT]:
    # 处理函数只依赖协议,不依赖某一个转换器类。
    return [converter(value) for value in values]

InputT 出现在可调用对象的参数位置,所以使用 contravariant=TrueOutputT 出现在返回值位置,所以使用 covariant=True。这不是为了改变运行时行为,而是告诉类型检查器如何判断一个更宽或更窄的回调能否替换当前回调。

Python typing.Protocol 泛型可调用对象从输入类型经过 __call__ 到输出类型的静态关系说明图
图1:泛型回调协议把输入类型、__call__ 契约和输出类型连成一条静态关系;这是说明图,不是运行截图。

让函数与可调用实例共享同一份接口

结构化子类型的价值在于,实现方不需要为了“满足接口”修改继承关系。下面的普通函数和类实例都具备兼容的调用签名,因此可以交给同一个 convert_all

class UserRecord:
    def __init__(self, name: str) -> None:
        self.name = name

def normalize_name(value: UserRecord) -> str:
    # 普通函数天然可以作为 Converter[UserRecord, str] 使用。
    return value.name.strip().casefold()

class DisplayName:
    def __call__(self, value: UserRecord) -> str:
        # 可调用实例可以保存配置,同时仍符合同一个 __call__ 契约。
        return value.name.strip().title()

records = [UserRecord(" Ada "), UserRecord("grace")]
normalized = convert_all(records, normalize_name)
displayed = convert_all(records, DisplayName())

这里不需要写 class DisplayName(Converter[UserRecord, str])。类型检查器会检查它是否有匹配的 __call__。如果把参数名、参数种类或返回类型改得不兼容,错误会出现在调用点附近,而不是等到业务运行后才暴露。

Python 3.12 与旧语法的迁移写法

Python 3.12 引入了类型参数语法,可以把类型变量直接放在函数和协议定义上;项目仍需兼容 Python 3.11 时,继续使用 TypeVar 工厂更稳妥。两种写法表达的是同一个类型关系:

# Python 3.12:类型参数更靠近声明,适合新项目。
class Converter12[InputT, OutputT](Protocol):
    def __call__(self, value: InputT) -> OutputT:
        ...

def convert_one12[T, R](value: T, converter: Converter12[T, R]) -> R:
    # 返回值 R 与传入 converter 的输出类型保持关联。
    return converter(value)

# Python 3.11 及更早版本:复用前面的 InputT、OutputT 定义。
class Converter311(Protocol[InputT, OutputT]):
    def __call__(self, value: InputT) -> OutputT:
        ...

不要把两种声明方式混在同一个协议里,也不要为了兼容旧版本而把所有参数退化成 Any。如果库需要支持多个解释器版本,可以在类型定义文件中保留旧语法,应用代码再按最低支持版本选择写法。

需求建议声明边界
固定一个参数和返回值Protocol + 泛型 __call__能表达输入输出的对应关系
参数名、关键字参数或重载很复杂回调协议继续扩展 __call__签名必须与实现的参数种类兼容
只关心返回值类型Callable[..., OutputT]参数约束会被主动放宽
运行时判断是否满足协议谨慎使用 @runtime_checkable运行时检查只看有限的成员存在性,不等于静态类型检查

把静态检查和运行时职责分开

Protocol 主要服务静态分析。它不能像普通类一样被实例化,也不会在程序运行时自动验证泛型参数。提交代码前可以让 mypy 或 pyright 检查以下三件事:调用参数是否匹配、返回值是否真的能赋给目标类型、对象的 __call__ 是否使用了兼容的参数名和参数种类。

# 这个示例故意保留类型关系,交给静态检查器报告错误。
class BadConverter:
    def __call__(self, value: int) -> bytes:
        # 返回 bytes,不能假装成 Converter[int, str]。
        return str(value).encode("utf-8")

bad: Converter[int, str] = BadConverter()  # 类型检查器应在这里报错。

如果业务确实需要运行时校验,应单独检查输入数据或使用明确的运行时校验库,不要把 Protocol 当成数据验证器。这样可以让静态接口负责开发期约束,让业务代码负责运行时异常和用户输入。

Python Protocol 让普通函数与可调用类实例结构化匹配同一泛型接口的关系说明图
图2:普通函数和带 __call__ 的对象通过结构化成员匹配进入同一处理器;这是静态关系说明图,不是运行证据。

常见问题

为什么不用一个 Callable 就结束?

简单回调用 Callable[[T], R] 足够;当需要关键字参数、可变参数、重载或可调用对象的更精细签名时,带 __call__ 的协议更容易表达,也更方便扩展成员。

泛型 Protocol 一定要显式继承吗?

不需要。实现方只要提供兼容的成员就能结构化匹配;显式继承主要用于表达意图或让工具在定义处更早发现不一致。

为什么输入类型要逆变?

一个能处理更宽输入范围的函数,可以替代只要求较窄输入范围的函数;这是可调用参数的安全替换方向。返回值则相反,能够返回更具体类型的实现可以替代返回更宽类型的实现。

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