Skip to main content

变量作用域

这篇文章我们聊一下 Python 中变量的作用域。我们之前在全局和闭包变量里,比较详细地聊了一下全局变量和闭包变量。这篇文章我们就更全面地去了解一下跟 Python 变量作用域有关的事情。

变量的三种类型​

我们见到更多的说法可能是有全局变量和局部变量,但是我个人一般会认为有三种:全局变量、局部变量和闭包变量。我之所以会这么分类,是因为这三种变量在绝大多数情况下都是编译期被决定的。

我们看下面这段程序,一共定义了三个变量。第二行在 module level 有一个 g 等于 0,第五行在这个 outer 函数里面有一个 c 等于 1,第七行在 f 函数里有一个 l 等于 2,而在第八行我们把 g、c、l 都打印出来。我们来运行一下这个 outer 函数。注意,我们运行 outer 函数的时候是不执行这个 f 的。这个 outer 函数只定义了这个 f,并且打印了它的字节码。我们可以看它的字节码,在没有运行 f 的时候,编译出来这个字节码就知道了:g 是一个全局变量,c 是一个闭包变量,而 l 是一个局部变量。

import dis
g = 0

def outer():
c = 1
def f():
l = 2
print(g, c, l)
dis.dis(f)

outer()

编译期如何判断作用域​

那我们先来简单说一下,在编译期,Python 是怎么判断一个变量的作用域的,或者说,当 Python 想使用一个变量的时候,它是去哪找的?一个最直观的理解方式就是找等号,也就是从里往外找,这个变量是在哪个作用域内被赋值的。

我们看 l 是在 f 内部被赋值的,所以在 f 里面去寻找 l 的时候,它就会认为它是一个局部变量。而 c 是在外层函数被赋值的,所以它会认为 c 是一个闭包变量。如果 g 不是局部变量,也不是闭包变量,它就会认为它是一个全局变量。

我们可以看,哪怕我们把这个对 g 的赋值给删掉,它编译出来的结果依然是一样的,它会认为这个 g 是一个全局变量。你在运行这个 f 函数的时候,它才会给你报错,说这个 g 没有定义。

import dis

def outer():
c = 1
def f():
l = 2
print(g, c, l)
dis.dis(f)

outer()

而对于局部变量来说,哪怕这个变量的赋值是在你使用这个变量之后,Python 也会认为它是一个局部变量。我们看这里的字节码,它依然用的是 LOAD_FAST。当然这里加了一个 CHECK,是因为 Python 的 compiler 发现了,它先使用后赋值的。

import dis

def outer():
c = 1
def f():
print(g, c, l)
l = 2
dis.dis(f)

outer()

而对变量作用域的判断,是遵循着由内而外的原则的,也就是只要在相对内部的地方,发现了这个变量是其中一种变量,那就不再往外找了。比如这里在 f 里面已经有对 l 的赋值了,那 Python 就认为它是一个局部变量。不管在它的外层函数和 module level,有没有这个变量的赋值,这个变量都是局部变量。

import dis
g = 0
c = 1
l = 2

def outer():
c = 1
l = 2
def f():
l = 2
print(g, c, l)
dis.dis(f)

outer()

那对于闭包变量当然也是一样的。大家可以看到,对于我们这条代码,Python 依然认为 l 是局部变量,而 c 是闭包变量。

那其实就像刚才我们说的,你在 module level 是否给一个变量赋值,完全不影响 Python compiler 判断这个变量的作用域。只要 Python compiler 在局部、在闭包都没有找到,就直接会默认它是一个全局变量。只不过在运行的时候,它可能会发现全局没有这个变量。

nonlocal 声明​

那这是 Python compiler 的默认行为,对吧?我们可以强行地告诉它,这个变量不是全局变量。我们可以用这个 nonlocal 关键字,告诉 Python compiler,这个变量它是一个闭包变量。可以看到,这个时候 Python 就把这个变量当作闭包变量来处理了。

import dis
g = 0
c = 1
l = 2

def outer():
c = 1
l = 2
def f():
nonlocal l
l = 2
print(g, c, l)
dis.dis(f)

outer()

这里注意一下,当我们把这个变量声明为闭包变量的时候,Python 是会检查这个闭包变量是否存在的。如果我们把外层函数里面这个 l 等于 2 给注释掉,可以看到 Python 在编译期是会出错的,它会告诉你这个闭包变量它压根不存在。

import dis
g = 0
c = 1
l = 2

def outer():
c = 1
# l = 2
def f():
nonlocal l
l = 2
print(g, c, l)
dis.dis(f)

outer()

global 声明​

