现代Python学习——深入理解 Python 装饰器

装饰器是 Python 的强大特性:它能在不改动被装饰函数源代码的情况下为函数「包装」通用行为(日志、计时、缓存、重试、权限校验等)。在开启 Pylance strict 的编辑器环境下,我们还要注意类型注解写法——ParamSpec + TypeVar 保留被装饰函数的准确签名,避免 Callable 被当成未指定类型参数的泛型而飘红。当然,下面是这些概念的简单陈述:

  • 装饰器(decorator):接受一个可调用对象(通常是函数),返回一个新的可调用对象。语法糖:@decorator 写在函数定义上。
  • 装饰器工厂(decorator factory):带参数的装饰器,其本身是一个返回装饰器的函数,语法 @factory(arg)
  • 类装饰器:用类实现的装饰器,通常通过实现 __call__ 保存状态。
  • 保持元信息:用 functools.wraps(或 update_wrapper)保留原函数 __name____doc____annotations__ 等。
  • 静态类型(Pylance strict):使用 ParamSpec + TypeVar 精确保留被装饰函数的参数和返回类型,避免裸 Callable 导致的 reportMissingTypeArgument

在 strict 模式中,尽量不要使用裸 Callable,应使用 ParamSpec + TypeVar 保留签名:

# typing_compat.py — 兼容写法(可直接 copy)
try:
    from typing import ParamSpec, TypeVar, Callable, Awaitable
    from typing import Concatenate  # 可选:用于显式在签名中插入 self/class
except Exception:
    # Python < 3.10
    from typing_extensions import ParamSpec, TypeVar, Concatenate  # type: ignore
    from typing import Callable, Awaitable  # Callable/Awaitable 存在于 typing

P = ParamSpec("P")
R = TypeVar("R")
T = TypeVar("T")

用法约定:

  • 通用同步装饰器类型:Callable[P, R] -> Callable[P, R]
  • 通用异步装饰器类型:Callable[P, Awaitable[T]] -> Callable[P, Awaitable[T]]
  • 装饰器工厂返回类型示例:Callable[[Callable[P, R]], Callable[P, R]]

语法与示例

1) 最简单的计时器(严格类型)

import functools
import time
from typing import Callable

try:
    from typing import ParamSpec, TypeVar
except Exception:
    from typing_extensions import ParamSpec, TypeVar  # type: ignore

P = ParamSpec("P")
R = TypeVar("R")

def time_calculator(func: Callable[P, R]) -> Callable[P, R]:
    @functools.wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        start_time = time.time()
        result = func(*args, **kwargs)
        end_time = time.time()
        print(f"[time_calculator] {func.__name__} took {end_time - start_time:.6f}s")
        return result
    return wrapper

2) 带参数的装饰器(装饰器工厂)

def repeat(n: int) -> Callable[[Callable[P, R]], Callable[P, R]]:
    def decorator(func: Callable[P, R]) -> Callable[P, R]:
        @functools.wraps(func)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
            last: R
            for _ in range(n):
                last = func(*args, **kwargs)
            return last
        return wrapper
    return decorator

3) 异步函数的装饰器(保留 awaitable 签名)

import asyncio
from typing import Awaitable

def async_time_calculator(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
    @functools.wraps(func)
    async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        loop = asyncio.get_event_loop()
        start = loop.time()
        result = await func(*args, **kwargs)
        end = loop.time()
        print(f"[async_time_calculator] {func.__name__} took {end - start:.6f}s")
        return result
    return wrapper

4) 类装饰器(有状态)

class CountCalls:
    def __init__(self, func: Callable[P, R]) -> None:
        functools.update_wrapper(self, func)
        self._func = func
        self.count = 0

    def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R:
        self.count += 1
        return self._func(*args, **kwargs)

