cover

Python基本原理:名字绑定与命名空间

本文以Python官方文档CPython实现作为参考,示例代码均在3.13.7测试通过。

名字绑定

通常情况下,我们会把a = 1称为“把数值1赋值给变量a”,这个说法对于初学者理解代码没有问题,但是当你稍微想进阶一些的时候,请一定要理清楚这个表达式的严谨解释是:把名字(也称作标识符)a和这个整数对象1绑定起来。

这样你就可以清楚下面这段代码为什么会输出反直觉的结果:

1a = [1, 2]
2b = a
3b.append(3)
4
5print(a)
6
7# 运行结果
8# [1, 2, 3]

因为第1行语句的意思是,把标识符a和一个列表对象绑定在一起;第2行语句的意思是把标识符b和“标识符a绑定的对象”绑定在一起。这样从始至终就只有一个列表对象,所以不管是谁进行append,都会影响“另一个”。

那不对啊,为什么下面这段代码的a不会变呢?

 1a = 1
 2b = a
 3b = b + 1
 4
 5print(a)
 6print(b)
 7
 8# 运行结果
 9# 1
10# 2

直到第2行为止,状态还是和前面的示例一样的,标识符a和b都绑定到同一个整数对象1上。但是第3行的操作实际上是:b + 1返回一个整数对象2,然后把整数对象2和标识符b绑定到一起。

所以它们的根本区别在于b.append()是改变了列表对象本身,而b + 1是返回了一个新的对象。

 提示

对于整数对象而言(其它类型的对象并不一定),b += 1b = b + 1背后的实现虽然略有差异,但是原理是一样的,也是返回另外一个整数对象然后和b绑定在一起。

如果再思考一步,就会发现这种绑定其实单向的就可以:我需要从一个标识符定位到一个对象,但是基本不需要从一个对象定位到它绑定了哪些标识符。

也就是说这种绑定其实可以是一个映射(Mapping)。

嗯,没错,字典就是一种典型的Mapping,在大多数情况下,标识符和对象的绑定关系就记录在字典中。所以CPython对字典做了很多细致的优化工作,因为它关系到Python的底层原理,字典的效率会很大程度上影响整个Python的效率。

除了字典之外,还有一种常见但是容易被忽略的Mapping——数组下标到对应元素的映射。假如我现在只有两个标识符a和b,我再开辟一个长度为2的数组arr,并且规定arr[0]arr[1]分别代表a和b,那这样就建立了一个映射关系,并且效率会比字典更快。但是这种方式有一个明显的缺点就是只能对事先知道总共有多少个标识符的情况下有效,因为要开辟数组以及把标识符和数组下标对应起来。

巧了,一个函数写好之后,函数内部总共有多少个标识符是确定的,在函数内部就可以用开辟数组的方式进行优化。

在Python中与本文主题有关的有4个Mapping,通常情况下也称之为命名空间:局部命名空间(Local Namespace)、闭包命名空间(Enclosing Namespace)、全局命名空间(Global Namespace)和内置命名空间(Built-in Namespace)。在通过一个标识符获取对应的对象时,Python会按照顺序依次查找,也就是说Local优先级最高,Built-in优先级最低。

这就是Python中的LEGB规则。

 注意

虽然在这种机制下,Python的标识符和常规意义上的变量有很大区别,但是在厘清原理之后,也不会对由于这种机制产生的反直觉结果感到困惑。因此为了表述变量,本文在后面的表述上并不严格区分“变量”和“标识符”。

全局命名空间

虽然全局命名空间属于第三优先级,但是是大多数人除了print("Hello, World!")之外接触的第一个命名空间——当你在文件顶层(也就是不在函数或者类里面)写代码时,标识符就会默认保存在全局命名空间中。

我们可以用globals()函数获取它:

1a = 1
2
3print(type(globals()))
4print("a" in globals())
5print(globals()["a"] is a)

结果:

1<class 'dict'>
2True
3True

需要注意的是,这里的“全局”是相对于一个模块而言的,也就是每一个模块都有一个保存全局变量的字典,在模块A中是没办法直接用模块B中的全局变量的。

如果要用怎么办呢?导入模块,然后用<module>.<identifier>访问。但是此时这种访问方式不再是命名空间这套规则了,<identifier>其实是作为模块对象<module>的一个属性来处理的,这就设计Python的另一个基本原理——属性查找,可以参考这里(还没写好,链接后面放)。

