深度分析python UnboundLocalError: local variable ‘x‘ referenced before assignment

  • Post author:
  • Post category:python


对于该报错,典型的例子如下所示,对此网上的解释很多,但基本都是说,由于赋值把变量声明成了本地变量,但是本地并没有定义该变量,所以报错。解决办法是在本地通过global或nonlocal关键词将其声明为全局或者外层变量。这没有问题,只是这种解释不痛不痒,比较粗浅,没有进一步的分析原因。

x = 1

def f():
    x += 1
    print(x)

# f() --> raise UnboundLocalError

python程序的运行分为编译和执行,这里的编译指的是将源码编译成字节码,然后再由解释器解释执行。在编译阶段,编译器会对源码进行语法分析,在这个阶段,每个变量的所属的命名空间就会根据python的LEGB规则被确定,所以编译后的字节码实际上已经确定了变量的命名空间了。因此,当解释器执行字节码想要获取某个变量时,python会直接到其所属的命名空间中去获取,如果获取不到该变量(比如此时该变量还没有定义),便会报错。

下面例子中,尽管c的赋值操作时发生在该语句之后,但因为在解释器执行该程序之前的编译阶段,该变量被识别为本地变量,但是由于print(c)语句执行时,本地变量c还没有在本地定义,所以在本地的命名空间中并不存在,因此报错。所以下面的两个例子在第X行都会抛出同样的异常,即使赋值语句发送在第X行之后,甚至真正解释器执行的时候永远都不会执行到赋值语句。

a,b,c = 1,2,3
def f1():
    print(a)
    print(b)
    print(c) # line X
    c += 1


def f2():
    print(a) # line X
    if False:
        a += 1

f1() # raise UnboundLocalError at line X
f2() # raise UnboundLocalError at line X

这可以解释有些人的部分困惑:解释器明明是按照文本顺序逐行解释的,为什么彷佛可以预知后面几行的赋值语句,提前抛出异常呢?现在我们知道,这其实是因为在解释之前的编译阶段,就已经给该变量确定了所属的命名空间。更准确的说,在字节码中,由于在语法分析阶段发现该变量在本地发生了被赋值操作,所以该变量已经被认为是本地变量,字节码里面会通过FAST来标识本地变量,所以解释器遇到FAST时,只会在本地的命名空间去找寻该变量,但是该变量并没有在本地初始化,因此,本地的命名空间中并没有该变量,那么就会抛出该异常,即本地变量没有绑定值,也就是没有初始化。

下面是对上述函数f1的字节码呈现,在编译的语法分析阶段,会将f1函数编译成下面的字节码,可以发现,对于a,b,都将其编译成全局变量,以LOAD_GLOBAL标识;但是对于c,在print(c)语句中,c的导入是LOAD_FAST,表示其已经是本地变量了。

dis.dis(f1)
  4           0 LOAD_GLOBAL              0 (print)
              2 LOAD_GLOBAL              1 (a)
              4 CALL_FUNCTION            1
              6 POP_TOP

  5           8 LOAD_GLOBAL              0 (print)
             10 LOAD_GLOBAL              2 (b)
             12 CALL_FUNCTION            1
             14 POP_TOP

  6          16 LOAD_GLOBAL              0 (print)
             18 LOAD_FAST                0 (c)
             20 CALL_FUNCTION            1
             22 POP_TOP

  7          24 LOAD_FAST                0 (c)
             26 LOAD_CONST               1 (1)
             28 INPLACE_ADD
             30 STORE_FAST               0 (c)
             32 LOAD_CONST               0 (None)
             34 RETURN_VALUE

如果我们在代码中,通过global关键字显式的将c声明为全局变量,那么在语法分析时,就会将c认为时全局变量,解释器便会在全局命名空间中去寻找该变量。

对于全局变量,我们可以通过globals()去获取,其返回一个字典,不可修改,key就是全局变量名,value是对应的值。同理,本地变量可以通过locals()获取。这里的globals()和locals()返回的变量都是可以访问的已经初始化了的变量,未初始化的变量不可见,不会存在其中。

如果我们在一个函数中通过global关键字声明了一个之前不存在的变量x会怎么样呢?实际上同理,在编译阶段,将其标识为全局变量,但是全局命名空间中不存在,引用会报错,但对于全局变量,不是报UnboundLocalError异常,而是NameError:name ‘x’ is not defined,该变量名未定义。

python中可以通过gloabl、nonlocal来将变量的命名空间声明为全局和外层的,那么哪些操作会将变量声明为本地变量呢?本文列出如下场景,尽量在代码中注意到这些点,以避免再犯类似错误:

1. 对于一切的赋值操作,都会将变量声明为本地变量;

2. 对于不可变对象,一切改变对象的操作,也会将其声明为本地变量,比如赋值语句或del x语句;

3. 本地import x,x也会被声明为本地变量;

4. 本地的异常捕捉中对异常重新命名,如try…except Exception as e,尽管没有异常,但是该语句已经将e声明为了本地变量,因此在此之前,对e的引用被认为是本地变量e的引用;此外,对于异常捕捉中新命名的变量e,需要额外注意,一旦抛出异常被捕捉后进入捕捉后的except代码块,e相对于该代码块是本地局部变量,一旦跳出该代码块,e会从本地命名空间中被删除,从而后续本地代码再引用e,会抛出UnboundLocalError异常,但是如果没有进入except代码块便不会被删除;这属于3.x的新特性,在2.x中,e会一直被保留。具体如下所示,调用该函数,执行到第x行,会抛出UnboundLocalError异常,原因是解释器从上面的print(e)跳出来后,e就被删除了,所以下面的print(e)中的e变成了未初始化的本地变量,因为无法在本地命名空间中找到。但是在2.x中,不会删除,所以代码可以正常运行。

def f():
    e=1
    print(e)
    try:
        x=0
        b = 1/x
    except Exception as e:
        print(e)
    print(e)  # line X

5. for x in iterable语句中,会将x声明为本地变量,所以如果之前预期引用了全局的x变量,由于后面的for loop会将x声明为本地变量,因此会抛出UnboundLocalError异常;

6. 2.x中的列表推导式[x for x in iterable]会将x声明为本地变量,同样若执行上述相似操作,会抛出异常;在2.x中,列表推导式的变量x会一致在本地有效;但是在3.x中,列表推导式会拥有一个自己的作用域,x只会存在在列表推导式的作用域中,不会对外层代码有所影响,因此,在3.x中,列表推导式不会将x声明为本地变量,也不会对后续代码有影响;但是无论是2.x还是3.x,元组推导式(生成器推导式)以及字典推导式都和3.x中列表推导式的行为一样,拥有自身的作用域,不会对外层代码造成影响。



版权声明:本文为S_o_l_o_n原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。