相似地,我们可以用 global 关键字来声明,这个变量是一个全局变量。可以看到 Python 这里就认为 l 是全局变量了。

import dis
g = 0
c = 1
l = 2

def outer():
c = 1
l = 2
def f():
global l
l = 2
print(g, c, l)
dis.dis(f)

outer()

而对于全局变量,就像我们刚才说的,它是不会去检查全局里面有没有这个变量的。所以大家看,哪怕我们把这个 l 等于 2 给注释掉,这段代码依然是可以正常编译的。

import dis
g = 0
c = 1
# l = 2

def outer():
c = 1
l = 2
def f():
global l
l = 2
print(g, c, l)
dis.dis(f)

outer()

全局变量为什么不做检查​

好,那我们会发现这个全局变量怪怪的,对吧?怎么一到它就啥也不检查了呢?为什么它和闭包变量、局部变量有这么多不一样呢?这里就涉及到一件事情,就是我们刚才所谓的全局变量,其实是由两个部分组成的。它们分别是 module level 的全局变量,和所谓的 built-ins,也就是内置对象。

在 Python 中是有非常多的内置对象的。我们最常见的那些内置函数,以及我们可以直接 raise 的那些内置的 exceptions,它们都是内置对象。那我们这个狭义的 module level 的全局变量和这些 built-ins,它们最大的区别在于,module level 的 global variables,只是在这个 module,或者说在这个文件里,是所谓全局的。

比如说,我现在在这个文件里面有一个 g 等于 0,那在另外一个 Python 文件里面,我是没有办法直接打印出 g 来的。我就得 from example import g,或者是 import example,然后 example.g。也就是这个全局变量所谓的全局,是文件意义上或者 module 意义上的全局。

而 built-ins 里面的那些东西,它是真正的全局,在任何一个 Python 文件里,都可以使用的全局变量。

而当我们在做 LOAD_GLOBAL 的时候,它会先在这个 module level 里面找,有没有这个全局变量。如果没找着,就会去 built-ins 里面找,有没有这个全局变量。

我之所以把这两个实际上作用域不同的全局变量,放到一起,是因为它们都会由 LOAD_GLOBAL 触发。当然大家要知道,这两个实际上是两个作用域。所以有人在讲这个变量作用域的时候,也会把它们俩分开讲。

我们看这段函数,我们打印这个 abs。我们知道它是一个内置函数,对吧?那这个 LOAD_GLOBAL 实际上就检查了一下,我们这个文件里面有没有定义 abs。如果没有的话,它就去 built-ins 里面找有没有 abs。

import dis

def f():
print(abs)

dis.dis(f)
f()

这也就意味着,如果我们在这个文件里面定义了这个全局变量,同样的代码,它就会打印我们在文件级别定义的这个全局变量,也就是 module level 要优于 built-ins。

import dis

abs = "hello world"

def f():
print(abs)

dis.dis(f)
f()

当然我们可以干更疯狂的事情,我们可以通过 import builtins 来直接修改这些内置对象。我们让 builtins.abs 等于 hello world,这个时候我们再打印出来的 abs 也是 hello world。而当我们进行了这个操作之后,不管哪个文件使用 abs 这个函数,它都会是 hello world。当然它现在已经不是一个函数了,对吧?它就会报错。

import dis
import builtins

builtins.abs = "hello world"

def f():
print(abs)

dis.dis(f)
f()

闭包变量​

以上说的是全局变量。闭包变量的机制,我们在闭包的实现机制里详细地介绍过。

在使用的时候,大家只要意识到,当一个在外层函数被赋值的变量,在内层函数也被使用了的时候,就形成了闭包。而闭包简单地来说就是,这两个变量现在就是一个变量了。你可以把它认为是外层函数级别的全局变量,所谓作用域嘛。

我们可以看到,内层函数是能意识到这个变量是闭包变量的。同样地,外层函数也能意识到这是一个闭包变量。具体的字节码与 cell 机制,可以参考闭包的实现机制。

import dis

def outer():
c = 1
def f():
print(c)

dis.dis(f)
dis.dis(outer)

outer()

局部变量​

函数作用域​

最后我们说一下局部变量。可能有人觉得局部变量有什么好讲的,其实还是有。最重要的就是,Python 的局部变量只有一个 scope,就是 function scope。换句话说,Python 除了定义函数之外,并不会产生新的变量作用域。

比如我们给的这个非常经典的代码,在其他的一些语言中,可能这个 print(i, k) 是要报错的,因为 i 是一个循环变量,而 k 是在这个循环里面被赋值的变量。那有一些有所谓 code block scope 的语言,它的 loop、它的 if statement,可能都会产生新的作用域,但是 Python 不会。所以下面这段代码,print(i, k) 会打印出来 9 9,因为在最后一次循环结束的时候,i 是 9,k 也是 9。