5) 在方法上使用装饰器 & 显式 self 类型(使用 Concatenate

当你想显式在类型签名中表示 self 时(strict 下更严格),可以用 Concatenate

try:
    from typing import Concatenate
except Exception:
    from typing_extensions import Concatenate  # type: ignore

S = TypeVar("S")  # self 的类型

def method_logger(func: Callable[Concatenate[S, P], R]) -> Callable[Concatenate[S, P], R]:
    @functools.wraps(func)
    def wrapper(self: S, *args: P.args, **kwargs: P.kwargs) -> R:
        print(f"Calling {func.__name__} on {self!r}")
        return func(self, *args, **kwargs)
    return wrapper

class Greeter:
    @method_logger
    def greet(self, name: str) -> str:
        return f"hi {name}"

Concatenate 对于把 selfcls 明确插入签名会很有帮助,但它是 Python 3.10+ 的特性;在旧版本用 typing_extensions 回退。


装饰器行为细节(实践中常见的“坑”)

  • 装饰器顺序:多个装饰器从上到下应用时,实际调用顺序是从下到上。例如:

    @deco_a
    @deco_b
    def f(...):
        ...
    

    等价于 f = deco_a(deco_b(f))

  • 保持签名functools.wraps 除了拷贝元信息,还拷贝 __annotations__(有助于类型检查器)。在 strict 模式下务必使用 wraps

  • 同步装饰器不能直接包裹 async 函数(否则会返回 coroutine 对象但不会 await)。对 async 函数写专门的 async 装饰器或让装饰器返回 coroutine。

  • 类型检查器与运行时不一致:类型注解仅供静态检查。装饰器在运行时仍要保证行为兼容原函数签名(不要随意改变参数传递方式)。

  • 方法装饰器与 descriptor:如果需要在装饰器中实现 descriptor 行为(例如把函数变成 property),要注意 __get__ 的实现;对于常见用例,可优先考虑 @property@classmethod / @staticmethod 的组合。


为什么要做类型注解(Pylance strict)

Pylance strict 会对泛型和可调用注解很严格。常见飘红原因之一就是使用裸 Callable(即 Callable 没有类型参数),Pylance 会报 reportMissingTypeArgument

为了解决并且不丢失被装饰函数的签名信息,推荐使用:

  • ParamSpec:表示函数参数列表(比如 (*args, **kwargs) 的类型信息)
  • TypeVar:表示返回值类型

组合能写出 Callable[P, R] 等精确注解。


支持Pylance Strict的规范的通用模板(兼容 typing_extensions)

下面给出一个开箱即用的模板,兼容 Python 3.10+(原生 ParamSpec)和更早 Python(从 typing_extensions 导入):

# utils/decorator_typing.py
import functools
import time
from typing import Callable, TypeVar

# 兼容:若 Python 版本较低,可从 typing_extensions 导入 ParamSpec
try:
    from typing import ParamSpec  # Python 3.10+
except Exception:
    from typing_extensions import ParamSpec  # type: ignore

P = ParamSpec("P")
R = TypeVar("R")

举个例子:一个通用、严格类型的计时器装饰器

# timing_decorator.py
import functools
import time
from typing import Callable, TypeVar

try:
    from typing import ParamSpec  # Python 3.10+
except Exception:
    from typing_extensions import ParamSpec  # type: ignore

P = ParamSpec("P")
R = TypeVar("R")

def time_calculator(func: Callable[P, R]) -> Callable[P, R]:
    """
    通用计时器装饰器。使用 ParamSpec + TypeVar 保留原函数签名与返回类型。
    """
    @functools.wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        start_time: float = time.time()
        result: R = func(*args, **kwargs)
        end_time: float = time.time()
        elapsed = end_time - start_time
        print(f"[time_calculator] {func.__name__} took {elapsed:.6f} seconds")
        return result
    return wrapper

# 用法示例
@time_calculator
def add(a: int, b: int) -> int:
    time.sleep(0.2)
    return a + b

@time_calculator
def say(msg: str) -> None:
    time.sleep(0.1)
    print(msg)

说明:Callable[P, R] 明确了传入函数的参数和返回值类型,Pylance strict 不会再飘红,IDE 也能正确推断被装饰函数的签名(例如 add 仍然被认为接受 int, int 并返回 int)。


带参数的装饰器(装饰器工厂)

带参数的装饰器需要额外一层包装。下面示例是 repeat(n):执行函数 n 次并返回最后一次结果。

from typing import Callable, TypeVar

try:
    from typing import ParamSpec
except Exception:
    from typing_extensions import ParamSpec  # type: ignore

import functools
import time

P = ParamSpec("P")
R = TypeVar("R")

def repeat(n: int) -> Callable[[Callable[P, R]], Callable[P, R]]:
    def decorator(func: Callable[P, R]) -> Callable[P, R]:
        @functools.wraps(func)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
            last: R
            for _ in range(n):
                last = func(*args, **kwargs)
            return last
        return wrapper
    return decorator

# 用法:
@repeat(3)
def greet(name: str) -> str:
    print("greet", name)
    return f"hello {name}"

类型注解 Callable[[Callable[P, R]], Callable[P, R]] 精确标注了工厂返回类型,Pylance strict 不会警告。


装饰异步函数(async)——严格类型

如果被装饰函数是 async def(返回 Awaitable[T]),我们也可以写出严格的注解。

import asyncio
import functools
from typing import Callable, TypeVar, Awaitable

try:
    from typing import ParamSpec
except Exception:
    from typing_extensions import ParamSpec  # type: ignore

P = ParamSpec("P")
T = TypeVar("T")

def async_time_calculator(func: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]:
    @functools.wraps(func)
    async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
        start_time = asyncio.get_event_loop().time()
        result = await func(*args, **kwargs)
        end_time = asyncio.get_event_loop().time()
        print(f"[async_time_calculator] {func.__name__} took {end_time - start_time:.6f} seconds")
        return result
    return wrapper

# 用法:
@async_time_calculator
async def async_add(a: int, b: int) -> int:
    await asyncio.sleep(0.1)
    return a + b

注意:此处 Callable[P, Awaitable[T]] -> Callable[P, Awaitable[T]] 明确声明了这是装饰异步函数的签名。


类装饰器(用类实现的装饰器)

类装饰器有时用于需要保存状态的场景。类型注解写法也可以很严格:

from typing import Callable, TypeVar

try:
    from typing import ParamSpec
except Exception:
    from typing_extensions import ParamSpec  # type: ignore

import functools

P = ParamSpec("P")
R = TypeVar("R")

class CountCalls:
    def __init__(self, func: Callable[P, R]) -> None:
        functools.update_wrapper(self, func)
        self._func = func
        self.count = 0

    def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R:
        self.count += 1
        return self._func(*args, **kwargs)

# 用法
@CountCalls
def foo(x: int) -> int:
    return x * 2

Pylance 会知道 CountCalls 被用于函数并不会飘红(注意 __call__ 用上 P.args / P.kwargs)。


在方法上使用装饰器

方法(实例方法)本质上多了一个 self 参数,使用 ParamSpec 时签名会把 self 包含进去,因此无需特殊处理:

class Greeter:
    @time_calculator
    def greet(self, name: str) -> None:
        print("Hello", name)

Pylance strict 能正常推断 greet(self, name: str) -> None


组合装饰器与 functools.wraps

始终在 wrapper 上使用 functools.wraps 来保留 __name____doc____annotations__ 等。对于类型检查器和调试体验都大有裨益。


5) 注意事项与兼容性

  1. ParamSpec 支持
    • Python 3.10+:from typing import ParamSpec
    • Python 3.8/3.9:from typing_extensions import ParamSpec
    • 上面示例已包含兼容写法(try / except)。
  2. 不要使用裸 Callable(会触发 reportMissingTypeArgument)。始终写成 Callable[..., Any](若你确实要接受任意签名),但更好是用 ParamSpec 保留签名:Callable[P, R]
  3. async 装饰器:同步装饰器不能直接包裹 async 函数(因为 wrapper 不是 async 会返回 coroutine 而非 awaited 结果)。为 async 函数写专用的 async 装饰器,类型签名应使用 Awaitable[T]
  4. 第三方类型工具:若你使用 mypy,ParamSpec / TypeVar 的写法也能被正确识别。若你的 CI 运行在旧环境,请确保 typing_extensions 在依赖里。
  5. 保留返回值类型:装饰器应该尽量返回与原函数相同类型的结果(除非故意改变),这样静态类型检查器才好推断。

更多推荐