Python 类型系统:写一个真正保持函数签名的 Decorator——从 Callable[..., T] 到 ParamSpec、Concatenate 与 Async
Python 的 decorator 很迷人。
你可以用几行代码给任意函数增加日志、缓存、权限检查、重试、计时、事务控制:
@retry
def fetch_user(user_id: int) -> str:
...
运行时看起来一切完美。
可一旦项目开始使用静态类型检查,一个隐藏的问题就会出现:
装饰器包装了函数以后,原函数的参数信息还在吗?
例如原函数明明是:
def fetch_user(
user_id: int,
*,
timeout: float = 3.0,
) -> str:
...
我们真正希望 @retry 之后,类型检查器仍然知道:
user_id 必须是 int
timeout 必须是 float
timeout 是 keyword-only
返回值仍然是 str
也就是说,一个“不改变调用接口”的 decorator 应该满足:
原函数:
(int, *, timeout: float) -> str
↓ @retry
装饰后:
(int, *, timeout: float) -> str
而不是退化成:
(...) -> str
这正是 ParamSpec 存在的重要原因。
Python 官方 typing 文档明确指出,ParamSpec 主要就是为了把一个 callable 的参数类型转发给另一个 callable,高阶函数和 decorator 是它最典型的应用场景。(Python documentation)
一、先写一个没有类型的 retry
从最普通的版本开始:
from functools import wraps
def retry(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(3):
try:
return func(*args, **kwargs)
except ConnectionError:
if attempt == 2:
raise
return wrapper
使用:
@retry
def load_user(user_id: int) -> str:
return f"user-{user_id}"
运行没有问题:
print(load_user(1001))
真正的问题发生在静态世界。
如果 decorator 自己完全没有类型,那么不同类型检查器和配置可能无法完整推断包装后的调用关系。
于是我们自然会想到:
那给 decorator 加类型不就行了吗?
于是出现了第一个常见版本。
二、为什么 Callable[..., T] 不够?
很多人第一次会这样写:
from collections.abc import Callable
from typing import TypeVar
R = TypeVar("R")
def retry(
func: Callable[..., R],
) -> Callable[..., R]:
...
它看起来挺合理:
传入一个函数
↓
返回值类型 R
↓
返回另一个返回 R 的函数
例如:
def get_name(user_id: int) -> str:
return "Alice"
那么:
retry(get_name)
确实仍然可以知道返回值是:
str
这就是:
R = TypeVar("R")
发挥的作用。
但问题在参数部分:
Callable[..., R]
这里的:
...
并不是:
“自动把原函数参数复制到这里。”
它表达的是一个未指定的参数列表。
typing specification 把 Callable[..., R] 中的 ... 作为 callable 参数部分的渐进式、未指定形式处理;它并没有捕获输入函数具体有哪些参数。(Python Typing)
于是:
def retry_bad(
func: Callable[..., R],
) -> Callable[..., R]:
...
装饰:
@retry_bad
def transfer(
user_id: int,
amount: float,
) -> bool:
...
类型检查器看到装饰后的函数,本质上只能得到类似:
Callable[..., bool]
结果它知道:
返回 bool
却不知道原来必须:
第一个参数 int
第二个参数 float
于是类似:
transfer(
"not-int",
amount="not-float",
whatever=123,
)
可能无法获得我们原本期待的参数检查。
这就是 Callable[..., T] “丢信息”的真正含义。
三、TypeVar 保留“一个类型”,ParamSpec 保留“整套参数”
理解这两个工具,可以用一句非常重要的话:
TypeVar → 捕获一个类型关系
ParamSpec → 捕获一整个函数参数规格
例如:
R = TypeVar("R")
解决的是:
输入函数返回什么
↓
包装函数也返回什么
而:
P = ParamSpec("P")
解决的是:
输入函数接受哪些参数
↓
包装函数继续接受同样的参数
所以一个真正保持签名的 decorator,其核心结构应该是:
Callable[P, R]
↓
Callable[P, R]
这才叫“签名保真”。
四、正确答案:ParamSpec + TypeVar
完整代码:
from collections.abc import Callable
from functools import wraps
from typing import ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def retry(
func: Callable[P, R],
) -> Callable[P, R]:
@wraps(func)
def wrapper(
*args: P.args,
**kwargs: P.kwargs,
) -> R:
for attempt in range(3):
try:
return func(*args, **kwargs)
except ConnectionError:
if attempt == 2:
raise
raise AssertionError("unreachable")
return wrapper
最关键的四个地方是:
P = ParamSpec("P")
R = TypeVar("R")
以及:
func: Callable[P, R]
和:
-> Callable[P, R]
最后:
*args: P.args
**kwargs: P.kwargs
它建立了一条完整关系:
原函数参数
↓
P
↓
wrapper 参数
↓
装饰后函数参数
返回类型则是:
原函数返回值
↓
R
↓
wrapper 返回值
↓
装饰后返回值
于是:
@retry
def fetch_user(
user_id: int,
*,
timeout: float = 3.0,
) -> str:
return f"user-{user_id}"
类型检查器仍然能够把 fetch_user 理解为概念上的:
(user_id: int, *, timeout: float = ...) -> str
因此:
fetch_user(1001)
fetch_user(1001, timeout=5.0)
合法。
而:
fetch_user("1001")
应该被拒绝。
同样:
fetch_user(
1001,
timeout="five",
)
也能在静态检查阶段暴露问题。
这才是 decorator 真正应该提供的类型体验。
五、为什么不能只写 *args: tuple[...]?
有开发者可能想:
def wrapper(
*args: object,
**kwargs: object,
) -> R:
...
问题在于,函数参数远比:
一堆 positional arguments
+
一堆 keyword arguments
复杂。
Python 参数可能包括:
def example(
a: int, # positional-or-keyword
/,
b: str, # positional-or-keyword
*values: float, # variadic positional
flag: bool, # keyword-only
**extra: bytes, # variadic keyword
) -> None:
...
一个完整的参数规格包含:
参数数量
参数顺序
参数类型
positional-only
positional-or-keyword
keyword-only
*args
**kwargs
PEP 612 引入 ParamSpec,正是因为简单的 tuple/dict 无法正确表达这种完整关系。P.args 和 P.kwargs 必须来源于同一个 ParamSpec,这样类型检查器才能知道 wrapper 只是忠实地把参数继续转发给原函数。(Python Enhancement Proposals (PEPs))
六、functools.wraps 能不能替代 ParamSpec?
不能。
这是一个特别容易混淆的问题。
我们通常会同时写:
@wraps(func)
def wrapper(...):
...
所以有人会认为:
@wraps不是已经保持函数签名了吗?
其实它解决的是运行时元数据问题。
Python 当前 functools.wraps() 会通过 update_wrapper() 保留或更新诸如:
__name__
__qualname__
__doc__
__annotations__
__type_params__
等信息,并设置:
__wrapped__
指向被包装函数。(Python documentation)
所以:
@retry
def calculate() -> int:
"""Calculate something."""
...
你希望:
calculate.__name__
仍然是:
calculate
而不是:
wrapper
这是 @wraps 的职责。
但:
@wraps
主要解决:
runtime metadata / introspection
而:
ParamSpec + TypeVar
解决:
static type relationship
成熟 decorator 通常两个都要:
@wraps(func)
def wrapper(
*args: P.args,
**kwargs: P.kwargs,
) -> R:
...
一个服务运行时工具,一个服务类型检查器。
七、为什么 TypeVar 不能替代 ParamSpec?
曾经有一种常见写法:
from collections.abc import Callable
from typing import TypeVar, cast
F = TypeVar(
"F",
bound=Callable[..., object],
)
def retry(func: F) -> F:
...
return cast(F, wrapper)
乍看也达到了:
传进 F
返回 F
所以签名似乎保住了。
问题是:
cast(F, wrapper)
实际上是在告诉检查器:
“相信我,wrapper 和 func 类型完全一样。”
但类型系统并没有真正检查:
wrapper
是否正确地接收和转发了原函数参数。
换句话说,它更像:
程序员声明它正确
而不是:
类型系统证明它正确
使用:
Callable[P, R]
以后,我们可以让 wrapper 自身被检查:
def wrapper(
*args: P.args,
**kwargs: P.kwargs,
) -> R:
return func(*args, **kwargs)
所以工程上优先推荐:
ParamSpec + TypeVar
而不是:
TypeVar[Callable] + cast
八、Concatenate 是干什么的?
到目前为止,我们讨论的是:
装饰前参数 = 装饰后参数
但有些 decorator 会改变函数签名。
例如依赖注入。
假设业务函数需要一个上下文对象:
class RequestContext:
def __init__(self, trace_id: str) -> None:
self.trace_id = trace_id
原始实现:
def handle_request(
ctx: RequestContext,
user_id: int,
) -> str:
return f"{ctx.trace_id}:{user_id}"
但你希望调用方不需要传 ctx:
handle_request(1001)
decorator 自动注入:
RequestContext(...)
于是发生了:
原函数:
(RequestContext, int) -> str
↓ decorator
外部看到:
(int) -> str
这时候就轮到:
Concatenate
出场。
九、用 Concatenate 实现参数注入
代码如下:
from collections.abc import Callable
from functools import wraps
from typing import Concatenate, ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def inject_context(
func: Callable[
Concatenate[RequestContext, P],
R,
],
) -> Callable[P, R]:
@wraps(func)
def wrapper(
*args: P.args,
**kwargs: P.kwargs,
) -> R:
ctx = RequestContext(
trace_id="trace-001",
)
return func(
ctx,
*args,
**kwargs,
)
return wrapper
使用:
@inject_context
def handle_request(
ctx: RequestContext,
user_id: int,
*,
verbose: bool = False,
) -> str:
...
在函数内部:
ctx
user_id
verbose
都存在。
但是调用方只看到:
handle_request(
1001,
verbose=True,
)
类型关系就是:
Callable[
Concatenate[RequestContext, P],
R,
]
↓
Callable[P, R]
Concatenate 的语义可以理解为:
在 ParamSpec 表示的参数列表前面拼接一个或多个 positional 参数。
官方 typing specification 明确说明,它适合描述高阶函数增加、删除或者转换有限数量的前置参数;被拼接进去的参数是前置的 positional-only 语义。(Python Typing)
常见场景包括:
RequestContext 注入
Lock 注入
Database Session 注入
Transaction 注入
Framework Context 注入
例如官方文档经典的 lock 模式,本质上也是:
Callable[Concatenate[Lock, P], R]
↓
Callable[P, R]
十、Concatenate 不是万能的“参数编辑器”
这里有一个很重要的限制。
你不能把它简单理解为:
“想在参数列表任何位置插什么都行。”
它最自然的能力是:
在 P 前面增加有限个 positional 参数
例如:
Concatenate[Request, P]
或者:
Concatenate[
Request,
Database,
P,
]
而“给任意函数偷偷增加一个新的 keyword-only 参数”则要困难得多。
例如我们梦想:
def wrapper(
*args: P.args,
retry_count: int = 3,
**kwargs: P.kwargs,
):
...
问题是:
原函数自己的
P里面会不会已经有一个叫retry_count的参数?
这会导致参数名称冲突和 soundness 问题。
PEP 612 专门讨论了为什么通用的 keyword parameter concatenation 没有被纳入该机制。(Python Enhancement Proposals (PEPs))
所以工程经验是:
完全保留签名
→ ParamSpec
增加/移除前置 positional 参数
→ Concatenate
复杂修改 keyword-only signature
→ 谨慎设计 API
不要为了“聪明的 decorator”牺牲整个库的类型可理解性。
十一、真正棘手的问题:怎样同时支持 sync 和 async?
现在进入最实战的一关。
我们希望:
@retry
def sync_fetch(...) -> str:
...
可以工作。
同时:
@retry
async def async_fetch(...) -> str:
...
也可以工作。
而且类型检查器必须知道:
sync_fetch → 调用后直接得到 str
async_fetch → 调用后得到 Awaitable[str]
不能把两者混成:
Any
或者:
object
这里通常需要:
ParamSpec
TypeVar
overload
inspect.iscoroutinefunction
一起工作。
十二、一个 sync / async 双兼容 retry
先给出一个实用版本:
import inspect
from collections.abc import Awaitable, Callable
from functools import wraps
from typing import ParamSpec, TypeVar, cast, overload
P = ParamSpec("P")
R = TypeVar("R")
@overload
def retry(
func: Callable[P, Awaitable[R]],
/,
) -> Callable[P, Awaitable[R]]:
...
@overload
def retry(
func: Callable[P, R],
/,
) -> Callable[P, R]:
...
def retry(
func: (
Callable[P, R]
| Callable[P, Awaitable[R]]
),
) -> (
Callable[P, R]
| Callable[P, Awaitable[R]]
):
if inspect.iscoroutinefunction(func):
async_func = cast(
Callable[P, Awaitable[R]],
func,
)
@wraps(async_func)
async def async_wrapper(
*args: P.args,
**kwargs: P.kwargs,
) -> R:
for attempt in range(3):
try:
return await async_func(
*args,
**kwargs,
)
except ConnectionError:
if attempt == 2:
raise
raise AssertionError("unreachable")
return async_wrapper
sync_func = cast(
Callable[P, R],
func,
)
@wraps(sync_func)
def sync_wrapper(
*args: P.args,
**kwargs: P.kwargs,
) -> R:
for attempt in range(3):
try:
return sync_func(
*args,
**kwargs,
)
except ConnectionError:
if attempt == 2:
raise
raise AssertionError("unreachable")
return sync_wrapper
这里发生了三层类型建模。
第一层:两个 overload
同步函数:
Callable[P, R]
进去以后:
Callable[P, R]
回来。
异步函数则是:
Callable[P, Awaitable[R]]
进去以后仍然:
Callable[P, Awaitable[R]]
所以调用者看到的接口没有改变。
第二层:运行时判断
inspect.iscoroutinefunction(func)
负责判断当前传入的是不是 coroutine function。
Python 3.14 当前文档说明,它会识别普通 async def coroutine function,也能识别相应的 functools.partial() 包装,以及被 markcoroutinefunction() 标记的函数。(Python documentation)
异步分支必须:
await async_func(...)
否则你 retry 的很可能只是:
“创建 coroutine 对象”
而不是:
“真正执行 coroutine”
异常也就无法在正确的位置捕获。
第三层:两个 wrapper 本质不同
同步:
def sync_wrapper(...) -> R:
return sync_func(...)
异步:
async def async_wrapper(...) -> R:
return await async_func(...)
这一区别不能偷懒。
下面这种写法就是错觉:
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
对于 async function:
func(...)
只是产生 coroutine / awaitable。
真正执行发生在:
await func(...)
所以如果你要做:
retry
timeout
transaction
logging exception
resource cleanup
sync 和 async 往往不仅类型不同,控制流也不同。
十三、一个隐藏边界:sync function 返回 Awaitable
还要注意一个少见但真实的情况:
def strange() -> Awaitable[int]:
...
它是一个:
普通 def
但是返回 Awaitable。
这和:
async def strange() -> int:
...
不是完全相同的运行时语义。
inspect.iscoroutinefunction() 检测的是 coroutine function,而不是简单看类型注解是否写了:
Awaitable[T]
所以在一个生产级库里,你最好明确 decorator 的契约:
我支持真正的
async defcoroutine function,而不是试图统一所有“返回 awaitable 的普通 callable”。
类型系统能描述很多东西,但它不能代替清晰的 API 语义。
十四、生产级 retry 还应该改什么?
前面的代码是为了突出类型。
真正上线时,至少还应该考虑:
重试哪些异常?
最多重试几次?
是否指数退避?
是否加入 jitter?
哪些错误绝不能 retry?
async 版本是否使用 asyncio.sleep?
函数是否幂等?
是否记录 attempt?
是否支持 cancellation?
例如千万不要轻易写:
except Exception:
...
然后所有错误都重试。
数据库唯一键错误:
retry
retry
retry
通常没有意义。
参数验证错误:
retry
retry
retry
也不会自己变好。
更合理的是只捕获明确的 transient error:
except (
ConnectionError,
TimeoutError,
):
...
这也是一个很重要的 Python 最佳实践:
类型正确只是高质量 decorator 的第一步,错误语义正确同样重要。
十五、Python 3.12+ 还能写得更现代
传统写法是:
P = ParamSpec("P")
R = TypeVar("R")
def decorator(
func: Callable[P, R],
) -> Callable[P, R]:
...
Python 3.12 引入新的类型参数语法以后,可以写成:
def decorator[**P, R](
func: Callable[P, R],
) -> Callable[P, R]:
...
官方 typing specification 已支持使用:
**P
在类型参数列表中声明 ParamSpec。(Python Typing)
例如:
from collections.abc import Callable
from functools import wraps
def retry[**P, R](
func: Callable[P, R],
) -> Callable[P, R]:
@wraps(func)
def wrapper(
*args: P.args,
**kwargs: P.kwargs,
) -> R:
return func(*args, **kwargs)
return wrapper
如果你的项目:
最低 Python >= 3.12
这种写法非常漂亮。
如果你维护兼容:
Python 3.10 / 3.11
那么继续使用:
ParamSpec(...)
TypeVar(...)
往往更加实用。
ParamSpec 与 Concatenate 是 Python 3.10 引入 typing 系统的;更旧环境通常需要借助 typing_extensions。(Python Enhancement Proposals (PEPs))
十六、Decorator 类型设计的速查模型
以后看到 decorator,可以先问:
情况一:参数和返回值完全不变
(P) -> R
↓
(P) -> R
使用:
ParamSpec + TypeVar
模板:
def deco(
func: Callable[P, R],
) -> Callable[P, R]:
...
情况二:参数不变,但返回值改变
例如:
(P) -> R
↓
(P) -> Awaitable[R]
使用:
ParamSpec + TypeVar
例如 PEP 612 的 logging 示例就是这种关系。(Python Enhancement Proposals (PEPs))
情况三:自动注入前置参数
(Context, P) -> R
↓
(P) -> R
使用:
Concatenate
例如:
Callable[
Concatenate[Context, P],
R,
]
情况四:同时支持 sync / async
需要:
ParamSpec
+
TypeVar
+
overload
+
运行时 coroutine 判断
而不要简单把所有东西打成:
Callable[..., Any]
十七、最值得记住的错误写法
第一种:
def retry(
func: Callable[..., R],
) -> Callable[..., R]:
...
问题:
保留返回值
丢失具体参数关系
第二种:
def retry(func: Any) -> Any:
...
问题更严重:
参数检查丢失
返回值检查丢失
IDE 推断变弱
错误继续传播
第三种:
def retry(func: F) -> F:
...
再靠:
cast(F, wrapper)
硬告诉 checker:
“相信我”
不是完全不能用,但如果 ParamSpec 可以准确表达,应优先让类型系统真正理解参数转发关系。
第四种:
@wraps(func)
def wrapper(*args, **kwargs):
...
然后认为:
有
@wraps,所以静态类型自然全部保留。
这是把:
运行时 metadata
和:
静态 callable type
混为一谈。
十八、一个真正成熟的 decorator 应该同时守住三层契约
设计 decorator 时,可以画成下面这张图:
Original Function
│
┌───────────┼───────────┐
│ │ │
▼ ▼ ▼
Runtime Static Semantics
Metadata Type
│ │ │
functools ParamSpec retry only
.wraps TypeVar safe errors
overload
│
└───────────┼───────────┘
▼
Decorated Function
第一层:
运行时身份
靠:
functools.wraps
第二层:
静态调用契约
靠:
ParamSpec
TypeVar
Concatenate
overload
第三层:
真正业务行为
靠你的 decorator 设计。
三层缺一不可。
十九、总结:ParamSpec 解决的不是语法问题,而是“函数关系”
理解 decorator typing 最重要的转变,是不要再把一个函数只看成:
Callable
真正的函数类型包含两部分:
Parameters → Return
所以:
Callable[..., R]
只比较准确地保存了右边:
R
却没有捕获左边的具体参数规格。
ParamSpec 补上的正是这一块:
P → R
于是我们终于可以表达:
这个 wrapper 不关心 P 到底是什么,
但它必须接收与原函数完全相同的 P。
而 TypeVar 表达:
我不知道 R 到底是什么,
但 wrapper 必须继续返回同一个 R。
最终:
Callable[P, R]
真正描述的是一种关系,而不仅是一堆类型名称。
再进一步:
Concatenate[Context, P]
允许我们表达:
签名经过了什么受控变化。
这也是现代 Python 静态类型系统越来越强大的地方:
它并不是要把 Python 变成一门僵硬的语言,而是在尽可能保留 Python 高阶函数、Duck Typing 和 decorator 灵活性的同时,让 IDE 和类型检查器理解这些动态模式背后的契约。
如果只记住五句话,可以记住:
1. TypeVar 保留一个类型关系。
2. ParamSpec 保留整个参数规格。
3. Callable[..., R] 不等于
“保持原函数参数”。
4. Concatenate 用于描述
前置参数的增加、移除或转换。
5. sync / async decorator
不仅返回类型不同,
执行控制流也必须分别处理。
一个真正优秀的 decorator,不应该因为增加了:
retry
logging
cache
permission
transaction
就让调用者失去:
参数补全
类型检查
重构安全
返回值推断
decorator 最理想的境界不是:
“我把函数包起来了。”
而是:
“我改变了它的行为,却没有破坏它原本对调用者做出的类型承诺。”
这才是 ParamSpec 真正值得掌握的地方。
思考题
第一:
def deco(
f: Callable[..., R],
) -> Callable[..., R]:
...
为什么虽然保存了 R,却没有保存:
f(a: int, *, flag: bool)
这样的参数契约?
第二:
如果一个 decorator 把:
def handler(
request: Request,
user_id: int,
) -> str:
...
变成外部调用:
handler(1001)
你应该使用:
ParamSpec
还是:
ParamSpec + Concatenate
?
第三,也是最值得在真实项目里讨论的问题:
假设你的 decorator 同时支持:
def fetch() -> User
与:
async def fetch() -> User
你希望调用方看到:
User
和:
Awaitable[User]
两个精确接口,
还是为了少写几十行类型代码,统一退化成:
Callable[..., Any]
?
在小脚本里,后者或许没什么。
但当 decorator 成为整个 Web 框架、SDK 或基础设施库的公共 API 时,这几十行 typing,往往换来的正是数万行调用代码的类型安全。
参考资料
Python 官方 typing 文档中关于 ParamSpec、Concatenate 和 callable 参数转发的说明:Python typing documentation
ParamSpec 与 Concatenate 的原始规范及设计动机:PEP 612 — Parameter Specification Variables
Python typing specification 对泛型、ParamSpec 和 callable 的详细定义:Python Typing Specification — Generics
functools.wraps()、update_wrapper() 与 __wrapped__ 的运行时行为:Python functools documentation
同步/异步函数运行时判断:Python inspect documentation

580

被折叠的 条评论
为什么被折叠?



