Python 中的作用域准则

0x00 前言

因为最早用的是 Java 和 C#,写 Python 的时候自然也把 Python 作用域的想的和原有的一致。

Python 的作用域变量遵循在大部分情况下是一致的,但也有例外的情况。

本文着通过遇到的一个作用域的小问题来说说 Python 的作用域

0x01 作用域的几个实例

Python 的作用域变量遵循在大部分情况下与其他语言一致,但也有例外的情况。比如:

1.1 第一个例子

作用域第一版代码如下

1
2
3
4
5
a = 1
print(a, id(a)) # 打印 1 4465620064
def func1():
print(a, id(a))
func1() # 打印 1 4465620064

作用域第一版对应字节码如下

1
2
3
4
5
6
7
8
9
4 0 LOAD_GLOBAL 0 (print)
3 LOAD_GLOBAL 1 (a)
6 LOAD_GLOBAL 2 (id)
9 LOAD_GLOBAL 1 (a)
12 CALL_FUNCTION 1 (1 positional, 0 keyword pair)
15 CALL_FUNCTION 2 (2 positional, 0 keyword pair)
18 POP_TOP
19 LOAD_CONST 0 (None)
22 RETURN_VALUE

PS: 行 4 表示 代码行数 0 / 3 / 9 … 不知道是啥,我就先管他叫做吧 是 load global
PPS: 注意条 3/6 LOAD_GLOBAL 为从全局变量中加载

顺手附上本文需要着重理解的几个指令

LOAD_GLOBA          : Loads the global named co_names[namei] onto the stack.
LOAD_FAST(var_num)  : Pushes a reference to the local co_varnames[var_num] onto the stack.
STORE_FAST(var_num) : Stores TOS into the local co_varnames[var_num].

这点似乎挺符合我们认知的,那么,再深一点呢?既然这个变量是可以 Load 进来的就可以修改咯?

1.2 第二个例子

然而并不是,我们看作用域第二版对应代码如下

1
2
3
4
5
6
a = 1
print(a, id(a)) # 打印 1 4465620064
def func2():
a = 2
print(a, id(a))
func2() # 打印 2 4465620096

一看,WTF, 两个 a 内存值不一样。证明这两个变量是完全两个变量。

作用域第二版对应字节码如下

1
2
3
4
5
6
7
8
9
10
11
12
4 0 LOAD_CONST 1 (2)
3 STORE_FAST 0 (a)
5 6 LOAD_GLOBAL 0 (print)
9 LOAD_FAST 0 (a)
12 LOAD_GLOBAL 1 (id)
15 LOAD_FAST 0 (a)
18 CALL_FUNCTION 1 (1 positional, 0 keyword pair)
21 CALL_FUNCTION 2 (2 positional, 0 keyword pair)
24 POP_TOP
25 LOAD_CONST 0 (None)
28 RETURN_VALUE

注意行 4 条 3 (STORE_FAST) 以及行 5 条 9/15 (LOAD_FAST)

这说明了这里的 a 并不是 LOAD_GLOBAL 而来,而是从该函数的作用域 LOAD_FAST 而来。

1.3 第三个例子

那我们在函数体重修改一下 a 值看看。

1
2
3
4
5
6
7
a = 1
def func3():
print(a, id(a)) # 注释掉此行不影响结论
a += 1
print(a, id(a))
func3() # 当调用到这里的时候 local variable 'a' referenced before assignment
# 即 a += 1 => a = a + 1 这里的第二个 a 报错鸟
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
3 0 LOAD_GLOBAL 0 (print)
3 LOAD_FAST 0 (a)
6 CALL_FUNCTION 1 (1 positional, 0 keyword pair)
9 POP_TOP
4 10 LOAD_FAST 0 (a)
13 LOAD_CONST 1 (1)
16 BINARY_ADD
17 STORE_FAST 0 (a)
5 20 LOAD_GLOBAL 0 (print)
23 LOAD_FAST 0 (a)
26 CALL_FUNCTION 1 (1 positional, 0 keyword pair)
29 POP_TOP
30 LOAD_CONST 0 (None)
33 RETURN_VALUE

那么,func3 也就自然而言由于没有无法 LOAD_FAST 对应的 a 变量,则报了引用错误。

然后问题来了,a 为基本类型的时候是这样的。如果引用类型呢?我们直接仿照 func3 的实例把 a 改成 list 类型。如下

1.4 第四个例子

1
2
3
4
5
6
7
8
a = [1]
def func4():
print(a, id(a)) # 这条注不注释掉都一样
a += 1 # 这里我故意写错 按理来说应该是 a.append(1)
print(a, id(a))
func4()
# 当调用到这里的时候 local variable 'a' referenced before assignment

╮(╯▽╰)╭ 看来事情那么简单,结果变量 a 依旧是无法修改。

