Kodewick
第 30 课,共 31 课目录

装饰器

第 30 课,共 31 课 · 第 6 部分 · 进阶 · 约 20 分钟

装饰器能给一个函数加点东西,又不改函数自己的代码:前后各说一句话、记下每次调用、量一量花了多久。它靠的两件事你都见过了:函数是值,可以传来传去(第 19 课);函数里面还能定义函数(第 18 课)。这一课把它们合起来。

函数进,函数出

函数可以接受另一个函数当实参,然后调用它:

main.py
1def add_one(n):2    return n + 13 4def use_twice(func, value):5    return func(func(value))6 7print(use_twice(add_one, 5))
输出
7

函数也可以造一个函数,再把它返回。里面的函数记得外面那个函数的变量,哪怕外面的函数已经结束了:

main.py
1def make_multiplier(factor):2    def multiply(n):3        return n * factor4    return multiply5 6double = make_multiplier(2)7triple = make_multiplier(3)8print(double(5))9print(triple(5))
输出
10
15
先猜一猜

最后一行显示什么?

1def make_multiplier(factor):2    def multiply(n):3        return n * factor4    return multiply5 6print(make_multiplier(10)(4))

包一层

把这两件事合起来:一个函数接受一个函数,返回一个新函数;新函数多做点事,中间调用原来的函数。这个新函数一般叫 wrapper。

main.py
1def announce(func):2    def wrapper():3        print("Wick 拿起梯子")4        func()5        print("Wick 放下梯子")6    return wrapper7 8def light_lamp():9    print("灯亮了")10 11light_lamp = announce(light_lamp)12light_lamp()
输出
Wick 拿起梯子
灯亮了
Wick 放下梯子

announce 就是一个装饰器。light_lamp = announce(light_lamp) 这一行让名字 light_lamp 指向 wrapper,而 wrapper 手里还拿着原来的函数,名字叫 func。点单步,跟着调用从 wrapper 走进原来的函数,再走回来。

@ 写法

像 light_lamp = announce(light_lamp) 这样,函数名要写三遍,挺笨的。Python 有个简写:在 def 上面一行写 @announce。意思完全一样。

main.py
1def announce(func):2    def wrapper():3        print("Wick 拿起梯子")4        func()5        print("Wick 放下梯子")6    return wrapper7 8@announce9def light_lamp():10    print("灯亮了")11 12light_lamp()
输出
Wick 拿起梯子
灯亮了
Wick 放下梯子

这样一来,谁读到 light_lamp 都马上知道它被装饰过。装饰器只在运行到 def 时运行一次,不是每次调用函数都运行:

先猜一猜

输出的第一行是什么?

1def announce(func):2    print("包装", func.__name__)3    return func4 5@announce6def light_lamp():7    print("亮了")8 9print("准备好了")

给带参数的函数加装饰器

上面的 wrapper 不接受实参,所以只能装饰不要实参的函数。拿它装饰 add(a, b),再调用 add(2, 3) 就会出 TypeError:wrapper 要 0 个实参,却给了 2 个。办法是第 17 课的 *args 和 **kwargs:wrapper 什么实参都收,再原样转交出去,还要把原来函数的返回值返回。

main.py
1def log_call(func):2    def wrapper(*args, **kwargs):3        print("调用", func.__name__, args, kwargs)4        result = func(*args, **kwargs)5        print("返回", result)6        return result7    return wrapper8 9@log_call10def add(a, b):11    return a + b12 13@log_call14def greet(name, greeting="你好"):15    return f"{greeting},{name}!"16 17add(2, 3)18greet("Wick", greeting="晚上好")
输出
调用 add (2, 3) {}
返回 5
调用 greet ('Wick',) {'greeting': '晚上好'}
返回 晚上好,Wick!

几乎所有装饰器都是这个样子。现在一个装饰器就能用在任何函数上,不管它要什么实参。忘了写 return result,被装饰的函数就都返回 None 了。

计时装饰器

一个经典用法:不碰函数的代码,量一量它花了多久。调用前后各读一次 time.perf_counter()(第 25 课),两数相减就是秒数。

main.py
1import time2 3timings = {}4 5def timed(func):6    def wrapper(*args, **kwargs):7        start = time.perf_counter()8        result = func(*args, **kwargs)9        elapsed = time.perf_counter() - start10        timings[func.__name__] = elapsed11        return result12    return wrapper13 14@timed15def total_ore(carts):16    return sum(range(carts))17 18print(total_ore(10_000))19print(list(timings))20print(timings["total_ore"] >= 0)
输出
49995000
['total_ore']
True

