装饰器
要讲清楚装饰器,首先要知道一些前置概念。下文涉及到这些概念的地方,会展开讲述。
什么是装饰器?
装饰器是一种AOP(面向切面编程)的设计模式。
面向对象编程往往需要通过继承或组合依赖等方式调用一些功能,这样可能造成代码的重复,增加了耦合。
而AOP可以在需要的类或方法上切入,切入点可以增强功能,让调用者与被调用者解耦。
这种不修改原来的业务代码,给程序动态添加(或修改)功能的技术,就是装饰器。
装饰器可用于日志记录、监控、参数检查等地方。比如业务函数中不应该包含与业务无关的功能,那么可以构建一个logger装饰器,对业务函数增加日志功能。并且logger装饰器可以是通用的,需要日志功能的地方,都可以使用logger装饰器,达到复用的目的。
装饰器的分类
无参数装饰器:无参数装饰器实现的关键是“闭包”,即嵌套函数与自由变量。
带参数装饰器:带参数装饰器实现的关键是“柯里化”,多层嵌套函数可以实现柯里化。
什么是闭包?
在讲什么是闭包前,需要先讲一个概念:自由变量。什么是自由变量呢?
未在本地作用域中定义,就是出现在嵌套函数中,定义在某函数的外层函数的作用域中的变量,叫自由变量。
如果某函数(即内层函数)引用了外层函数的自由变量,这样就形成了闭包。
比如:
1 | def foo(): |
再比如,用列表实现一个计数器:
1 | def counter(): |
上面代码的第6行,涉及到Python的“未赋值先引用”问题。因为Python是动态语言,对一个变量赋值就是在定义这个变量,就是“赋值即定义”。由于这个特性,会造成下面的问题:
1 | x = 5 |
上面的 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 | x = 5 |
好了。到这里,闭包的概念有了。接下来要讲什么是柯里化。
什么是柯里化?
简单来说,柯里化就是把一个函数的多参数形式转换成函数的单参数连续调用形式。比如:
1 | def func(a, b, c): # 多参数形式 |
那怎么变呢?用嵌套函数实现:
1 | def func(a, b, c): |
好了。柯里化的概念也有了,而且从上面代码可见,装饰器的雏形已经出来了。
问题:
聪明的你,可能会提出一个问题。因为Python里面一切皆对象,对象是有生命周期的。Python给对象设定引用计数来决定某个对象是否要销毁。如果对象的引用计数=0,这个对象会在恰当的时机被Python的GC(垃圾回收)回收,然后释放出占用的内存。
于是这个问题就是:自由变量为什么不会被销毁呢?
因为自由变量它也是局部变量,而函数内的局部变量是在函数调用时才创建(压入函数栈)。调用完成,局部变量就不需要了,也就没有了。像下面这个例子里的自由变量c,函数counter执行完,c怎么没有被销毁呢,c不也是函数内的局部变量吗?
1 | def counter(): |
因为,当foo=counter()执行完之后,counter返回它的内部函数inc,即foo=inc,内部函数被变量foo记住了,所以inc的引用计数不可能是0。并且,c是被inc函数引用的,inc没有消失,c怎么能消失呢。c的引用计数也必然不会等于0,所以执行foo()可以不断累加。具体可以看Python的源代码,这里只是反推,不解析源码。
讲了这么多,下面终于可以讲装饰器了。装饰器可以分为这么几种:
函数作为装饰器、类作为装饰器、类的实例作为装饰器(即描述器)。
函数作为装饰器
无参数装饰器
先给出用法:
1 |
|
其中 @logger 为装饰器的语法糖,等价于 add=logger(add)。那么logger长什么样呢?如下:
1 | def logger(fn): # 这个就是装饰器,用函数构建的装饰器 |
以上就是用函数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 | def logger(a, b): |
问题:
到这里,我们又可以提出一个问题:
装饰器如何解决同一个作用域中变量名同名问题?比如:
1 | def add(fn): |
推测:
装饰器语法执行时,被装饰函数虽然名叫add,但实际定义该函数后,函数对象直接赋值给了装饰器add的参数fn,类似于 fn=lambda x,y: x+y,被装饰函数名add实际上并不存在。所以,也就不存在相同变量名(add)冲突的问题(根据上面源码debug后观察到)。
上面讲的是,函数作为装饰器,去装饰一个函数。函数装饰器也可以去装饰一个类。
用函数装饰类
1 | def add_name(name='tom', lang='cn'): |
类作为装饰器
同样,类也可以作为装饰器。去装饰函数或者类。
用类装饰函数
1 | class Log(object): |
用类装饰类
1 | class ClsName(object): |
最后,用with可以实现类似装饰器的效果。
with上下文管理实现装饰器的效果
1 | import time |
严格地讲,with这个用法不是装饰器,但它达到了装饰器具有的一些能力,比如这里的统计函数执行时间。
类的实例作为装饰器(描述器)
类的实例作为装饰器,通常用作描述器。
什么是描述器?详见 一文彻底搞懂 Python中的描述器。
偏函数
什么是偏函数?
偏函数:把一个函数的部分参数固定下来,就是为部分参数添加固定的默认值,形成一个新的函数并返回。
偏函数可以配合嵌套函数实现带参数装饰器。Python标准库提供了偏函数 functools.partial。partial的等价函数为:
1 | # 等价偏函数(实际functools.partial是一个类,它还会做更多工作,比如改变新函数的函数头) |
用偏函数与嵌套函数实现装饰器
为了方便,这里使用标准库里的偏函数partial,实现带参数装饰器:
1 | from functools import partial |