Python 中的作用域准则

0x00 前言

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

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

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

0x01 作用域的几个实例

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

1.1 第一个例子

作用域第一版代码如下

a = 1
print(a, id(a)) # 打印 1 4465620064
def func1():
    print(a, id(a))
func1()  # 打印 1 4465620064

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

  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 第二个例子

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

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

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

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

  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 值看看。

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 报错鸟
  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 第四个例子

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 依旧是无法修改。

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

'int' object is not iterable

1.5 第五个例子

a = [1]
def func5():
    print(a, id(a))
    a.append(1)
    print(a, id(a))
func5()
# [1] 4500243208
# [1, 1] 4500243208

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

  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 提供了一个不错的例子用来说明

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 变量了。要是按照这个查找原则的话,似乎有点说不通了。

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

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 从原有笔记中抽取本文整理而成
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,117评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,328评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,839评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,007评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,384评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,629评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,880评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,593评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,313评论 1 243
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,575评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,066评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,392评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,052评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,082评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,844评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,662评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,575评论 2 270

推荐阅读更多精彩内容