介绍
本篇主要介绍Python中的命名解析与函数闭包,关于类或对象的命名解析是关于属性,在另一篇中有详细介绍:Python3描述器
Python中的名字(name)
Python中的名字不等同于其他语言中的变量,当进行赋值操作时,name1 = xxx,是给对象xxx赋予了名字nam1,这被称为命名绑定(name binding)。而当访问name1时,例如print(name1),则是将访问name1对应的绑定对象xxx,这个操作被称为命名解析(Resolution of name)。
首先通过一个简单例子描述Python中的名字。
a = 3
b = a
a = 4
print(a)
print(b)
""" output
4
3
"""
在上面代码中a = 3,将名字’a’绑定到了对象’3’,而当执行b = a时,等号右边的a指的是之前的对象’3’,这条语句将名字’b’绑定到了’a’绑定的对象’3’,此时对象’3’有了两个名字’a’、‘b’。在执行a = 4时,将名字’a’重新绑定到对象’4’上,因此最后打印出了结果是’b’绑定的对象3和’a’新绑定的对象’4’。
下面将对Python中的命名绑定和解析进行分析。
命名绑定
Python中有多种命名绑定的方式,例如最常见的在代码块中的变量赋值,这里的代码块包括(模块、函数体、类等等)。除此之外,函数参数的传入、类和函数的定义(绑定函数、类名到所属代码块)、import语句等等也都是常见的命名绑定。
命名空间
通过上文我们知道命名绑定实际上是给某个对象起了一个名字,方便后序我们通过访问名字时就能拿到绑定的对象,因此现在产生了一个问题,Python如何记录描述这个对应关系呢?换句话说当我们访问(调用)某个名字时我们怎么拿到这个名字对应的对象呢?
上面的问题由Python中的命名空间(namespace)来解答,在命名绑定后,Python会产生字典来描述名字和对象的对应关系,通过字典的key-value能获取名字和对象的对应关系。
下面看看Python中有哪些命名空间以及他们的生命周期。
命名空间 | 级别 | 生成 | 死亡 |
---|---|---|---|
全局(global) | 模块中 | 模块开始或被引用时 | 解释器结束 |
局部(local) | 函数中等 | 函数开始 | 函数结束 |
原生(built-in) | 原生包builtin中 | 解释器开始 | 解释器结束 |
a = 1
def hello():
a = 2
print(a)
def hey():
a = 3
print(a)
hello()
hey()
print(a)
""" output
2
3
1
"""
通过以上例子我们可以看出,名字’a’及它绑定的对象可以存在于多个命名空间,这些命名空间之间相互独立,在两个函数之外绑定的a = 1将保存在全局命名空间,而在调用函数时,名字’a’将保存在自己所属的局部命名空间。
因此,我们可以引出下一问题,这些命名空间可以同时存在且相互独立,我们在某处代码中访问某个名字想要获取它绑定的对象时,到底访问的是哪个命名空间?为解释这个问题,下面将详细介绍命名解析,和命名绑定时发生的细节。
scope
再解决上节描述的问题前,我们先看Python中scope的概念。简单来说,scope就是代码层面上的区域,Python中的代码由各个scope构成(模块的全局scope,函数的局部scope),这个区域对应着这个scope中生成的命名空间。上述所说的命名绑定可以发生在任何scope上,例如模块、函数等。因此上节的问题“在某处代码中访问某个名字想要获取它绑定的对象时,到底访问的是哪个命名空间?”的另一种表达为,在某处scope访问某个名字时是通过哪个scope的命名空间访问的。
命名解析
上面scope的描述可能有些晦涩,简单来说命名解析(访问名字对应的对象)其实就是编译器如何找到这个名字所对应的对象,这个是通过确定scope来解决的。那么下面就会产生新的问题:
- 在命名解析时,Python解释器是怎么找到从哪个scope中的命名空间从而从中拿到对应的对象呢?
- 确定scope的时间是什么时候呢?
下面将对这个两个问题展开详细阐述。
编译阶段
Python中的dis.dis可以看到Python中编译后的字节码,通过它可以看到Python中的执行顺序。例如:通过dis观察函数的编译,可以看出函数执行时的具体步骤。
from dis import dis
def hello():
a = 3
print(a)
dis(hello)
""" output
5 0 LOAD_CONST 1 (3)
2 STORE_FAST 0 (a)
6 4 LOAD_GLOBAL 0 (print)
6 LOAD_FAST 0 (a)
8 CALL_FUNCTION 1
10 POP_TOP
12 LOAD_CONST 0 (None)
14 RETURN_VALUE
"""
dis中打印出来Python编译后的字节顺序,其中LOAD_*和STORE_*分别代表着命名解析和命名绑定的步骤。下文中我们将详细通过它们来介绍,命名解析和绑定发生的步骤。
局部变量(local variables)
我们首先看看常见的在函数中定义变量(命名绑定),以及访问变量(命名解析)。在函数这个scope中进行命名绑定的就是局部变量,例如上文hello函数中的a=3,除此之外函数参数也是局部变量(函数参数的传入也是命名绑定),上面字节码中的’STORE_FAST’就是存储局部变量的过程,当Python执行这句话时,名字’a’将保存到函数hello的局部命名空间中。因为’a’是局部变量,因此在命名解析时的字节码为’LOAD_FAST’,说明Python在执行访问名字’a’时将从局部命名空间中找到’a’所对应的对象。由此我们可以看出,Python在编译阶段就可以确定该如何进行命名解析。
全局变量(global variables)
在函数中,我们也会经常访问函数之外的在模块中绑定的名字,编译器会将在模块中绑定的名字视全局变量,在运行阶段执行到绑定的语句后全局变量将保存在模块的全局命名空间中。在所有模块中定义的函数中对该名字进行访问,都将被编译器视作是访问全局scope中的命名空间。
但如果该函数中再次绑定了这个名字,这就变为了上节提到的局部变量,编译器将不会在将访问变量时指向全局scope的命名空间。下面通过例子来说明这两点。
from dis import dis
a = 3
def hello():
print(a)
dis(hello)
""" output
6 0 LOAD_GLOBAL 0 (print)
2 LOAD_GLOBAL 1 (a)
4 CALL_FUNCTION 1
6 POP_TOP
8 LOAD_CONST 0 (None)
10 RETURN_VALUE
"""
在hello函数外绑定了名字a后,在hello函数中的访问都会被编译器翻译成’LOAD_GLOBAL’字节码,以此在Python执行hello函数时,访问名字’a’时将会从全局命名空间去找到’a’对应的对象。
from dis import dis
a = 3
def hello():
a = 4
print(a)
dis(hello)
""" output
6 0 LOAD_CONST 1 (4)
2 STORE_FAST 0 (a)
7 4 LOAD_GLOBAL 0 (print)
6 LOAD_FAST 0 (a)
8 CALL_FUNCTION 1
10 POP_TOP
12 LOAD_CONST 0 (None)
14 RETURN_VALUE
"""
如果在hello函数中重新绑定名字’a’,我们看到在hello函数中访问’a’时,不再是’LOAD_GLOBAL’的字节码,而是变为’LOAD_FAST’,因为名字’a’在hello函数绑定时会被编译器视为局部变量。
绑定的顺序不会影响编译器的判断,上面的代码中我们都是在命名解析前进行命名绑定,下面我们调整下顺序观察编译器的变化。
from dis import dis
a = 3
def hello():
print(a)
a = 4
dis(hello)
""" output
6 0 LOAD_GLOBAL 0 (print)
2 LOAD_FAST 0 (a)
4 CALL_FUNCTION 1
6 POP_TOP
7 8 LOAD_CONST 1 (4)
10 STORE_FAST 0 (a)
12 LOAD_CONST 0 (None)
14 RETURN_VALUE
"""
我们在函数hello中将两行代码的顺序交换了一下,可以看到在hello函数中访问’a’时,即使’a’在global中已经绑定,但由于hello函数中的’a’又被重新绑定作为局部变量,即使重新绑定在访问之后,编译器扔将这次访问翻译成’LOAD_FAST’的局部变量方式的字节码,和绑定的顺序无关。因此在执行hello函数时,由于在进行到’LOAD_FAST’这步时会从局部命名空间去找,但由于’STORE_FAST’存入命名空间的这步还为发生,因此访问’a’会报’UnboundLocalError’的错误,并不会打印出a在全局空间绑定的对象’3’。
上述中的编译尤为重要,是命名绑定和解析的核心关键,我们看到编译器关心命名在哪个scope中进行绑定,并不关心绑定和解析的顺序,而在程序运行中才会考虑到顺序问题。
因此上文scope的问题的部分回答为:如果在函数中对某个名字进行绑定,则会被编译器翻译成’LOAD_FAST’,解释器会从函数的局部命名空间中去找对应的对象;如果该名字没有在函数scope中进行绑定,则编译器会翻译成’LOAD_GLOABL’,解释器在运行阶段会从全局命名空间找,如果找不到则去原生命名空间找,都找不到则会报’NameError’错误。
总结一下在编译阶段,就可以决定运行时在某scope处访问名字时应当从哪个命名空间对找名字对应的对象。
自由变量(free variables)
对于嵌套函数来说,会和上面的描述有些区别。我们可以在被嵌套的内部函数中访问外部函数,下面看看具体细节。
from dis import dis
def hello():
a = 3
def hello_in():
print(a)
print(a)
dis(hello)
""" output
4 0 LOAD_CONST 1 (3)
2 STORE_DEREF 0 (a)
5 4 LOAD_CLOSURE 0 (a)
6 BUILD_TUPLE 1
8 LOAD_CONST 2 (<code object hello_in at 0x10b8c7b70, file "/Users/lyr/Desktop/lyr/计算机/python/py-learn/interface.py", line 5>)
10 LOAD_CONST 3 ('hello.<locals>.hello_in')
12 MAKE_FUNCTION 8
14 STORE_FAST 0 (hello_in)
7 16 LOAD_GLOBAL 0 (print)
18 LOAD_DEREF 0 (a)
20 CALL_FUNCTION 1
22 POP_TOP
24 LOAD_CONST 0 (None)
26 RETURN_VALUE
"""
我们可以看到因为在hello函数的内部函数hello_in中用到了hello函数的局部变量’a’因此,编译器将不再将’a’视为局部变量,而将它视作自由变量,因此在hello函数中对名字’a’进行绑定和解析时分别对应的字节码为’STORE_DEREF’和’LOAD_DEREF’,
在运行阶段,自由变量对应的命名空间也不在是hello函数的局部命名空间,而可以理解为是一个cell数组,而在内部函数hello_in中访问’a’的字节码为’LOAD_CLOSURE’,它在运行时引用刚才提到的cell对象进行命名解析。在后文中的闭包中将进一步解析。
global和nonlocal
我们在函数中可以访问全局变量,但当在函数中重新绑定改命名时将会产生局部变量,而在函数中使用global关键字,编译器可以将命名的scope指定为全局的,当 进行赋值操作时不再是将该名字绑定到该函数的scope作为局部变量,而是重新绑定了全局的该名字,下面通过例子说明。
from dis import dis
a = 3
def hello():
global a
a = 4
print(a)
hello()
print(a)
dis(hello)
""" output
4
4
6 0 LOAD_CONST 1 (4)
2 STORE_GLOBAL 0 (a)
7 4 LOAD_GLOBAL 1 (print)
6 LOAD_GLOBAL 0 (a)
8 CALL_FUNCTION 1
10 POP_TOP
12 LOAD_CONST 0 (None)
14 RETURN_VALUE
"""
我们可以看到在编译器检查到’global a’时,所有在该函数对名字’a’的绑定和解析都是针对模块的命名空间的,因此编译器编译成相应的’*_GLOBAL’字节码。在执行完hello函数后,因为重新绑定的原因模块的命名空间中的’a’所对应的对象变为’4’。
同样地,编译器不关心global和命名绑定的顺序,如果在‘global a’之前调用’a = 4’,编译器也不会将’a’视作局部变量了,‘a = 4’依然会被翻译为’STORE_GLOBAL’。因此若此时调用hello函数会报’SyntaxError’的错误。
闭包(Closure)
一个函数在执行完后其中的命名空间销毁,此时该命名空间中的名字所对应的对象也因此释放,而利用闭包可以保留住对象状态。下面举例说明。
import weakref
map = weakref.WeakKeyDictionary()
class A:
def __init__(self, value):
self.value = value
def outer():
a = A(0)
map[a] = a.value
print(dict(map))
outer()
print(dict(map))
""" output
{<__main__.A object at 0x10f060b38>: 0}
{}
"""
上面的map是一个字典,它的key用来保存弱引用对象,当执行outer函数时map字典增加了用名字’a’绑定的类A的实例对象,而在outer函数结束时,outer函数的局部命名空间被清理,内存释放后没有名字绑定到刚才的实例对象,因此弱引用字典为空。下面通过闭包保留住这个实例对象。
import weakref
map = weakref.WeakKeyDictionary()
class A:
def __init__(self, value):
self.value = value
def outer():
a = A(0)
def inner():
map[a] = a.value
print(dict(map))
return inner
in = outer()
in()
print(dict(map))
""" output
{<__main__.A object at 0x1077f2b38>: 0}
{<__main__.A object at 0x1077f2b38>: 0}
"""
以上的编译后的字节码为
12 0 LOAD_GLOBAL 0 (A)
2 LOAD_CONST 1 (0)
4 CALL_FUNCTION 1
6 STORE_DEREF 0 (a)
14 8 LOAD_CLOSURE 0 (a)
10 BUILD_TUPLE 1
12 LOAD_CONST 2 (<code object inner at 0x10a18e4b0)
14 LOAD_CONST 3 ('outer.<locals>.inner')
16 MAKE_FUNCTION 8
18 STORE_FAST 0 (inner)
18 20 LOAD_FAST 0 (inner)
22 RETURN_VALUE
以上的字节码在上文中提到,当inner函数引用了外层函数的变量’a’时,‘a’将不再保存到局部命名空间中,而是保存到cell数组中,而这个cell数组其实保存到了闭包inner中。当调用’in = outer()'时,函数将名字’in’绑定到了outer函数返回的闭包对象inner,因此再次调用’print(dict(map))'时,由于闭包inner被名字’in’绑定,此时cell数组并没有被销毁,依然保存着outer函数中’a’绑定的类A的实例对象。所以通过闭包保持了outer函数中的变量。
若将’in = outer(); in()‘更改为’outer()()’,同样调用了闭包inner,但因为没有名字绑定了闭包,所以map字典就会是空的了。
闭包的用途
下面我们通过不同的方式,实现计算函数被调用的次数。
通过类:
class A:
times = 1
def hello(self):
print("call times: ", self.times)
self.times += 1
a = A()
a.hello()
a.hello()
a.hello()
""" output
call times: 1
call times: 2
call times: 3
"""
可以将只包含一个函数的类转闭包的实现方式。
def out():
a = 1
def hello():
nonlocal a
print("call times: ", a)
a += 1
return hello
hello = out()
hello()
hello()
hello()
""" output
call times: 1
call times: 2
call times: 3
"""
通过装饰器的方式:
def decorate(func):
a = 1
def wrapper(*args, **kwargs):
nonlocal a
print('call times: ', a)
a += 1
return func(*args, **kwargs)
return wrapper
@decorate
def hello():
pass
hello()
hello()
hello()
""" output
call times: 1
call times: 2
call times: 3
"""
我们看到,装饰器本质其实就是闭包,"@decorate"相当于执行了"hello = decorate(hello)",相当于hello名字绑定了闭包wrapper,而decorate函数中的’a’和’func’就是闭包要保持的对象。
通过代码:“print(hello.code.co_freevars)“我们可以看到hello绑定的闭包返回了t它绑定的自由变量元组”(‘a’, ‘func’)”。
闭包的陷阱
import weakref
map = weakref.WeakKeyDictionary()
class A:
def __init__(self, value):
self.value = value
def hello():
a = A(0)
def hey():
map[a] = a.value
pass
heylist = []
for i in range(3):
a = A(i)
heylist.append(hey)
return heylist
a = hello()
for h in a:
h()
print(dict(map))
上面代码我们渴望的map输出为三个对象的key,value分别为0,1,2。但由于hello函数中的变量’a’保存在了闭包hey的cell数组中,因此在闭包返回后,无论调用几次,'a’所对应的对象都是外层函数hello中最后绑定的对象(这里是整型i)。可以通过以下改造解决这个问题。
import weakref
map = weakref.WeakKeyDictionary()
class A:
def __init__(self, value):
self.value = value
def hello():
a = A(0)
def hey(a=a):
map[a] = a.value
pass
heylist = []
for i in range(3):
a = A(i)
heylist.append(lambda x=a: hey(x))
return heylist
a = hello()
for h in a:
h()
print(dict(map))
这里关键点的是heylist.append中的’x=a’,x将作为局部变量传入hey中,hey将不再是闭包,'a’也不再是被保持的变量。真正的闭包是那个个lambda函数,它保持了变量hey。
通过__code__查看:
print(hello.__code__.co_cellvars)
print(a[0].__code__.co_freevars)
1. 第一种
('a',)
('a',)
2. 第二种
('hey',)
('hey',)