在自己的程序里,wrapper 直接把时间显示出来就行,比如用 f"{elapsed:.4f} 秒"。这里改成存进一个字典,因为这个数每次运行、每台电脑都不一样。能看到的是:结果原样传了回来,时间也按函数名记下了。

functools.wraps

包一层有个小副作用:被装饰的函数报出来的是 wrapper 的名字和文档字符串,不是它自己的。

main.py
1def log_call(func):2    def wrapper(*args, **kwargs):3        return func(*args, **kwargs)4    return wrapper5 6@log_call7def add(a, b):8    """把两个数加起来。"""9    return a + b10 11print(add.__name__)12print(add.__doc__)
输出
wrapper
None

这会让报错信息、日志和用 help() 的人都看糊涂。functools 模块正好有一个装饰器解决这个问题:在 wrapper 上加 @functools.wraps(func),它就把原来函数的名字和文档字符串复制过来。

main.py
1import functools2 3def log_call(func):4    @functools.wraps(func)5    def wrapper(*args, **kwargs):6        return func(*args, **kwargs)7    return wrapper8 9@log_call10def add(a, b):11    """把两个数加起来。"""12    return a + b13 14print(add.__name__)15print(add.__doc__)
输出
add
把两个数加起来。

functools.wraps(func) 是一次调用,它返回一个装饰器,所以带括号。养成习惯:写 wrapper 就加上它。

Python 自带的装饰器

第 28 课已经见过 @dataclass。Python 还有几个给类里的方法用的装饰器:

装饰器做什么
@property一个像属性那样读的方法,不用加括号
@staticmethod一个既不要 self 也不要类的方法
@classmethod拿到的不是对象而是类(cls)的方法,常用来造对象
@dataclass(来自 dataclasses)按类里的字段替你写好 __init__、__repr__ 和 __eq__
main.py
1class Lamp:2    def __init__(self, oil):3        self.oil = oil4 5    @property6    def is_lit(self):7        return self.oil > 08 9    @staticmethod10    def max_oil():11        return 1012 13    @classmethod14    def full(cls):15        return cls(cls.max_oil())16 17lamp = Lamp.full()18print(lamp.oil)19print(lamp.is_lit)20print(Lamp.max_oil())
输出
10
True
10

Lamp.full() 是在类上调用的,不是在某盏灯上,它造出一盏油加满的灯。lamp.is_lit 没有括号,但每次读它,那个方法都会运行一次。

练一练

写好代码,点检查。提示随便看,实在想不出来再看答案。

练习 1

响两次

写出装饰器 twice,让被装饰的函数一调用就运行两次。

要显示
叮!
叮!
exercise1.py
1def twice(func):2    pass3 4@twice5def ring():6    print("叮!")7 8ring()
输出
点「运行」或按 Ctrl+Enter
练习 2

喊出来

写出装饰器 shout:拿到什么实参就用什么实参调用函数,把结果变成大写再返回。

要显示
GOOD EVENING, WICK
exercise2.py
1def shout(func):2    pass3 4@shout5def greet(name, greeting="Hello"):6    return f"{greeting}, {name}"7 8print(greet("Wick", greeting="Good evening"))
输出
点「运行」或按 Ctrl+Enter
练习 3

留住名字

最后一行显示的是 wrapper。给装饰器加一行,让它显示函数真正的名字。

要显示
light_lamp
exercise3.py
1import functools2 3def announce(func):4    def wrapper():5        print("开始了")6        func()7    return wrapper8 9@announce10def light_lamp():11    print("亮了")12 13print(light_lamp.__name__)
输出
点「运行」或按 Ctrl+Enter

这一课学会了

  • 函数可以接受函数当实参,也可以返回新的函数;里面的函数记得外面函数的变量。
  • 装饰器接受一个函数,返回一个 wrapper,在调用原函数的前后多做点事。
  • def 上面的 @装饰器 是 名字 = 装饰器(名字) 的简写,在函数定义时运行一次。
  • wrapper 写成 *args, **kwargs,原样转交,再返回结果,就能用在任何函数上。
  • @functools.wraps(func) 留住原来的名字和文档字符串;@property、@staticmethod、@classmethod、@dataclass(来自 dataclasses)是 Python 自带的。