0%

一文彻底搞懂 Python中的装饰器、偏函数

装饰器

要讲清楚装饰器,首先要知道一些前置概念。下文涉及到这些概念的地方,会展开讲述。

什么是装饰器?

装饰器是一种AOP(面向切面编程)的设计模式。

面向对象编程往往需要通过继承或组合依赖等方式调用一些功能,这样可能造成代码的重复,增加了耦合。

而AOP可以在需要的类或方法上切入,切入点可以增强功能,让调用者与被调用者解耦。

这种不修改原来的业务代码,给程序动态添加(或修改)功能的技术,就是装饰器

装饰器可用于日志记录、监控、参数检查等地方。比如业务函数中不应该包含与业务无关的功能,那么可以构建一个logger装饰器,对业务函数增加日志功能。并且logger装饰器可以是通用的,需要日志功能的地方,都可以使用logger装饰器,达到复用的目的。

装饰器的分类

无参数装饰器:无参数装饰器实现的关键是“闭包”,即嵌套函数与自由变量。

带参数装饰器:带参数装饰器实现的关键是“柯里化”,多层嵌套函数可以实现柯里化。

什么是闭包?

在讲什么是闭包前,需要先讲一个概念:自由变量。什么是自由变量呢?

未在本地作用域中定义,就是出现在嵌套函数中,定义在某函数的外层函数的作用域中的变量,叫自由变量

如果某函数(即内层函数)引用了外层函数的自由变量,这样就形成了闭包

比如:

1
2
3
4
5
6
def foo():
x = 100 # 这个x就是自由变量
def bar():
print(x) # 嵌套函数bar就能使用自由变量x(注意是直接使用x而不是通过参数传递给bar函数),这就叫闭包
bar()
foo()

再比如,用列表实现一个计数器:

1
2
3
4
5
6
7
8
9
10
11
12
def counter():
c = [0] # 自由变量c
def inc():
# 这行不会报错,因为这里不是在赋值变量c本身,
# 而是修改c变量里(即列表里)的内容,c保存的列表引用是不变的
c[0] += 1
return c[0]
return inc
foo = counter()
print(foo(), foo()) # 输出 1 2
c = 100 # 这是全局变量c,与函数内的局部变量c无关
print(foo()) # 输出 3

上面代码的第6行,涉及到Python的“未赋值先引用”问题。因为Python是动态语言,对一个变量赋值就是在定义这个变量,就是“赋值即定义”。由于这个特性,会造成下面的问题:

1
2
3
4
5
6
x = 5
def func():
# 或者 x += 1
x = x + 1 # 这里的x是本地变量,这里会抛出未赋值先引用异常
print(x)
func()

上面的 x=x+1 语句为什么会异常呢?如果是静态编译语言,像C语言,这个语句就是对全局变量x加1,没有问题。但是,因为赋值即定义,x=x+1 这个表达式先计算等号右边部分:x+1,此时的x的确是全局变量,即x=5的那个x。

接着计算等号左边部分,就涉及到赋值操作,等号左边的x赋值即定义,那就是在定义局部变量x。这时,会出现冲突:
x=x+1 这个等式里的x到底是全局变量还是函数的本地(局部)变量?

显然,因为抛出了未赋值先引用的异常,这个等式里的x最后变成了本地变量(要不然,赋值即定义这种特性就不复存在了)。解决这个问题的办法之一就是把func内的x声明为全局变量:

1
2
3
4
5
6
x = 5
def func():
global x # 把x声明为全局变量
x = x + 1 # x的结果=6
print(x)
func()

好了。到这里,闭包的概念有了。接下来要讲什么是柯里化。

什么是柯里化?

简单来说,柯里化就是把一个函数的多参数形式转换成函数的单参数连续调用形式。比如:

1
2
3
4
def func(a, b, c):  # 多参数形式
return a+b+c

func(a)(b)(c) # 要变成这样的单参数连续调用的形式

那怎么变呢?用嵌套函数实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def func(a, b, c):
return a+b+c

print(func(1,2,3)) # 返回结果是6

# 嵌套函数func。如果_infunc1函数是业务函数,func函数是装饰函数,装饰器的雏形是不是已经出来了?
def func(a):
def _infunc1(b): # 内层函数1
def _infunc2(c): # 内层函数2
return a+b+c
return _infunc2
return _infunc1

print(func(1)(2)(3)) # 返回也是6,这个就是单参数连续调用

好了。柯里化的概念也有了,而且从上面代码可见,装饰器的雏形已经出来了。

问题:

