Python 中的作用域准则

0x00 前言

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

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

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

0x01 作用域的几个实例

但也有部分例外的情况,比如:

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 为从全局变量中加载

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

1
2
3
复制代码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 中找不到,并不向上查找,就报引用错误。

1
2
3
复制代码这不是 BUG
这不是 BUG
这不是 BUG

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

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

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

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

0xEE 参考链接


ChangeLog:

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

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

0%