除了用globals()获取全局命名空间之外,也可以用下面的代码获取:

 1import sys
 2
 3a = 1
 4
 5global_namespace = sys.modules["__main__"].__dict__
 6
 7print(
 8    "a" in global_namespace
 9    and global_namespace["a"] is a
10    and global_namespace is globals()
11)
12
13# 运行结果
14# True

使用python main.py执行代码时,main.py这个文件也会作为一个模块,并且这个模块有一个固定的名字__main__sys.modules是一个字典,记录了所有已存在的模块,所以sys.modules["__main__"]就可以获取这个模块,而sys.modules["__main__"].__dict__则是获取这个模块的全局命名空间对应的字典。

 注意

主模块的名字和入口源代码文件的名字无关,不管是python 1.py还是python test.py,它们对应的模块的名字都是__main__

全局命名空间除了保存用户自定义的模块级全局变量之外,还有一些在模块对象创建之初就存在的变量:

  • __name__:字符串。模块的名字,值与sys.modules中的key一致。
  • __file__:字符串。模块对应py文件的绝对路径。所以可以用__file__方便的改变当前工作目录,或者结合相对路径读取同目录下的文件。
  • __package__:字符串。记录模块的层级,类似<package>.<sub_package>.<module>,在进行相对导入时Python使用这个变量进行模块定位。
  • __spec__:ModuleSpec对象。详细记录了模块的关键属性。

除此之外还有__doc____cached__等。

总的来说,这些特殊变量大多都是模块的元数据,除了if __name__ == "__main__"之外并不经常用到。

内置命名空间

内置命名空间是优先级最低的命名空间,用于保存内置变量,例如printTrueint等。这些变量是整个Python解释器都通用的。所以你可以在没有任何前摇的情况下直接使用print函数进行输出。

内置命名空间对应的Mapping也是一个字典,也就是builtins模块的.__dict__

1import builtins
2
3print('print' in builtins.__dict__ and builtins.__dict__['print'] is print)
4
5# 运行结果
6# True

也就说,print这个函数对象,和标识符print绑定在了一起。

 提示

除了显式import builtins之外,也可以直接使用全局变量__builtins__获取builtins模块。但是这个全局变量不是语言标准,不要在生产环境中使用。

内置命名空间有最低的优先级,所以你可以在全局命名空间覆盖它们:

1int = float
2
3a = int(1)
4
5print(a, type(a))
6
7# 运行结果
8# 1.0 <class 'float'>

也正因为如此,你可以放心将某个函数的入参设置为input,而不用担心在函数内部使用标识符input的时候会误用为input函数,因为函数的参数处于最高优先级的命名空间——局部命名空间。

局部命名空间

局部命名空间包含函数内部定义的变量和函数的参数,可以用locals()函数获取:

 1def func(x: int, y: int) -> int:
 2    z = x + y
 3    print(type(locals()), locals())
 4    return z
 5
 6
 7print(func(1, 2))
 8
 9# 运行结果
10# <class 'dict'> {'x': 1, 'y': 2, 'z': 3}
11# 3

注意,虽然locals()返回的是一个字典,但是不要试图通过这个字典修改局部变量的值。

Python解释器每次执行一个函数的时候,都会准备一个帧对象,用于模仿物理机的栈帧,在帧对象的尾部有一块类似Python列表的空间(但是是定长的,因为变量数量确定),局部变量其实是通过这个列表的下标进行映射的。

所以更准确的说法是,locals()函数返回当前执行时的局部变量的快照,你修改这个快照对真实的局部变量没有任何影响。

在函数内部,可以用global关键字告诉解释器,后面这个变量不是局部变量而是全局变量,这样就可以在函数内部修改全局变量的值(但是强烈建议不要这么做):

 1z = 0
 2
 3def f(x: int, y: int) -> None:
 4    z = x + y
 5    print(z, end=" ")
 6
 7
 8def g(x: int, y: int) -> None:
 9    global z
10    z = x + y
11    print(z, end=" ")
12
13
14f(1, 2)
15print(z, end=" ")
16g(1, 2)
17print(z)
18
19# 运行结果:3 0 3 3
20# 如果你在f里打印locals是有z的,但是g的locals就不会出现z

闭包命名空间

闭包是一个相对抽象的概念,它是实现函数式编程几乎不可或缺的核心机制(Python装饰器就是函数式编程的一种)。要理解闭包,首先要从自由变量说起。

