Python 中的装饰器

Python 中的装饰器

首先,假设我们有一个函数get_remote_resource接受一个参数res_id并且返回要获取取的后台资源。
有一个函数get_resource_x_png,获取一个名为x.png的图片。

1
2
3
4
5
6
def get_remote_resource(res_id):
...

def get_resource_x_png():
res_id = 'x.png'
return get_remote_resource(res_id)

这时,假设我们引入了缓存技术,那么自然在获取后台资源之前,我们需要检测当前资源是否在缓存中存在。

1
2
3
4
5
6
7
8
9
10
11
12
def get_remote_resource(res_id):
...

def exists_in_cache(res_id):
return res_id in cache

def get_resource_x_png():
res_id = 'x.png'
if exists_in_cache(res_id):
return cache[res_id]
else:
return get_remote_resource(res_id)

如上代码所示,我们直接在get_resource_x_png中加入缓存检测的逻辑。
这样的代码会导致获取资源的逻辑和检测缓存的逻辑之间出现紧耦合的现象。
那么也就无法在没有缓存的环境下重用get_resource_x_png,导致函数变得不灵活。
最好的我们能通过某种手段剥离缓存检测的逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def get_remote_resource(res_id):
...

def exists_in_cache(func):
def wrapper():
res_id = 'x.png'
if res_id in cache:
return cache[res_id]
else:
return func()
return wrapper

@exists_in_cache
def get_resource_x_png():
res_id = 'x.png'
return get_remote_resource(res_id)

如上述代码所示,我们采用了一种装饰器的技巧。
其中exists_in_resource就是一个装饰器,它接受一个函数func作为参数,并且返回一个函数wrapper
wrapper会检查x.png在缓存中是否存在,如果存在则返回缓存中的结果,如果不存在这调用func并返回结果。

其中@exists_in_cache是 Python 的一种语法糖,其效果类似于调用exists_in_cache(get_resource_x_png)并将返回的函数重新赋值给get_resource_x_png
这样在使用时,可以直接使用同一个函数名字,不增加命名负担。

1
get_resource_x_png = exists_in_cache(get_resource_x_png)

如果对于装饰器本身如何工作不理解的话,请查阅 Python 中的闭包


假设我们希望修改函数get_resource_x_png,通过传递资源 ID,获取对应的资源。
由于之前的装饰器在调用被装饰函数时并没有传递参数,所以会导致调用失败,造成异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def get_remote_resource(res_id):
...

def exists_in_cache(func):
def wrapper(*args, **kwargs):
res_id = kwargs['res_id'] if 'res_id' in kwargs else args[0]
if res_id in cache:
return cache[res_id]
else:
return func(*args, **kwargs)
return wrapper

@exists_in_cache
def get_resource(res_id):
return get_remote_resource(res_id)

这时,我们就需要修改装饰器内部的wrapper函数, 添加argskwargs接受任意参数,并且将接收到的参数传递给被装饰的函数get_resource
res_id可以通过判断外部的传参方式来获取,如果是关键字传参字,则直接获取;如果不是,则通过 args 元组获取参数。


假设,我们又有一个新函数get_resource_by_type,改函数使用的缓存是另一个缓存type_cache
那么我们该如何通过修改装饰器来使代码保持一致呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def get_remote_resource(res_id):
...

def exists_in_cache(cache):
def decorator(func):
def wrapper(*args, **kwargs):
res_id = kwargs['res_id'] if 'res_id' in kwargs else args[0]
if res_id in cache:
return cache[res_id]
else:
return func(*args, **kwargs)
return wrapper
return decorator

@exists_in_cache(cache)
def get_resource(res_id):
return get_remote_resource(res_id)

@exists_in_cache(type_cache)
def get_resource_by_type(res_type):
...

我们用一个高级一点的带参数装饰器
现在exists_in_cache是一个函数接受一个cache作为参数,并且返回一个decorator
这个decorator的构成和上一个代码中的exists_in_cache函数一致。

那么这里发生了一些什么呢?
其实带参数装饰器也是一种语法糖,等效于如下代码。
先调用exists_in_cache(type_cache),生成一个新的装饰器(注意,此时内部的cache被绑定到了传递进来的type_cache),然后生成的装饰器再用get_resource_by_type作为参数生成最终的get_resource_by_type

1
get_resource_by_type = exists_in_cache(type_cache)(get_resource_by_type)

通过装饰器等价的代码,我们可以理解到,由装饰器产生的函数get_resource包含的元信息(函数名get_resource.name,函数文档get_resource.doc)和我们定义的get_resource不一样了。
原因是实际上我们调用的不是get_resource而是在装饰器内部定义的wrapper函数,自然函数元信息会不一样。
考虑到有些情况下,会处理元信息,为了保留该元信息。
Python 为我们引入了functools.wraps

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def get_remote_resource(res_id):
...

def exists_in_cache(cache):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
res_id = kwargs['res_id'] if 'res_id' in kwargs else args[0]
if res_id in cache:
return cache[res_id]
else:
return func(*args, **kwargs)
return wrapper
return decorator

@exists_in_cache(cache)
def get_resource(res_id):
return get_remote_resource(res_id)

@exists_in_cache(type_cache)
def get_resource_by_type(res_type):
...

如此一来即使是装饰后的函数也会包含一样的元信息。


最后补充一点,装饰器是可以叠加任意层数的,类似如下:

1
2
3
4
5
@C
@B
@A
def f():
...

由于装饰的顺序不同可能产生的效果也会不同,所以使用时需要认真考虑。