可按理来说跟应该报下面的错误呀

1
'int' object is not iterable

1.5 第五个例子

1
2
3
4
5
6
7
8
a = [1]
def func5():
print(a, id(a))
a.append(1)
print(a, id(a))
func5()
# [1] 4500243208
# [1, 1] 4500243208

这下可以修改了。看一下字节码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
3 0 LOAD_GLOBAL 0 (print)
3 LOAD_GLOBAL 1 (a)
6 LOAD_GLOBAL 2 (id)
9 LOAD_GLOBAL 1 (a)
12 CALL_FUNCTION 1 (1 positional, 0 keyword pair)
15 CALL_FUNCTION 2 (2 positional, 0 keyword pair)
18 POP_TOP
4 19 LOAD_GLOBAL 1 (a)
22 LOAD_ATTR 3 (append)
25 LOAD_CONST 1 (1)
28 CALL_FUNCTION 1 (1 positional, 0 keyword pair)
31 POP_TOP
5 32 LOAD_GLOBAL 0 (print)
35 LOAD_GLOBAL 1 (a)
38 LOAD_GLOBAL 2 (id)
41 LOAD_GLOBAL 1 (a)
44 CALL_FUNCTION 1 (1 positional, 0 keyword pair)
47 CALL_FUNCTION 2 (2 positional, 0 keyword pair)
50 POP_TOP
51 LOAD_CONST 0 (None)
54 RETURN_VALUE

从全局拿来 a 变量,执行 append 方法。

0x02 作用域准则以及本地赋值准则

2.1 作用域准则

看来这是解释器遵循了某种变量查找的法则,似乎就只能从原理上而不是在 CPython 的实现上解释这个问题了。

查找了一些资料,发现 Python 解释器在依据 基于 LEGB 准则 (顺手吐槽一下不是 LGBT)

LEGB 指的变量查找遵循

  • Local
  • Enclosing-function locals
  • Global
  • Built-In

StackOverFlow 上 martineau 提供了一个不错的例子用来说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
x = 100
print("1. Global x:", x)
class Test(object):
y = x
print("2. Enclosed y:", y)
x = x + 1
print("3. Enclosed x:", x)
def method(self):
print("4. Enclosed self.x", self.x)
print("5. Global x", x)
try:
print(y)
except NameError as e:
print("6.", e)
def method_local_ref(self):
try:
print(x)
except UnboundLocalError as e:
print("7.", e)
x = 200 # causing 7 because has same name
print("8. Local x", x)
inst = Test()
inst.method()
inst.method_local_ref()

我们试着用变量查找准则去解释 第一个例子 的时候,是解释的通的。

第二个例子,发现函数体内的 a 变量已经不是那个 a 变量了。要是按照这个查找原则的话,似乎有点说不通了。

但当解释第三个例子的时候,就完全说不通了。

1
2
3
4
5
6
7
a = 1
def func3():
print(a, id(a)) # 注释掉此行不影响结论
a += 1
print(a, id(a))
func3() # 当调用到这里的时候 local variable 'a' referenced before assignment
# 即 a += 1 => a = a + 1 这里的第二个 a 报错鸟

按照我的猜想,这里的代码执行可能有两种情况:

  • 当代码执行到第三行的时候可能是向从 local 找 a, 发现没有,再找 Enclosing-function 发现没有,最后应该在 Global 里面找到才是。注释掉第三行的时候也是同理。
  • 当代码执行到第三行的时候可能是向下从 local 找 a, 发现有,然后代码执行,结束。

但如果真的和我的想法接近的话,这两种情况都可以执行,除了变量作用域之外还是有一些其他的考量。我把这个叫做本地赋值准则 (拍脑袋起的名称)

一般我们管这种考量叫做 Python 作者就是觉得这种编码方式好你爱写不写 Python 作者对于变量作用域的权衡。

事实上,当解释器编译函数体为字节码的时候,如果是一个赋值操作 (list.append 之流不是赋值操作),则会被限定这个变量认为是一个 local 变量。如果在 local 中找不到,并不向上查找,就报引用错误。

这不是 BUG
这不是 BUG
这不是 BUG

这是一种设计权衡 Python 认为 虽然不强求强制声明类型,但假定被赋值的变量是一个 Local 变量。这样减少避免动态语言比如 JavaScript 动不动就修改掉了全局变量的坑。

这也就解释了第四个例子中赋值操作报错,以及第五个例子 append 为什么可以正常执行。

如果我偏要勉强呢? 可以通过 global 和 nonlocal 来 引入模块级变量 or 上一级变量。

PS: JS 也开始使用 let 进行声明,小箭头函数内部赋值查找变量也是向上查找。

0xEE 参考链接


ChangeLog:

  • 2017-11-20 从原有笔记中抽取本文整理而成