我们知道,当一个函数执行完毕之后,对应的帧对象就会销毁,内部的局部变量也随之被销毁,如果不引入额外的机制,遇到下面这种情形的时候就没法正常工作(如果对type hints不是很了解,可以忽略type hints的内容,不影响对核心原理的理解):

 1from typing import Callable
 2
 3
 4def outer_func(x: int) -> Callable[[int], int]:
 5    def inner_func(y: int) -> int:
 6        return x + y
 7
 8    return inner_func
 9
10
11add_two = outer_func(2)
12
13print(type(add_two))
14print(add_two(3))
15
16# 运行结果
17# <class 'function'>
18# 5

通过运行结果可以看到,add_two是一个函数,可以进行正常的调用,调用结果就是对传入的y(示例中就是3)加上x(示例中是2)并返回。

add_two = outer_func(2)执行完之后,outer_func这个函数就执行完毕了,内部的局部变量x就会被销毁。但是实际上add_two这个函数依然能够记住x的值,背后的原因就是Python依靠闭包机制捕获了自由变量x,并记录下了它的值。

也就是说,当一个函数内部引用了一个变量,而这是变量是一个外部函数的局部变量的时候,就称这个变量为自由变量。

而捕获这些自由变量的那个Mapping,就是闭包命名空间。

我们可以通过函数对象的.__closure__获取到这个Mapping。当函数不引用自由变量时,这个Mapping是None;当引用自由变量时,这个Mapping是一个元组,和局部命名空间一样,依靠下标进行映射(因为引用多少个自由变量也是确定的)。

 1from typing import Callable
 2
 3
 4def outer_func(x: int) -> Callable[[int], int]:
 5    def inner_func(y: int) -> int:
 6        return x + y
 7
 8    enclosing = inner_func.__closure__
 9    print(type(enclosing), len(enclosing), enclosing[0].cell_contents)
10
11    return inner_func
12
13
14outer_func(2)
15
16# 运行结果
17# class 'tuple'> 1 2
18# 解释:由于print函数是在outer_func内的,所以执行outer_func就可以输出结果,不必再执行返回的inner_func了

但是这样捕获的自由变量是只读的,不能重新进行名字绑定。例如你在inner_func中执行x += 1,就会抛出UnboundLocalError

想要修改自由变量的值,需要用nonlocal关键字修饰自由变量:

 1from typing import Callable
 2
 3
 4def outer_func(x: int) -> tuple[Callable[[int], int], Callable[[int], int]]:
 5    def inner_func_1(y: int) -> int:
 6        nonlocal x
 7        x += 1
 8        return x + y
 9
10    def inner_func_2(y: int) -> int:
11        nonlocal x
12        x += 2
13        return x + y
14
15    return inner_func_1, inner_func_2
16
17
18func_1, func_2 = outer_func(2)
19print(func_1(3))
20print(func_2(3))
21print(func_1(3))
22print(func_2(3))
23
24# 运行结果
25# 6
26# 8
27# 9
28# 11

可以看到这种修改是对整个outer_func内部都生效的。

 提示

nonlocal关键字修饰的自由变量只是不能重新名字绑定,并不是完全不可变。如果一个自由变量是一个列表,你完全可以调用列表的append等方法改变列表对象本身的值。

enclosing[0].cell_contents所示,闭包命名空间其实并不是直接保存的自由变量本身,而是保存的Cell对象,每个Cell对象“持有”一个自由变量,这也是经过nonlocal关键字修饰的自由变量可以进行重新绑定的关键。但是这部分内容偏向于内部实现细节,对了解LEGB规则帮助不大,这里就略过了。

 注意

如果你在inner_func中执行print(locals()),你会发现自由变量也会被输出。这里并不是命名空间存在混乱,而是locals函数的设计本身就是这样——除了局部变量之外,也会捕获局部变量,将它们共同打包成一个快照。为了避免过多的细节解释破坏行文逻辑,前一节关于locals()的部分使用了简化表述,这里更正一下。

总结

当你在Python中使用一个标识符时,Python解释器会严格按照LEGB的顺序进行查找,如果所有命名空间都找不到这个标识符,就会抛出NameError

BTW,之所以没有提作用域这个和命名空间密不可分的概念,是因为在理解了命名空间之后,它们的关系可以用一句话总结:作用域是一个静态语法概念,定义了变量可以被哪些代码“看到”,而命名空间,就是在程序动态运行时,实现“变量可以被作用域内的代码的‘看到’”的数据实体。