聪明的你,可能会提出一个问题。因为Python里面一切皆对象,对象是有生命周期的。Python给对象设定引用计数来决定某个对象是否要销毁。如果对象的引用计数=0,这个对象会在恰当的时机被Python的GC(垃圾回收)回收,然后释放出占用的内存。

于是这个问题就是:自由变量为什么不会被销毁呢?

因为自由变量它也是局部变量,而函数内的局部变量是在函数调用时才创建(压入函数栈)。调用完成,局部变量就不需要了,也就没有了。像下面这个例子里的自由变量c,函数counter执行完,c怎么没有被销毁呢,c不也是函数内的局部变量吗?

1
2
3
4
5
6
7
8
9
10
def counter():
c = [0] # c是自由变量,也是局部变量
def inc():
c[0] += 1
return c[0]
return inc

foo = counter()
foo()
foo() # 连续执行foo,返回值从1,2,3...不断累加,表明c变量没有消失

因为,当foo=counter()执行完之后,counter返回它的内部函数inc,即foo=inc,内部函数被变量foo记住了,所以inc的引用计数不可能是0。并且,c是被inc函数引用的,inc没有消失,c怎么能消失呢。c的引用计数也必然不会等于0,所以执行foo()可以不断累加。具体可以看Python的源代码,这里只是反推,不解析源码。

讲了这么多,下面终于可以讲装饰器了。装饰器可以分为这么几种:

函数作为装饰器、类作为装饰器、类的实例作为装饰器(即描述器)。

函数作为装饰器

无参数装饰器

先给出用法:

1
2
3
@logger
def add():
pass

其中 @logger 为装饰器的语法糖,等价于 add=logger(add)。那么logger长什么样呢?如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def logger(fn):     # 这个就是装饰器,用函数构建的装饰器
def wrapper(*args, **kwargs):
print('调用原函数前增强的代码')
ret = fn(*args, **kwargs)
print('调用原函数后增强的代码')
return ret
return wrapper # 返回一个新函数

# 装饰器语法,等价于 add = logger(add),add变量保存的就是wrapper函数,
# add(4,5) 相当于调用 logger(add)(4,5),还记得吗?这种单参数连续调用就是柯里化。
# 而且@logger是无参数装饰器,有参数装饰器形如@logger(a,b),下文讲
@logger
def add(x,y):
return x+y

print(add(4,5))

以上就是用函数logger作为装饰器,去修饰一个加法函数add,而且logger是无参数装饰器。可以看到,一个无参数装饰器是一个两层嵌套函数

装饰器还可以多次使用,规则是:自底向上执行,即靠近被装饰函数的装饰器先执行,远离的后执行。比如:

@logger1
@logger2
@logger3
def add(x,y):
pass

等价形式为:logger1(logger2(logger3(add))) 等价于调用:令f=logger1(logger2(logger3(add))), f(4,5)

就是说,add这个函数被3个装饰器分别修改了3次,形成一个新函数。

带参数装饰器

要实现带参数装饰器,只需要再加一层嵌套函数,形成三层嵌套函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def logger(a, b):
def _logger(fn): # 再套一层函数,外层函数logger就可以带参数了
def wrapper(*args, **kwargs): # kwargs这里没用到,不用管
print(f'增强代码, a={a}, b={b}')
ret = fn(*args, **kwargs)
print(f'增强代码, a+b+add{args}={a+b+ret}')
return ret
return wrapper # 返回一个新函数
return _logger

# 带参数的装饰器,相当于:logger(1,2)=>_logger, _logger(add)=>wrapper, wrapper(4,5)
# 在add被装饰前,等价于执行:logger(1,2)(add)(4,5)
@logger(1, 2)
def add(x,y):
return x+y

print(add(4,5))

# 输出结果
增强代码, a=1, b=2
增强代码, a+b+add(4, 5)=12
9

问题:

到这里,我们又可以提出一个问题:

装饰器如何解决同一个作用域中变量名同名问题?比如:

1
2
3
4
5
6
7
8
9
10
11
def add(fn):
def w(x,y):
print('_in w')
return fn(x, y)
return w

# 装饰器名是add,函数名也是add,假如两个变量都在全局作用域中,而且同名,如何解决同名变量名问题?
@add
def add(x,y):
return x+y
add(3,4)

推测:

装饰器语法执行时,被装饰函数虽然名叫add,但实际定义该函数后,函数对象直接赋值给了装饰器add的参数fn,类似于 fn=lambda x,y: x+y,被装饰函数名add实际上并不存在。所以,也就不存在相同变量名(add)冲突的问题(根据上面源码debug后观察到)。