def f():
for i in range(10):
k = i
print(i, k)

f()

异常变量的清理​

然后对于局部变量,有一个比较特殊的情况。大家可以看下面这段代码,我上来让 e 等于 1,然后我 raise 了一个 exception,把这个 exception 保存到 e。请问这个 exception 结束之后,我打印 e,结果是什么?

def f():
e = 1
try:
raise ValueError()
except ValueError as e:
pass
print(e)

f()

这个题如果你能非常有信心地做对,说明你之前做过这道题。它结果是这么一个东西,它告诉你 e 这个里面,什么都没有。这个结果是不是非常荒谬?我上来就给 e 赋值了,后来我把 e 覆盖了,它最后怎么也不能是什么都没有。

这里就涉及到 Python 一个特殊的机制。由于在 raise exception 的时候,这个 exception 里保存了 traceback,而 traceback 里面又有 frame,frame 里面保存了局部变量,所以在这里这个局部变量 e,就产生了一个 reference cycle,也就是所谓引用循环。那如果我们不去主动解除这个循环,这个函数哪怕在运行之后,它所有的局部变量都没有办法被正常地释放,直到下一个垃圾回收。

那正是出于这方面的考量,所以 Python 在实现的时候,就在这个 try block 之后,把保存的这个 exception 变量给删掉了。你可以认为它在这里做了一个 del e,就是它把这个局部变量的引用给强行干掉了,来解决这个 reference cycle 的问题。所以你在这个 try block 之后打印 e 就会报错。

def f():
e = 1
try:
raise ValueError()
except ValueError as e:
try:
pass
finally:
del e

print(e)

f()

那如果你真的需要在这个 try block 之外使用这个 exception,你需要显式地把它保存到另外一个变量里,这样你才能接着用它。

def f():
e = 1
try:
raise ValueError()
except ValueError as e:
exc = e

print(exc.__class__)

f()

列表推导式的作用域​

好,那有了之前的经验,我们再来看一下这段代码。我们上来让 x 等于 0,然后我们做了一个这个东西,叫 list comprehension。只不过在这个 list comprehension 里,我们又重复地用到了这个局部变量 x。请问打印出来的 x 是多少?没想到吧,还是 0。为什么呢?为什么这个 x 没有跟着这个 list comprehension 做改变呢?

def f():
x = 0
lst = [x for x in range(10)]
print(x)

f()

可能有的人会说,实际上这个 list comprehension 建立了一个新的匿名函数,而这个循环变量 x,实际上是在那个匿名函数里面定义的。这个答案非常好。能说出这个答案,说明你对 3.11 版本之前的 Python 非常熟悉。对于 3.11 之前的 Python 来说,这个答案是正确的。对所谓 x 的赋值,确实是在另外一个匿名函数里面完成的。

然而在 3.12 之后的版本里,就不存在这个匿名函数了,也就是这个 list comprehension 被真正地 inline 了。它没有再产生另外一个 function call。我们也可以看到,在这个 for loop 里面,是真的在对这个局部变量 x 进行 STORE_FAST。那为什么这个 print(x) 会打印出来这个 list comprehension 之前的 0 呢?

import dis
def f():
x = 0
lst = [x for x in range(10)]
print(x)

dis.dis(f)

因为 Python 在执行列表推导式之前,会先保存 x 原来的值 0;推导式结束后,再把这个值恢复给 x。即使 Python 3.12 将列表推导式内联了,它仍然通过“先保存、后恢复”的机制,保证循环变量不会改变外面的同名变量。

对照上面高亮的几行字节码:偏移 28 的 LOAD_FAST_AND_CLEARPython 3.12 为内联推导式新增的指令:把局部变量当前的值压到栈上,然后把这个变量清空(变成未绑定)。 把 x 当前的值压到栈上,同时把局部变量 x 清空;循环结束后偏移 52 的 STORE_FAST 再把栈上的旧值写回 x。最后偏移 82 到 86 那一段配合 ExceptionTable,是推导式中途抛异常时的兜底:同样先把旧值写回 x,再 RERAISE。

这里有两个细节值得注意。第一,推导式执行期间 x 不只是被保存了,而是被清空了,所以推导式内部读到的 x 永远是循环变量,不会读到外面的 0。第二,如果推导式之前 x 从未赋值,恢复回来的仍然是“未绑定”状态,这也是第 5 行的 print 用 LOAD_FAST_CHECK 而不是 LOAD_FAST 的原因:编译器知道这里的 x 可能没有值。