老齐教室

Python中的命名空间和作用域

编译:老齐

本文将介绍Python命名空间和作用域,它们用于分配Python程序中的对象。Python语言是一种能够实现面向对象编程的高级语言,或者说,在Python中,“万物皆对象”。

例如,x = 'foo'中的x是一个变量,它应用了字符串对象'foo'

在一个复杂的程序中,会创建成百上千个这样的变量名称或者函数名称、类名称等,每个名称都指向特定的对象。Python如何跟踪所有这些名称,以便它们不会相互干扰呢?

接下来就解决这个问题。

命名空间

命名空间是当前定义的符号名称以及每个符号名称所引用的对象的信息的集合。可以将命名空间视为字典,其中键是对象名称,值是对象本身。每个键值对将一个名称映射到它所对应的对象。

正如《Python之禅》中所说的那样:命名空间是一个很棒的创意,让我们多做些这样的创意!

何止很棒,简直是绝妙。

在Python中,一共有三种类型的命名空间:

  • 内置(built-in),Python语言内置的名称,比如函数名abschar和异常名称BaseExceptionException等等。
  • 全局(global),模块中定义的名称,记录了模块的变量,包括函数、类、其它导入的模块、模块级的变量和常量。
  • 局部(local),函数中定义的名称,记录了函数的变量,包括函数的参数和局部定义的变量。(类中定义的也是)

每个命名空间有不同的声明周期,当Python执行一个程序时,会根据需要创建命名空间,并在不需要时删除。通常,在任何给定的时间都会存在许多命名空间。

内置命名空间

内置命名空间包含Python所有内置对象的名称。当Python运行时,这些可以直接使用。你可以用以下命令列出内置命名空间中的对象:

1
2
>>> dir(__builtins__)
['ArithmeticError', 'AssertionError', 'AttributeError', 'BaseException','BlockingIOError', 'BrokenPipeError', 'BufferError', 'BytesWarning', 'ChildProcessError', 'ConnectionAbortedError', 'ConnectionError', 'ConnectionRefusedError', 'ConnectionResetError', 'DeprecationWarning', 'EOFError', 'Ellipsis', 'EnvironmentError', 'Exception', 'False', 'FileExistsError', 'FileNotFoundError', 'FloatingPointError', 'FutureWarning', 'GeneratorExit', 'IOError', 'ImportError', 'ImportWarning', 'IndentationError', 'IndexError', 'InterruptedError', 'IsADirectoryError', 'KeyError', 'KeyboardInterrupt', 'LookupError', 'MemoryError', 'ModuleNotFoundError', 'NameError', 'None', 'NotADirectoryError', 'NotImplemented', 'NotImplementedError', 'OSError', 'OverflowError', 'PendingDeprecationWarning', 'PermissionError', 'ProcessLookupError', 'RecursionError', 'ReferenceError', 'ResourceWarning', 'RuntimeError', 'RuntimeWarning', 'StopAsyncIteration', 'StopIteration', 'SyntaxError', 'SyntaxWarning', 'SystemError', 'SystemExit', 'TabError', 'TimeoutError', 'True', 'TypeError', 'UnboundLocalError', 'UnicodeDecodeError', 'UnicodeEncodeError', 'UnicodeError', 'UnicodeTranslateError', 'UnicodeWarning', 'UserWarning', 'ValueError', 'Warning', 'ZeroDivisionError', '_', '__build_class__', '__debug__', '__doc__', '__import__', '__loader__', '__name__', '__package__', '__spec__', 'abs', 'all', 'any', 'ascii', 'bin', 'bool', 'bytearray', 'bytes', 'callable', 'chr', 'classmethod', 'compile', 'complex', 'copyright', 'credits', 'delattr', 'dict', 'dir', 'divmod', 'enumerate', 'eval', 'exec', 'exit', 'filter', 'float', 'format', 'frozenset', 'getattr', 'globals', 'hasattr', 'hash', 'help', 'hex', 'id', 'input', 'int', 'isinstance', 'issubclass', 'iter', 'len', 'license', 'list', 'locals', 'map', 'max', 'memoryview', 'min', 'next', 'object', 'oct', 'open', 'ord', 'pow', 'print', 'property', 'quit', 'range', 'repr', 'reversed', 'round', 'set', 'setattr', 'slice', 'sorted', 'staticmethod', 'str', 'sum', 'super', 'tuple', 'type', 'vars', 'zip']

Python解释器在启动时直接创建内置命名空间,并且这个命名空间一直存在,直到解释器终止。

