浅谈Python闭包与装饰器

前置知识

加载、解释与执行

python是解释型语言、脚本语言,一大特点是一行语句只有需要被执行时才会被编译一次,编译后立即执行。所谓的“需要被执行”指python解释器“阅读”代码的顺序,通常来说是从上往下一行一行“阅读”,也就是编译并执行完了当前行,才会执行下一行;当然也有例外的时候,比如设置了for循环(需要注意的是,假设for循环中有4行代码,循环3次,那么解释器实际上编译了12行),还有就是调用方法(函数),解释器会跳转到方法代码块开始编译执行。观察如下代码:

1
2
3
4
5
def fun(a):
a = a ** 2
return a
print("调用fun方法")
print(fun(a))

这段代码的编译执行顺序是1、4、2、3、5。(提示:函数定义真正被执行的只有def行,函数体中的代码只是以字符串的形式简单地加载进了内存,并没有被编译,函数每一次调用的时候才进行一轮编译执行;第五行中出现了函数调用,会先跳转到函数块,执行完函数中的语句拿到返回值后跳转回来执行print

一切皆对象

python中一切皆对象的思想比Java更为彻底。int型等所有变量是对象,函数是对象(类也是对象,这个暂时不做展开)。是对象就会有对象的属性和方法,这里介绍一种很有用的方法,id(对象名),这个方法会返回对象名所指向对象的地址,利用它可以很好的研究代码,观察各个变量的内存位置。因为一切皆对象,所以python中的一切变量都是引用变量,

1
2
3
4
5
6
a = 1
a = 2
b = 2
a = fun
print(a(2))
print(id(a) == id(b))

第一行到第二行,并不是给a变量赋了新的值,而是把a的指向从存放对象int 1的内存地址改到了存放int 2对象的内存地址(事实上python的虚拟机会在程序运行前提前创建好0到256的int对象,于是你会发现代码中id(a)和id(b)是一样的,因为他们都指向同一对象);函数也是对象,是对象就可以被引用,所以第四行表示给fun函数取了一个新名字a,a从指向2变成了指向fun(注意一下fun和fun()的区别,前者的返回值是fun所指的函数对象,后者的返回值是函数里return的东西)。

闭包

有了前置知识就很容易学习、理解闭包了。闭包说白了,就是定义一个外层函数,这个外层函数的功能是定义一个内层函数,并返回这个内层函数对象。

1
2
3
4
5
6
7
def outer(a):
def inner(b):
return a + b
return inner

f = outer(1)
print(f(2)) # 输出3

1到4这个结构就称为闭包,第六行的功能就是得到了一个叫f的、可以给传入的一个数字加1并返回的函数。上面代码的执行顺序是1、5(第五行为空)、2(识别到第六行有函数调用,所以先跳转执行函数outer,生成一个inner函数对象,并把inner函数体中的代码载入内存)、4、6、3(通过f找到内存中之前生成的inner函数,执行函数语句)、7。

1
2
f = outer(1)
g = outer(2)

这里调用了两次outer,实际上定义了两次inner,产生了两个不同的函数对象(内存地址不同,功能也不同,f()是传入的数字返回加1的结果,g()是返回加2的结果)。这也是为什么outer的返回值必须赋给(被引用给)一个变量,因为outer()是可以多次利用的,每次调用都会产生一个新的inner函数对象,那么你就需要不同的名字来区别这些函数,方便下次找到他们,比如上面的f和g(outer函数体外面是不属于inner这个名字的作用域的,所以在outer外通过inner()调用函数是非法的,在python解释器看来,这个位置没有定义过一个叫inner的函数或者变量,所以每次产生的inner对象虽然载入了内存,但是出了outer函数体之后就失去了名字,所以必须被一个外部的变量名引用,下次需要调用它的时候才能明确你到底需要调用内存中哪一个函数)。

我们再来看看新生成的inner对象载入内存这个过程的一些细节,

1
2
3
4
5
6
7
8
9
def outer(a):
def inner(b):
return a + b
a = 2
return inner


f = outer(1)
print(f(2)) # 输出4

这段代码和之前的区别就是def之后加了一句a = 2,结果输出就等于4了。如果你看懂了我之前说得调用和定义、执行和加载的区别,你就会理解定义inner对象的时候只是把return a + b这段字符串载入了内存,并没有进行编译,所以当之后通过f(2)调用,编译并执行return语句的时候就需要去看一下现在的a等于几。但是,我想说的是,通常来说,一个函数内部定义的变量,或者其形参的生命周期在函数结束的时候就结束了,也就是说outer结束的时候,解释器理应已经不认识那个叫a的变量了(和inner的道理一样),或者说内存中已经不存在叫a的变量了,那么当我们在outer外部调用f(2)的时候,解释器在编译return语句的时候去哪里找的a呢?这就是闭包之所以为闭包的特殊之处(如果你去看官方文档的闭包定义,你会觉得不知道它在讲什么,实际上它在说我下面要讲的这个特性),解释器在执行def inner(b):的时候会在内存中开一块内存用于存放新生成的inner函数对象和这个对象的一些成员信息,除了将函数体语句(这里指return a + b)载入这块内存,还会载入变量a(a在是inner中被调用的,但是不是在inner中定义的局部变量),相当于捆绑了一份a给新生成的inner对象,所以当outer结束的时候,a并没有被销毁,而是存活在inner对象的内存中(注意,我这里所说的a是否存活,不是指a所指的那个int对象,不是说int对象存到了inner对象的内存中,这里的a是一个引用,你应该把a理解为一份地址信息,而这个地址就是a所指的int对象的地址,P.S 如果你想学好python,就必须习惯所有的变量名、函数名都只是引用)。
现在可以尝试在自己机器上运行下面的代码并理解其输出,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def outer():
fs = []
for i in range(4): # i是整数类型 存在预留缓存池中
def inner():
print("i", id(i))
print("func", id(inner)) # 这里的inner的引用每次定义都会重新覆盖
return i
fs.append(inner)
inner()
print("*" * 30)
for j in range(4):
# print(id(fs[j]), fs[j]())
print(fs[j](), id(fs[j]))
print("*" * 30)
for i in range(4):
print(id(fs[i]), fs[i]())
print("*" * 30)
return fs


f1, f2, f3, f4 = outer()
print(f1(), f2(), f3(), f4())

装饰器

先理解@的作用,然后再理解通常意义上的装饰器。

1
2
3
4
5
6
7
8
9
10
11
12
def funC():
print('C')

def funA(arg):
print('A')
return funC

@funA
def funB():
print('B')

print(funB)

funA可以称为一个装饰器(通常意义上是装饰器应该写成一个闭包),它的特征是包含一个参数(这个参数会指向被修饰的函数对象,这里arg指向funB),返回一个函数对象(闭包形式中通常返回的是inner函数对象,这里返回了funC);@funA的作用就是

1
funB = funA(funB)  # 以后调用funB(),就不是调用的你自己定义的funB函数了,它已经指向了装饰器返回的那个函数对象,也就是funC

不是说能直接这样替换,我只是想意会一下@的作用,同时我希望你能注意到@funA不是定义或者加载,这里是真的跳转过去执行了一次funA,所以如果你在输出中看到了A,那么这个A是解释器在处理@funA时产生的,而不是在后面处理print(funB)时产生的。如果你对这些过程还有疑问,可以在各个位置添加print(id(函数名))来观察整个流程中的函数名指向的变化。

下面通过一个比较简单的例子来解释我们为什么需要装饰器,并引出通常意义上的装饰器。假设你的同事定义了一系列函数,比如

1
2
3
4
5
def f(x):
return x + 1

def g(x):
return x + 2

你的老板说,“我希望每次调用一个函数都给我打印一下我调用的这个函数的名字,这样我们的程序变大变复杂了之后,我可以通过看输出知道我们的程序运行过程中调用了哪些函数,调用的次数和顺序,而不用去翻代码”,然后他把这个任务交给了你。最笨的方法当然是在每个函数的定义里加一句print(函数名.__name__),(这里的name是函数对象的一个成员变量,其内容是函数名的字符串,这里的函数名是指def的时候用的那个名字,你可以在之前闭包代码的inner里加上这一句看看会打印出什么),问题是你同事定义的那些函数又多又臭又长,你根本不想看,你也不能叫你同事去加,一方面现在只是加一行print代码比较简单,如果老板以后要求更复杂的功能呢,另一方面如果找同事去做就能解决问题,老板要你干嘛(另外有一个原则性问题,我们不应该轻易去修改已经写好的代码块,因为即使那些代码是你自己写的,一段时间之后,你也可能忘掉其中的一些逻辑细节,轻易修改会导致bug)。所以,这个时候最好的办法就是写一个装饰器:

1
2
3
4
5
def outer(fun):
def inner(b):
print(fun.__name__)
return fun(b)
return inner

然后你只需要跟你的同事说,在他写的每一句def前面加一句@outer。例如

1
2
3
4
5
6
7
8
9
@outer
def f(x):
return x + 1

@outer
def g(x):
return x + 2

print(f(1))

这样调用f的时候,实际上是调用了f新指向的那个inner,而执行的时候fun就是被修饰前的那个f,就是同事def的那个函数,解释器会执行print(fun.__name__)(实现了老板要求你加的功能),然后执行return fun(b)(保留了你同事原来写的函数的功能),括号里的内容显示了这个叫装饰器的东西的优点。

讲到这里,你已经基本理解了装饰器的概念、原理及优点。如果你上网查装饰器,你还会发现其他许多借助装饰器实现的骚操作,我这里只介绍了最简单的版本,但是你理解了我讲的这些之后,再去看那些更秀的操作,就不会一头雾水了。这里我在多讲一个内容,就是装饰器有其他参数的情况。

你的公司100多个员工,每个人都写了一批函数,老板说,“我还希望调用一个函数的时候可以打印出这个函数是谁写的,这样出bug的时候,我可以知道该扣哪个人的工资”,你已经会写装饰器了,于是你轻松完成了这个任务,

1
2
3
4
5
6
7
8
9
10
11
12
13
def outer(name):
def mid(fun):
def inner(b):
print(name)
print(fun.__name__)
return fun(b)
return inner
return mid


@outer('szw')
def f(x):
return x + 1

然后你和所有人说,在你们的函数前都加上@outer('你的名字')吧。现在我们用前面所学的所有知识来理解一下这个装饰器,以检测你是不是真的理解了前面的东西。乍一看这个装饰器写了三层函数,我刚看到的时候也是一脸懵逼,但是这实际上这并不是什么新规则,用我们前面讲的逻辑就能理解它了。正常的装饰标志是@outer这样的对吧,@后面应该写一个函数对象,而不是调用一个函数(参看前面说的关于fun和fun()的区别),那么@outer('szw')这里@后面为什么写了一个调用呢?对的,这里的确是调用了,所以你看看调用的返回值是什么,是一个函数对象mid,那这样就可以理解了——调用执行了outer('szw')后返回了一个和name='szw'这个局部变量捆绑了的函数对象mid(参看前面讲的闭包的特性),然后执行@mid,再然后就和我们之前说的普通的装饰器的流程一样了。

这个故事告诉我们,语言的特性(语法规则)能为程序员提供多大的便利是一回事,程序员能在规则之上写出怎样的骚操作就是另一个故事了,这取决于一个程序员灵活利用规则的造诣。而造诣=天分+努力,如果你不够聪明,就需要多看别人的代码,模仿并理解别人的骚操作。