Python 装饰器(Decorator)完全入门与实践

你有没有遇到过这种场景? 写完了一堆核心业务函数,突然想给每个函数加个执行日志; 又或者发现几个性能敏感的函数,需要批量加上耗时统计; 如果要直接在每个函数开头结尾加重复代码,不仅麻烦,后续维护起来还要改一堆地方——这时候,Python 装饰器就成了你的救星!

1. 装饰器基础概念

装饰器本质上是一种高阶函数,要彻底理解它,得先搞懂两个前置知识点。

1.1 函数也是对象

在 Python 中,函数和整数、字符串、列表一样,都是“一等公民”:

  • 可以赋值给变量
  • 可以作为另一个函数的参数
  • 可以作为另一个函数的返回值
  • 可以存在列表/字典里

先看赋值的小例子:

def get_current_date():
    print('2024-06-01')

f = get_current_date  # 不带括号!是把函数对象本身赋值给变量
f()                   # 带括号才是调用函数
# 输出: 2024-06-01

# 每个函数对象都有专属元属性,比如 __name__ 显示函数名
print(get_current_date.__name__)  # 输出: get_current_date
print(f.__name__)                 # 输出: get_current_date(变量f只是函数的“别名”)

1.2 高阶函数

简单来说,接受函数为参数,或返回函数的函数,就是高阶函数。

刚才的赋值例子已经满足了“函数作为对象”的条件,我们再写一个简单的高阶前置函数:

def prepare_exec(func):
    print("=== 准备执行函数 ===")
    func()  # 直接调用传入的函数
    print("=== 函数执行完毕 ===")

def say_hello():
    print("Hello Python Deco!")

prepare_exec(say_hello)
# 输出:
# === 准备执行函数 ===
# Hello Python Deco!
# === 函数执行完毕 ===

这个例子可以工作,但每次调用 say_hello 都要手动包一层 prepare_exec,太繁琐了。能不能让 prepare_exec 直接“替换” say_hello,以后直接用 say_hello() 就行?

这就引出了装饰器的核心雏形:返回新函数的高阶函数。

2. 入门:实现第一个装饰器

2.1 基础装饰器 + 语法糖

我们修改刚才的 prepare_exec,让它返回一个“包装函数” wrapper,这个包装函数会先执行额外功能,再调用原函数:

def log(func):
    def wrapper(*args, **kwargs):  # *args/kwargs 接受任意参数,原函数怎么调,wrapper就能怎么调
        print(f'调用 {func.__name__}() 函数')
        return func(*args, **kwargs)  # 必须返回原函数的返回值,否则原功能就丢了!
    return wrapper

然后用 Python 提供的装饰器语法糖 @,把它贴在需要增强的函数头上:

@log
def get_current_date():
    print('2024-06-01')

@log
def add(a, b):
    return a + b

# 调用方式完全不变!
get_current_date()
# 输出:
# 调用 get_current_date() 函数
# 2024-06-01

result = add(1, 2)
print(result)
# 输出:
# 调用 add() 函数
# 3

注意! @log 放在函数上面,等价于 Python 解释器自动帮你执行了:

get_current_date = log(get_current_date)

2.2 保留原函数的元信息

刚才的装饰器有个小bug:包装后函数的元属性变了!

print(get_current_date.__name__)  # 输出: wrapper
print(help(get_current_date))    # 输出会指向 wrapper 的 docstring(如果有的话)

这会给后续的代码调试、文档生成、依赖元属性的工具带来麻烦。修复方式很简单,用 Python 标准库的 functools.wraps

import functools  # 引入标准库

def log(func):
    # 自动把原函数 func 的 __name__、__doc__、__module__ 等元属性复制给 wrapper
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f'调用 {func.__name__}() 函数')
        return func(*args, **kwargs)
    return wrapper

现在再试,元属性就正常了:

print(get_current_date.__name__)  # 输出: get_current_date

3. 进阶:带自定义参数的装饰器

如果想给装饰器加个前缀,比如有时用 [INFO] 有时用 [DEBUG],怎么办?

需要再嵌套一层函数!最外层接收自定义参数,中间层是原来的装饰器,最内层是包装函数:

import functools

def log(prefix="[INFO]"):  # 最外层:接受自定义参数,给个默认值让它更通用
    def decorator(func):   # 中间层:就是原来的“返回wrapper的高阶函数”
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            print(f'{prefix} 调用 {func.__name__}()')
            return func(*args, **kwargs)
        return wrapper
    return decorator

使用方式分两种:

# 带自定义参数的用法
@log(prefix="[DEBUG]")
def add(a, b):
    return a + b

# 用默认参数的用法(必须带括号!如果不带,请看4.2的通用装饰器)
@log()
def get_current_date():
    print('2024-06-01')

三层嵌套的原理可以分两步看:

  1. 先执行 log(prefix="[DEBUG]"),拿到返回值 decorator
  2. 再执行 @decorator,也就是 add = decorator(add)

4. 实践:两个常用装饰器

4.1 计算函数执行时间

性能优化时经常用到,注意用 time.perf_counter() 代替 time.time(),因为前者是高精度短时间计时器,不受系统时间调整的影响:

import time
import functools

def metric(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        end = time.perf_counter()
        print(f'{func.__name__} 执行耗时: {(end - start)*1000:.2f}ms')
        return result
    return wrapper

@metric
def fast_add(x, y):
    time.sleep(0.0012)
    return x + y

@metric
def slow_multiply(x, y, z):
    time.sleep(0.1234)
    return x * y * z

4.2 通用日志装饰器(两种用法都支持)

如果觉得带默认参数的装饰器必须写 @log() 太麻烦,可以优化成“带不带括号都能用”的通用版本:

import functools

def log(text_or_func=None):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            if callable(text_or_func):
                # 不带括号的情况:text_or_func 其实是原函数
                print(f'调用 {func.__name__}()')
            else:
                # 带括号的情况:text_or_func 是自定义前缀
                print(f'{text_or_func} 调用 {func.__name__}()')
            return func(*args, **kwargs)
        return wrapper
    
    # 判断第一个参数是函数还是文本
    if callable(text_or_func):
        func = text_or_func
        text_or_func = None
        return decorator(func)
    else:
        return decorator

两种用法都完美兼容:

@log
def func1():
    pass

@log("[DEBUG]")
def func2():
    pass

5. 高级:用类实现装饰器

装饰器也可以用类来实现,主要优势有两个:

  1. 可以方便地保存状态(比如统计函数被调用多少次)
  2. 可以通过继承扩展装饰器功能

5.1 简单的类装饰器

类装饰器需要实现 __init____call__ 两个方法:

  • __init__:接收原函数作为参数,并保留元信息
  • __call__:相当于之前的 wrapper,执行额外功能并调用原函数
import functools

class Logger:
    def __init__(self, func):
        self.func = func
        # 类装饰器没有 @functools.wraps 方便,用 update_wrapper 代替
        functools.update_wrapper(self, func)
    
    def __call__(self, *args, **kwargs):
        print(f'调用 {self.func.__name__}()')
        return self.func(*args, **kwargs)

@Logger
def get_current_date():
    print('2024-06-01')

5.2 带状态的类装饰器(统计调用次数)

这是类装饰器最常用的场景之一:

import functools

class CountCalls:
    def __init__(self, func):
        self.func = func
        self.count = 0  # 用实例属性保存调用次数
        functools.update_wrapper(self, func)
    
    def __call__(self, *args, **kwargs):
        self.count += 1
        print(f"{self.func.__name__} 已被调用 {self.count} 次")
        return self.func(*args, **kwargs)

@CountCalls
def say_hello():
    print("Hello!")

say_hello()
say_hello()
# 输出:
# say_hello 已被调用 1 次
# Hello!
# say_hello 已被调用 2 次
# Hello!

6. 装饰器最佳实践

  1. 始终保留原函数元信息:用 functools.wraps(函数装饰器)或 functools.update_wrapper(类装饰器)。
  2. 单一职责原则:一个装饰器只做一件事,比如计时装饰器别顺便记日志,日志装饰器别顺便加权限。
  3. 保持可配置性:尽量让装饰器接受可选参数,比如之前的通用日志装饰器。
  4. 避免过度使用:装饰器会增加一层抽象,套三四层以上会让代码执行流程变得不直观,调试困难。

7. 总结

现在再回头看开头的批量加日志、批量加计时的问题,是不是觉得很简单了?只要写好一个装饰器,往所有需要的函数头上加个 @ 就行,完全不用修改核心业务代码。

装饰器的核心价值在于:

  • 非侵入式扩展:不修改原函数的一行代码
  • 处理横切关注点:日志、计时、权限检查、Python自带的@functools.lru_cache缓存等,和核心业务无关但又很多地方用到的功能
  • 代码更简洁、更Pythonic

掌握装饰器,绝对能让你的Python代码提升一个档次!现在就去给你的现有函数试试吧!