装饰器
装饰器能给一个函数加点东西,又不改函数自己的代码:前后各说一句话、记下每次调用、量一量花了多久。它靠的两件事你都见过了:函数是值,可以传来传去(第 19 课);函数里面还能定义函数(第 18 课)。这一课把它们合起来。
函数进,函数出
函数可以接受另一个函数当实参,然后调用它:
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
函数也可以造一个函数,再把它返回。里面的函数记得外面那个函数的变量,哪怕外面的函数已经结束了:
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))
make_multiplier(10) 返回函数 multiply,它的 factor 是 10。第二对括号用 n = 4 调用这个函数:4 × 10 = 40。
包一层
把这两件事合起来:一个函数接受一个函数,返回一个新函数;新函数多做点事,中间调用原来的函数。这个新函数一般叫 wrapper。
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。意思完全一样。
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("准备好了")
函数一定义好,@announce 就调用 announce(light_lamp),所以它最先显示。func.__name__ 是函数名字的字符串。light_lamp 从来没被调用,所以「亮了」不会显示。
给带参数的函数加装饰器
上面的 wrapper 不接受实参,所以只能装饰不要实参的函数。拿它装饰 add(a, b),再调用 add(2, 3) 就会出 TypeError:wrapper 要 0 个实参,却给了 2 个。办法是第 17 课的 *args 和 **kwargs:wrapper 什么实参都收,再原样转交出去,还要把原来函数的返回值返回。
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 课),两数相减就是秒数。
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 的名字和文档字符串,不是它自己的。
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),它就把原来函数的名字和文档字符串复制过来。
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__ |
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 没有括号,但每次读它,那个方法都会运行一次。
练一练
写好代码,点检查。提示随便看,实在想不出来再看答案。
响两次
✓写出装饰器 twice,让被装饰的函数一调用就运行两次。
叮! 叮!
1def twice(func):2 pass3 4@twice5def ring():6 print("叮!")7 8ring()
喊出来
✓写出装饰器 shout:拿到什么实参就用什么实参调用函数,把结果变成大写再返回。
GOOD EVENING, WICK
1def shout(func):2 pass3 4@shout5def greet(name, greeting="Hello"):6 return f"{greeting}, {name}"7 8print(greet("Wick", greeting="Good evening"))
留住名字
✓最后一行显示的是 wrapper。给装饰器加一行,让它显示函数真正的名字。
light_lamp
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__)
这一课学会了
- 函数可以接受函数当实参,也可以返回新的函数;里面的函数记得外面函数的变量。
- 装饰器接受一个函数,返回一个 wrapper,在调用原函数的前后多做点事。
def上面的@装饰器是名字 = 装饰器(名字)的简写,在函数定义时运行一次。- wrapper 写成
*args, **kwargs,原样转交,再返回结果,就能用在任何函数上。 @functools.wraps(func)留住原来的名字和文档字符串;@property、@staticmethod、@classmethod、@dataclass(来自dataclasses)是 Python 自带的。