全局命名空间

全局命名空间包含主程序级别定义的任何名称。Python在主程序启动时创建全局命名空间,它一直存在,直到解释器终止。

严格地说,这可能不是唯一存在的全局命名空间。解释器还为程序使用import语句加载的任何模块创建一个全局命名空间。

局部命名空间

局部命名空间,也可以翻译为“本地命名空间”。比如函数,每一个函数一旦运行,就创建了一个新的命名空间,这个命名空间是函数的本地命名空间,它的存在一直持续到函数终止。

函数并非彼此独立存在的,而且这种关联不限于主程序级别的函数,你也可以在另一个函数中定义一个函数,即嵌套函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
>>>
>>> def f():
... print('Start f()')
...
... def g():
... print('Start g()')
... print('End g()')
... return
...
... g()
...
... print('End f()')
... return
...

>>> f()
Start f()
Start g()
End g()
End f()

在本例中,函数g()是在f()的内定义的,这种方式所定义的函数称为嵌套函数,也称为“闭包”——更详细解释,请参阅《Python大学实用教程》一书的有关章节。

当主程序调用f()时,Python会为f()创建一个新的命名空间。类似地,当f()调用g()时, g()将获得自己独立的命名空间。为g()创建的命名空间是本地命名空间,为f()创建的命名空间是闭包命名空间——与g()的命名空间名称区分,也可以认为两个都是局部命名空间。

局部命名空间的声明周期是自其建立开始,到它们各自的函数执行完毕终止。当这些命名空间的函数终止时,Python可能不会立即回收分配给这些命名空间的内存,但是对其中对象的所有引用都将失效。

变量作用域

有多个不同命名空间,这就意味着允许Python程序中可以在不同的命名空间中有几个不同实例同时存在——但是这些实例的名称相同。只要每个实例在不同的命名空间,它们都是单独维护的,不会相互干扰。

但这就产生了一个问题:假设你在代码中引用了名称x,并且x存在于多个命名空间中。Python怎么知道你指的是哪个命名空间?

答案就是“作用域”。名称的作用域是某个程序的区域,而在这个区域中该名称具有意义。解释器在运行时根据名称定义的位置以及名称在代码中被引用的位置来确定这一点。

例如代码中引用名称x,那么Python将按照以下的顺序搜索x:

  1. 本地作用域:如果你在一个函数中引用x,那么解释器首先在该函数本地的最内部作用域内搜索它。
  2. 闭包作用域:如果x不在本地作用域中,而是出现在另一个函数内部的函数中,则解释器将搜索闭包函数的作用域。
  3. 全局作用域:如果以上两个搜索都没有结果,那么解释器接下来会查看全局作用域。
  4. 内置作用域:如果在其他地方找不到x,那么解释器将尝试内置的作用域。

这是Python文献中通常所称的LEGB规则(尽管Python文档中并没有实际出现这个术语)。解释器从内到外搜索名称,查找本地、闭包、全局,最后是内置作用域。

如果解释器在这些位置中找不到名称,那么Python将抛出NameError异常。

下面是LEGB规则的几个例子。在每种情况下,最里面的闭包函数g() 都试图向控制台显示名为x的变量的值。注意每个示例如何根据x的作用域打印x不同的值。

例1:单一定义

在第一个例子中,x只定义在f()g()之外,因此它位于全局作用域:

1
2
3
4
5
6
7
8
9
10
11
12
1 >>> x = 'global' 
2
3 >>> def f():
4 ...
5 ... def g():
6 ... print(x)
7 ...
8 ... g()
9 ...
10
11 >>> f()
12 global

第6行的print()语句只能引用一个可能的x,它显然是在全局命名空间中定义的x对象,即字符串“global”

例2:双重定义

在这个例子中,x的定义出现在两个地方,一个在f()之外;一个在f()内部,但在g()之外:

1
2
3
4
5
6
7
8
9
10
11
12
13
1 >>> x = 'global' 
2
3 >>> def f():
4 ... x = 'enclosing'
5 ...
6 ... def g():
7 ... print(x)
8 ...
9 ... g()
10 ...
11
12 >>> f()
13 enclosing

与上一个示例一样,g()引用了x。但这一次,它有两个定义可供选择:

  • 第1行定义了全局作用域内的x
  • 第4行在闭包作用域内再次定义了x

根据LEGB规则,解释器在查找全局作用域之前,先从闭包作用域中找到值。所以第7行的print()语句显示“enclosing”而不是“global”

例3:三重定义

