Python 与数据实践 · 第 10 章

装饰器

写函数写着写着,你会发现一堆函数共享着同样的"周边动作":执行前打个日志、计时、鉴权、缓存结果、重试……要是每个函数都手抄一遍这些代码,既臃肿又难维护。装饰器就是解决这件事的语法工具:把"周边动作"抽成一个可复用的壳,往…

约 8 分钟3,119 字9 个代码示例

写函数写着写着,你会发现一堆函数共享着同样的"周边动作":执行前打个日志、计时、鉴权、缓存结果、重试……要是每个函数都手抄一遍这些代码,既臃肿又难维护。装饰器就是解决这件事的语法工具:把"周边动作"抽成一个可复用的壳,往函数身上一套,函数就自带这些能力了。

好消息是,学完前面两章,装饰器对你来说几乎没有任何新知识——它不过是函数是一等公民、闭包、__call__ 这几块积木的拼装。这一章我们把拼法讲透。


1. 装饰器的本质:接收函数,交还函数

先抛开 @ 符号,看装饰器"素颜"的样子。假设有个发布应用的函数,我们希望它执行前后自动进出维护模式:

def maintenance_window(func):
    def wrapper():
        print("进入维护模式")
        func()
        print("退出维护模式,恢复服务")
    return wrapper


def deploy_app():
    print("发布新版本……")


# 手动给函数"套壳"
deploy_app = maintenance_window(deploy_app)

deploy_app()
# 输出:
# 进入维护模式
# 发布新版本……
# 退出维护模式,恢复服务

看清楚发生了什么:maintenance_window 接收旧函数 deploy_app,返回新函数 wrapper;我们用新函数顶替了原名。之后调用 deploy_app(),跑的其实是"壳 + 原函数"的组合。

Python 为这个"套壳再顶替"的动作准备了专门语法——@,称为装饰器语法糖:

@maintenance_window
def deploy_app():
    print("发布新版本……")


# 上面的写法完全等价于:
# def deploy_app():
#     print("发布新版本……")
# deploy_app = maintenance_window(deploy_app)

也就是说,@ 后面跟的名字会被当成函数调用,底下定义的函数作为参数传进去,返回值顶替原函数名。仅此而已。

2. 用一句话给装饰器定性

结合第 08 章的知识,可以给装饰器下一个精确的定义:

装饰器是一个可调用对象,它接收一个可调用对象,可以返回任何东西。

两点展开说:

  • "可调用对象"不限于函数——实现了 __call__ 的类实例同样能当装饰器(上一章手写的 MyProp 就是活例子:@MyProp 接收方法,返回的是一个描述符对象,根本不是函数);
  • "返回任何东西"意味着语法上允许返回非可调用对象,但那样一来 被装饰的名字(...) 就没法调用了。所以为了让程序照常运行,装饰器通常返回另一个可调用对象来顶替原对象。

把这条定义想通,各类框架里千奇百怪的装饰器就都不神秘了。

3. 多个装饰器叠着用

装饰器可以套好几层,规则只有四个字:就近包裹——离函数最近的装饰器先套,然后一层层往外:

@outer
@inner
def task():
    pass


# 完全等价于:
# task = outer(inner(task))

执行装配时自下而上(inner 先套),实际调用时则由外向内穿过各层壳。可以想象成给礼物打包:先把礼物装进内层盒子,再套外层盒子;拆的时候自然先拆外层。

4. 动手前的两件小工具

本章习题会用到 time 模块的两个功能,先热热身:

  • time.time():返回当前时间戳(一个浮点数,表示从 1970 年至今的秒数);
  • time.sleep(seconds):让程序原地暂停指定秒数。
import time

start = time.time()
time.sleep(1)
elapsed = time.time() - start
print(f"耗时: {elapsed} 秒")  # 耗时: 1.0003 秒 左右

两件事一组合,就能测出任意函数跑了多久——这正是习题一要做的。


习题

一、秒表装饰器 stopwatch

编写装饰器 stopwatch,自动打印被装饰函数的执行耗时(保留 4 位小数):

import time

def stopwatch(func):
    # 你的代码
    pass


@stopwatch
def build_report():
    time.sleep(1)
    return "报表已生成"


build_report()
# 输出:build_report 执行耗时: 1.0012 秒

二、保住函数的"身份证":keep_identity

函数被套壳后,__name__ 会变成 wrapper、__doc__ 会丢失。请实现装饰器 keep_identity,把原函数的名字和注释"移植"给新函数:

def keep_identity(func):
    # 你的代码
    pass


def with_banner(func):
    @keep_identity(func)
    def wrapper():
        print("====== 开始 ======")
        func()
        print("====== 结束 ======")

    return wrapper


@with_banner
def welcome():
    """欢迎致辞"""
    print("欢迎各位!")


welcome()
# 输出:
# ====== 开始 ======
# 欢迎各位!
# ====== 结束 ======
print("name:", welcome.__name__)  # name: welcome
print("doc:", welcome.__doc__)    # doc: 欢迎致辞

三、复读机装饰器 echo

编写一个带参数的装饰器 echo(n),让被装饰函数每次调用都自动重复执行 n 次:

def echo(n):
    # 你的代码
    pass


@echo(3)
def notify(msg):
    print(msg)


notify("开饭了")
# 输出:
# 开饭了
# 开饭了
# 开饭了

四、缓存装饰器 memoize

编写装饰器 memoize:把函数的"参数 → 结果"映射存进字典,同样的参数再次调用时直接查表返回,不再重复计算:

def memoize(func):
    # 你的代码
    pass


@memoize
def climb(n):
    """爬楼梯:每次可以走 1 阶或 2 阶,返回走完 n 阶的方案总数"""
    if n <= 2:
        return n
    return climb(n - 1) + climb(n - 2)


print(climb(60))  # 应当瞬间出结果

提示: 用字典做"参数 → 结果"的登记簿;注意 climb 内部是递归调用,装饰器接住的是同一个名字,所以递归层也会吃到缓存——这正是它飞快的秘密。

五、类装饰器 auto_dict

装饰器不仅能修饰函数,还能修饰类。编写类装饰器 auto_dict,自动给类安上一个 to_dict 方法,把实例属性打包成字典:

def auto_dict(cls):
    # 你的代码
    pass


@auto_dict
class Song:
    def __init__(self, title, seconds):
        self.title = title
        self.seconds = seconds


song = Song("晴天", 269)
print(song.to_dict())  # {'title': '晴天', 'seconds': 269}

下一章:11 · 迭代协议与生成器