上面讲的是,函数作为装饰器,去装饰一个函数。函数装饰器也可以去装饰一个类。

用函数装饰类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def add_name(name='tom', lang='cn'):
def wrapper(cls):
cls.NAME = name
cls.LANG = lang
return cls
return wrapper

@add_name() # 给类添加新属性NAME与LANG
class Person:
AGE = 13
def show(self):
print(self)
p = Person()
p.NAME, p.LANG

类作为装饰器

同样,类也可以作为装饰器。去装饰函数或者类。

用类装饰函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Log(object):
def __init__(self, msg1, msg2):
self.msg1 = msg1
self.msg2 = msg2

def __call__(self, fn): # 让类的实例可调用
def wrapper(*args, **kwargs):
print(f'msg1 = {self.msg1}')
ret = fn(*args, **kwargs)
print(f'msg2 = {self.msg2}')
return ret
return wrapper

@Log('start', 'end') # 相当于:add=Log(...)(add)=callable_obj(add)=add
def add1(x, y):
return x + y

用类装饰类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class ClsName(object):
def __init__(self, prefix):
self.prefix = prefix

def __call__(self, cls): # 让实例可调用
cls.name = self.prefix + ' ' + cls.__name__
return cls


@ClsName('class name is') # A1=ClsName(...)(A1)=callable_obj(A1)=A1
class A1(object):
def __init__(self):
pass

a = A1()
a.name

最后,用with可以实现类似装饰器的效果。

with上下文管理实现装饰器的效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import time

class TimeIt: # 构建上下文,达到类似装饰器的目的
def __init__(self, fn):
self.fn = fn

def __enter__(self):
self.start = time.time()
return self

def __exit__(self, tp, val, tb):
print(f'TimeIt: {time.time()-self.start}')

def __call__(self, *args, **kwargs):
return self.fn(*args, **kwargs)

def add(x, y, z):
time.sleep(2)
return x+y+z

with TimeIt(add) as f:
print(f(2, 3, 4))

严格地讲,with这个用法不是装饰器,但它达到了装饰器具有的一些能力,比如这里的统计函数执行时间。

类的实例作为装饰器(描述器)

类的实例作为装饰器,通常用作描述器。

什么是描述器?详见 一文彻底搞懂 Python中的描述器

偏函数

什么是偏函数?

偏函数:把一个函数的部分参数固定下来,就是为部分参数添加固定的默认值,形成一个新的函数并返回。

偏函数可以配合嵌套函数实现带参数装饰器。Python标准库提供了偏函数 functools.partial。partial的等价函数为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 等价偏函数(实际functools.partial是一个类,它还会做更多工作,比如改变新函数的函数头)
def partial(func, *args, **keywords):
def newfunc(*fargs, **fkeywords):
newkeywords = keywords.copy() # 浅拷贝偏函数提供的可变关键字参数给newkeywords
newkeywords.update(fkeywords) # 合并新函数提供的可变关键字参数
return func(*args, *fargs, **newkeywords) # 偏函数提供的位置参数放前面,这样就把原函数的部分参数固定下来了
newfunc.func = func # 记录下原函数给新函数
newfunc.args = args # 记录下要固定的位置参数给新函数
newfunc.keywords = keywords # 记录下要固定关键字参数给新函数
return newfunc # 返回新函数

def add(x, y):
return x+y

# 固定add的参数x,partial(add,4) 返回 newfunc,调用newfunc(5)->func(*args,*fargs)->func(4,5)
newadd = partial(add, 4)
newadd(5) # 返回9

# 固定add的所有参数
partial(add, 4, 5)() # 返回9

用偏函数与嵌套函数实现装饰器

为了方便,这里使用标准库里的偏函数partial,实现带参数装饰器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from functools import partial
import inspect

def logger(fn, a, b=3): # 两层嵌套函数
def wrapper(*args, **kwargs):
return a + b + fn(*args, **kwargs)
return wrapper

def newlogger(a, b=3):
return partial(logger, a=a, b=b) # 固定logger的参数a、b

fn = newlogger(2,4) # 测试
print(inspect.signature(fn)) # 偏函数返回的新函数的函数头 (fn, *, a=2, b=4)

# add=newlogger(4,5)(add)=(logger,a=4,b=5)(add),生成新的logger(add)->wrapper, 使add(x,y)变成了wrapper(x,y)
@newlogger(4, 5)
def add(x, y):
return x + y

# 相当于调用 newlogger(4,5)(add)(1,2) 或 partial(logger,a=4,b=5)(add)(1,2),返回12
print(add(1, 2))