本示例中展示了关于x的三重定义。一个定义在f()之外;另一个定义在f()内部,但在g()之外;第三个定义在g()内部:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
1 >>> x = 'global' 
2
3 >>> def f():
4 ... x = 'enclosing'
5 ...
6 ... def g():
7 ... x = 'local'
8 ... print(x)
9 ...
10 ... g()
11 ...
12
13 >>> f()
14 local

现在第8行的print()语句必须区分三种不同的可能性:

  • 第1行定义了全局作用域内的x
  • 第4行在闭包作用域内再次定义了x
  • 第7行在g()的本地作用域内又一次定义了x

在这里,根据LEGB规则规定,g()首先看到自己在本地定义的x值。因此print() 语句显示“local”

例4:无定义

最后的一个例子中, g()试图打印x的值,但是x在任何地方都没有定义。这种情况根本行不通:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
1 >>> def f(): 
2 ...
3 ... def g():
4 ... print(x)
5 ...
6 ... g()
7 ...
8
9 >>> f()
10 Traceback (most recent call last):
11 File "<stdin>", line 1, in <module>
12 File "<stdin>", line 6, in f
13 File "<stdin>", line 4, in g
14 NameError: name 'x' is not defined

这一次,Python在任何命名空间中都找不到x,因此第4行的print()语句抛出NameError异常。

Python命名空间词典

前面提到,当首次介绍命名空间时,可以将命名空间视为字典,其中键是对象名称,值是对象本身。事实上,对于全局和本地命名空间,正是它们的本质!Python确实将这些命名空间作为字典实现。

注意:内置命名空间的用法不同于字典。Python将其作为一个模块来实现。

Python提供了名为globals()locals()的内置函数。这些内置函数允许你访问全局和本地的命名空间字典。

globals()函数

内置函数globals()返回对当前全局命名空间的字典,你可以使用它来访问全局命名空间中的对象。下面的示例体现了主程序启动时的情形:

1
2
3
4
>>> type(globals())
<class 'dict'>
>>> globals()
{'__name__': '__main__', '__doc__': None, '__package__': None,'__loader__': <class '_frozen_importlib.BuiltinImporter'>, '__spec__': None,'__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>}

如你所见,解释器已经在globals()中默认放置了一些内容,根据Python版本和操作系统的不同,它在你的环境中看起来可能会有所不同。但应该是相似的。

现在看看在全局作用域内定义变量时会发生什么:

1
2
3
>>> x = 'foo'
>>> globals()
{'__name__': '__main__', '__doc__': None, '__package__': None,'__loader__': <class '_frozen_importlib.BuiltinImporter'>, '__spec__': None,'__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>,'x': 'foo'}

在赋值语句x = 'foo'之后,一个新的项出现在全局命名空间字典中。键是对象的名称x,值是对象的值“foo”

通常,你可以通过引用对象的符号名x,以常规的方式访问该对象。但是,你也可以通过全局命名空间字典间接访问它:

1
2
3
4
5
6
7
1 >>> x 
2 'foo'
3 >>> globals()['x']
4 'foo'
5
6 >>> x is globals()['x']
7 True

第6行的比较证实了这些实际上是同一个对象。

还可以使用globals()函数在全局命名空间中创建和修改条目:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
1 >>> globals()['y'] = 100 
2
3 >>> globals()
4 {'__name__': '__main__', '__doc__': None, '__package__': None,
5 '__loader__': <class '_frozen_importlib.BuiltinImporter'>, '__spec__': None,
6 '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>,
7 'x': 'foo', 'y': 100}
8
9 >>> y
10 100
11
12 >>> globals()['y'] = 3.1415913
14 >>> y
15 3.14159

第1行的语句与赋值语句y = 100具有相同的效果。第12行的语句相当于y = 3.14159

当简单的赋值语句就可以奏效时,就不要用globals()来修改了,但它确实有效,而且很好地说明了这个概念。

locals()函数

Python还提供了一个相应的内置函数locals()。它类似于globals(),但它访问的是本地命名空间中的对象:

1
2
3
4
5
>>> def f(x, y):
... s = 'foo'
... print(locals())...
>>> f(10, 0.5)
{'s': 'foo', 'y': 0.5, 'x': 10}

f()中调用locals()时,locals()返回表示函数的本地命名空间的字典。注意,除了本地定义的变量s之外,本地命名空间还包括函数参数xy,因为它们也是f()的本地参数。

如果在函数外部调用locals() ,那么它与globals()用法相同。

深入探究

globals()locals()之间有一个小的区别,了解这个区别是很有用的。

globals()返回包含全局命名空间的字典的实际引用。这意味着,如果调用globals(),保存返回值,然后定义其他变量,那么这些新变量将显示在保存的返回值所指向的字典中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
1 >>> g = globals() 
2 >>> g
3 {'__name__': '__main__', '__doc__': None, '__package__': None,
4 '__loader__': <class '_frozen_importlib.BuiltinImporter'>, '__spec__': None,
5 '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>,
6 'g': {...}}
7
8 >>> x = 'foo'
9 >>> y = 29
10 >>> g
11 {'__name__': '__main__', '__doc__': None, '__package__': None,
12 '__loader__': <class '_frozen_importlib.BuiltinImporter'>, '__spec__': None,
13 '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>,
14 'g': {...}, 'x': 'foo', 'y': 29}

这里,g是对全局命名空间字典的引用。在第8行和第9行上的赋值语句之后,xy出现在g所指向的字典中。

与上述不同,locals()虽然也返回一个字典,而该字典是本地命名空间的当前副本,而不是对它的引用。对本地命名空间的进一步添加不会影响以前从locals()返回的值,除非你再次调用它。此外,不能使用locals()的返回值来修改实际的本地命名空间中的对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 1 >>> def f(): 
2 ... s = 'foo'
3 ... loc = locals()
4 ... print(loc)
5 ...
6 ... x = 20
7 ... print(loc)
8 ...
9 ... loc['s'] = 'bar'
10 ... print(s)
11 ...
12
13 >>> f()
14 {'s': 'foo'}
15 {'s': 'foo'}
16 foo

在本例中,loc指向local()的返回值,它是本地命名空间的一个副本。第6行上的语句x = 20x添加到本地名称空间,但不添加到loc指向的副本。类似地,第9行上的语句修改了loc所指向的副本中的键‘s'的值,但这对实际本地名称空间中的``s的值没有影响。

这是一个微妙的区别,但如果你不记住的话,可能会给你带来麻烦。

修改作用域之外的变量

如果你已经读过《Python大学实用教程》这本书,一定已经知道Python中函数的参数,有的是按位置引用,有的是按值引用;有的参数值能够修改,有的不能修改。

下面代码演示了函数试图在其本地作用域之外修改变量时出现的问题:

1
2
3
4
5
6
7
8
9
10
1 >>> x = 20 
2 >>> def f():
3 ... x = 40
4 ... print(x)
5 ...
6
7 >>> f()
8 40
9 >>> x
10 20

f()在第3行执行x=40时,它会创建一个新的本地引用,该引用指向一个值为40的整数对象。此时,f()将丢失对全局命名空间中名为x的对象的引用。因此该赋值语句不影响全局对象。

请注意,当f()在第4行执行print(x)时,显示结果为40,即它自己的本地x的值。但是在f() 终止后,全局作用域内的x仍然是20

如果函数就地修改对象,它可以修改其本地作用域之外的可变类型的对象:

1
2
3
4
5
6
7
>>> my_list = ['foo', 'bar', 'baz']
>>> def f():
... my_list[1] = 'quux'
...
>>> f()
>>> my_list
['foo', 'quux', 'baz']

在本例中,my_list是一个列表,并且列表是可变的。在f()内部可以对my_list进行更改,尽管my_list在本地作用域之外。

但是,如果f()试图重新对my_list赋值,那么它将创建一个新的本地对象,并且不会修改全局的my_list:

1
2
3
4
5
6
7
>>> my_list = ['foo', 'bar', 'baz']
>>> def f():
... my_list = ['qux', 'quux']
...
>>> f()
>>> my_list
['foo', 'bar', 'baz']

这类似于f()试图修改可变函数参数时所发生的情况。

全局声明

如果确实需要从f()中修改全局作用域中的值,该怎么办? 在Python中使用全局声明是可行的:

1
2
3
4
5
6
7
8
9
10
>>> x = 20
>>> def f():
... global x
... x = 40
... print(x)
...
>>> f()
40
>>> x
40

global x语句表明,当f()运行时,对名称x的引用将指向全局命名空间中的x。这意味着赋值x = 40不会创建一个新的引用。它在全局作用域内给x赋了一个新值:

前面已经介绍过,globals()返回对全局命名空间字典的引用。如果你愿意,可以使用globals() 代替global语句来完成相同的任务:

1
2
3
4
5
6
7
8
9
>>> x = 20
>>> def f():
... globals()['x'] = 40
... print(x)
...
>>> f()
40
>>> x
40

完全没有必要这样做,因为全局声明已经较为明确地表达了这种做法的意图。但它确实为globals() 的应用提供了另一个例证。

如果全局声明中指定的名称在函数启动时不存在于全局作用域中,则global语句和赋值的组合将创建这一名称:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
 1 >>> y 
2 Traceback (most recent call last):
3 File "<pyshell#79>", line 1, in <module>
4 y
5 NameError: name 'y' is not defined
6
7 >>> def g():
8 ... global y
9 ... y = 20
10 ...
11
12 >>> g()
13 >>> y
14 20

g()开始运行时,在全局作用域内没有名为y的对象,但是g()在第8行使用global y 语句创建了一个这样的对象。

你也可以在单个全局声明中指定用多个逗号分隔的名称:

1
2
3
4
5
1 >>> x, y, z = 10, 20, 30 
2
3 >>> def f():
4 ... global x, y, z
5 ...

在这里,我们通过第4行的单个global语句,声明xyz引用全局作用域内的对象。

全局声明中指定的名称不能出现在global语句之前的函数中:

1
2
3
4
5
6
1 >>> def f(): 
2 ... print(x)
3 ... global x
4 ...
5 File "<stdin>", line 3
6 SyntaxError: name 'x' is used prior to global declaration

第3行上的global x语句的目的是让对x的引用指向全局作用域中的一个对象。但是第2行的print()语句指向全局声明之前的x,这会引发SyntaxError异常。

非本地声明

嵌套函数的定义也存在类似的情况。全局声明允许函数访问和修改全局作用域中的对象。如果一个闭包函数需要修改闭包作用域的对象该怎么办?考虑一下这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
 1 >>> def f(): 
2 ... x = 20
3 ...
4 ... def g():
5 ... x = 40
6 ...
7 ... g()
8 ... print(x)
9 ...
10
11 >>> f()
12 20

在本例中,x的第一个定义在闭包作用域中,而不是在全局作用域中。就像g()不能直接修改全局作用域中的变量一样,它也不能修改闭包函数作用域中的x。在第5行赋值x = 40之后,闭包作用域中的x值仍然是20

global关键字不适用于解决这种情况:

1
2
3
4
5
6
7
8
9
10
11
12
>>> def f():
... x = 20
...
... def g():
... global x
... x = 40
...
... g()
... print(x)
...
>>> f()
20

由于x在闭包函数的作用域内,而不是全局作用域内,因此global关键字在这里不起作用。在g()终止后,闭包作用域中的x仍然是20

事实上,在本例中,global x语句不仅不能提供对闭包作用域内x的访问,而且还在全局范围内创建了一个名为x的对象,其值为40:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
>>> def f():
... x = 20
...
... def g():
... global x
... x = 40
...
... g()
... print(x)
...
>>> f()
20
>>> x
40

要从g()内部修改闭包作用域中的x,需要类似的关键字nonlocal。在关键字nonlocal 后边指定的名称引用最近的闭包作用域中的变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
1 >>> def f(): 
2 ... x = 20
3 ...
4 ... def g():
5 ... nonlocal x
6 ... x = 40
7 ...
8 ... g()
9 ... print(x)
10 ...
11
12 >>> f()
13 40

在第5行nonlocal x语句之后,当g()引用x时,它指的是最近的闭包作用域内的x,其定义在f()中的第2行。

第9行的print()语句确认对g()的调用已将闭包作用域内的x值更改为40

最佳实践

尽管Python提供了关键字globalnonlocal,但这些关键字的使用并不总是可取的。

当函数在本地作用域之外修改数据时,无论是使用关键字globalnonlocal,还是直接就地修改可变类型,都会产生副作用。这种副作用类似于在函数中修改它的一个参数。一般认为修改全局变量是不明智的,不仅在Python中如此,在其他编程语言中也是如此。

和许多事情一样,这个问题可以归结为风格和偏好。对全局变量进行审慎和明智的修改有时可以降低程序的复杂性。

在Python中,使用关键字global至少可以明确表示函数正在修改一个全局变量。在许多语言中,函数只需赋值就可以修改全局变量,而不必以任何方式声明它。这使我们非常难以追踪全局数据修改的位置。

总之,在本地作用域之外修改变量通常是不必要的。人们几乎总是有更好的方法,通常使用的是函数返回值。

使用支付宝打赏
使用微信打赏

若你觉得我的文章对你有帮助,欢迎点击上方按钮对我打赏

关注微信公众号,读文章、听课程,提升技能