分享

Python的命名空间

 妙行莲花 2016-05-10

来自:segmentfault专栏-Python 学习之旅

作者: xu_zhoufeng

链接:https://segmentfault.com/a/1190000004519811(点击尾部阅读原文前往)


懒得扫全文的童鞋,可以直接跳到最后看总结。


我们先从一个简单的栗子说起:


栗子


a 文件中有变量 va 以及类 A,b 文件导入 a 中class A ,并打印出 A:

#a.py

va = ['dobi', 'a', 'dog']

 

print('a1', id(va))

 

class A():

    def __init__(self):

        pass

 

    def rtn(self):

        global va

        va.insert(1,'is')

        print('a3', id(va))

        return va

 

print('a2', va)

 

#b.py

from a import A

 

print('b', A)


执行 b 文件的结果为:

Reloaded modules: a

a1 2407907960200

a2 ['dobi', 'a', 'dog']

b


可以发现,虽然 b 只是导入了 a 中的 class A,但导入这个过程却执行了整个 a 文件,那么我们是否能够在 b 中访问 a 中的全局变量 va 呢:

print(va)

# NameError: name 'va' is not defined

print(a.va)

# NameError: name 'a' is not defined

print(b.va)

# NameError: name 'b' is not defined


尝试了各类调用方法,发现都无法正常访问 a 的全局变量 va,既然 b 的导入执行了整个 a 文件,甚至还打印出了 va 的 id 和值,又为什么无法在 b 中调用 va 呢?


这个问题所涉及到的内容就是:命名空间。


但在开始正题之前,我们需要阐明若干概念:


一些基本概念的澄清


对象


Python 一切皆对象,每个对象都具有 一个ID、一个类型、一个值;对象一旦建立,ID 便不会改变,可以直观的认为 ID 就是对象在内存中的地址:

a = [1, 2]

b = a

id(a)

# 2407907978632

id(b)

# 2407907978632

b[1] = 3

a

# [1, 3]


上例 a, b 共享了同一个 ID、同一个值、同一个类型。因此 a, b 表达的是同一个对象,但 a, b 又明显是不同的,比如一个叫 'a' 一个叫 'b'…既然是同一个对象,为什么又有不同的名字呢?难道名字不是对象的属性?


标识符


事实确实如此,这是 Python 比较特殊一点:如同'a' 'b' 这样的名称其实有一个共同的名字:identifier(注意不要与 ID 混淆了),中文名为“标识符”,来解释一下:


标识符:各类对象的名称,比如函数名、方法名、类名,变量名、常量名等。


在 Python 中赋值并不会直接复制数据,而只是将名称绑定到对象,对象本身是不知道也不需要关心(该关心这个的是程序猿)自己叫什么名字的。一个对象甚至可以指向不同的标识符,上例中的'a' 'b'便是如此。真正管理这些名子的事物就是本文的主角”命名空间” 。


命名空间


命名空间:(英语:Namespace)表示标识符(identifier)的可见范围。(ps:copy 自 SF)


简而言之,命名空间可以被简单的理解为:存放和使用对象名字的抽象空间。


作用域


与命名空间相对的一个概念就是“作用域”,那么什么又是作用域呢?


作用域:(英文 Scope)是可以直接访问到命名空间的文本区域。


这里需要搞清楚什么是直接访问:

#x.py

a = 1

class A():

    def func():pass


python x.py

a   #直接访问

# 1

A.func  #属性访问


Python 中不加 . 的访问为直接访问,反之为属性访问。

因此作用域必定是相对某个对象内部而言的,比如一个函数内部、一个模块全局等,那作用域和命名空间是什么关系呢:


作用域是一种特殊的命名空间,该空间内的名称可以被直接访问;

并不是所有的命名空间都是作用域。

看不懂? 没关系,后面会解释,我们先回到命名空间这个话题上:


现今 Python 的大部分命名空间是通过字典来实现的,也即一个命名空间就是名字到对象的映射。另外, Python 允许对命名空间进行嵌套,而我们经常接触的命名空间有四层:


LEGB 规则


LEGB 层级


这四层命名空间可以简记为 LEGB:


局部命名空间(local):指的是一个函数所定义的空间或者一个类的所有属性所在的空间,但需注意的是函数的局部命名空间是一个作用域,而类的局部命名空间不是作用域。

闭包命名空间(enclosing function):闭包函数 的作用域(Python 3 引入)。

全局命名空间(global):读入一个模块(也即一个.py文档)后产生的作用域。

内建命名空间(builtin):Python 解释器启动时自动载入__built__模块后所形成的名字空间;诸如 str/list/dict…等内置对象的名称就处于这里。

为了说清楚这几层洋葱皮,举个栗子:

#c.py

v1 = 'a global var'

 

def func(v):

    v2 = 'a local var'

    def inn_func():

        v3 = v2 + v

        return v3

    return inn_func



内建命名空间比较好理解,我们重点讲解下其他三个:


1、'v1' 为全局变量 v1 的名子,其所处的命名空间为全局命名空间;需要注意的是全局命名空间包括 'func' 但不包括 func 的作用域。

2、func 内部囊括 'v2' 和 'inn_func' 名称的空间为局部命名空间;

3、执行 func 后,func 的作用域释放(或许遗忘更合适),并返回了绑定了 v 和 v变量的闭包函数 inn_func,此时闭包所具有的作用域为闭包空间,因此局部命名空间和闭包命名空间是相对而言的,对于父函数 func 而言,两者具有产生时间上的差异。


LEGB 访问规则


搞清楚各个层级概念后,我们来说一下 LEGB 的访问规则: 同样的标识符在各层命名空间中可以被重复使用而不会发生冲突,但 Python 寻找一个标识符的过程总是从当前层开始逐层往上找,直到首次找到这个标识符为止:

#d.py

v1 = 1

v2 = 3

def f():

    v1 = 2

    print(1, v1)

    print(2, v2)

 

f()

print(3, v1)


1 2

2 3

3 1


上例中,全局变量和函数 f 都定义了 变量 v1,结果 Python 会优先选择 f 的局部变量 v1 ,对于 f 内并未定义的变量 v2 ,Python 会向上搜寻全局命名空间,读取全局变量 v2 后打印输出。


global 和 nonlocal 语句


global 和 nonlocal 的作用


如前所述,对于上层变量,python 允许直接读取,但是却不可以在内层作用域直接改写上层变量,来看一个典型的闭包结构:


#e.py

gv = ['a', 'global', 'var']

 

def func(v):

    gv = ['gv'] + gv #UnboundLocalError:local variable 'gv' referenced before assignment

    lv = []

    def inn_func():

        lv = lv + [v]  #UnboundLocalError:local variable 'lv' referenced before assignment

        gv.insert(1, lv[0])

        return gv

    return inn_func


上面对函数内的 gv 和 lv 进行赋值操作后,两处都会发生 UnboundLocalError,因为 Python 并不知道你是想在内层作用域生成一个同名的局部变量,还是想直接改写上层变量,因此便会引起错误。为此,Python 引入了 global、nonlocal 语句就来说明所修饰的 gv、lv 分别来自全局作用域和父函数作用域,声明之后,就可以在 func 和 inn_func 内直接改写上层作用域内 gv 和 lv 的值:


#f.py

gv = ['a', 'global', 'var']

 

def func(v):

    global gv

    gv = ['gv'] + gv

    lv = []

    print(id(lv))

    def inn_func():

        nonlocal lv

        lv = lv + [v]

        print(id(lv))

        gv.insert(1, lv[0])

        return gv

    return inn_func


a = func('is')

# 2608229974344

 

a()

# 2608229974344

# ['gv', 'is', 'a', 'global', 'var']

 

print(gv)

# ['gv', 'is', 'a', 'global', 'var']


如上,全局变量 gv 值被函数改写了, inn_func 修改的也确实是父函数 lv的值 (依据 ID 判断)。


借壳


那么是不是不使用 global 和 nonlocal 就不能达到上面的目的呢?来看看这段程序:


#g.py

gv = ['a', 'global', 'var']

 

def func(v):

    gv.insert(0, 'gv')

    lv = []

    print(id(lv))

    def inn_func():

        lv.append(v)

        print(id(lv))

        gv.insert(1, lv[0])

        return gv

    return inn_func


执行的结果:


a = func('is')

# 2608110869168

 

a()

# 2608110869168

# ['gv', 'is', 'a', 'global', 'var']

 

print(gv)

# ['gv', 'is', 'a', 'global', 'var']


可以发现,执行结果同上面完全一致,问题自然来了:“为什么不用 global nonlocal 也可以改写全局变量gv和父函数变量lv的值?


为了看清楚这个过程,我们将上面的gv.insert(0, 'gv') lv.append(v) 改写为 gv[0:0] = ['gv'] lv[:] = [v]:


#h.py

gv = ['a', 'global', 'var']

 

def func(v):

    gv[0:0] = ['gv']

    lv = []

    print(id(lv))

    def inn_func():

        lv[:] = [v]

        print(id(lv))

        gv.insert(1, lv[0])

        return gv

    return inn_func


执行结果:


a = func('is')

# 2608229959496

 

a()

# 2608229959496

# ['gv', 'is', 'a', 'global', 'var']


同 g.py 文件的执行结果完全一致,事实上两者之间的内在也是完全一样的。

So 我们其实改写的不是 gv 和 lv ,而是 gv 和 lv 的元素 gv[0:0] 和 lv[:] 。因此,不需要 global 和 nonlocal 修饰就可以直接改写,这就是“借壳”,在 nonlocal 尚未引入 Python 中,比如 Python 2.x 若要在子函数中改写父函数变量的值就得通过这种方法。

当然借壳蕴藏着一个相对复杂的标识符创建的问题:比如子函数通过借壳修改父函数变量lv的值,那么子函数的标识符lv是怎么绑定到父函数变量lv的值 ID 的上的?


关于这个问题,这里有个问答就是讨论这个的:python的嵌套函数中局部作用域问题?


global 和 nonlocal 语句对标识符创建的不同影响


另外,需要注意的是:global 语句只是声明该标识符引用的变量来自于全局变量,但并不能直接在当前层创建该标识符;nonlocal 语句则会在子函数命名空间中创建与父函数变量同名的标识符:


#j.py

gv = 'a global var'

 

def func():

    global gv

    lv = 'a local var'

    print(locals())

    def inn_func():

        nonlocal lv

        global gv

        print(locals())

    return inn_func


执行结果:


c = func()

{'lv': 'a local var'}   #运行 `func` 函数后,`global` 语句并未将 `gv` 变量引入局部命名空间

 

c()

{'lv': 'a local var'}   #运行闭包函数后,`nonlocal` 语句将父函数变量 `lv` 引入闭包命名空间


之所以 nonlocal 语句与 global 语句的处置不同,在于全局变量在模块内随时都可以访问,而父函数变量在父函数执行完毕后便会释放,因此 nonlocal 语句必须将父函数变量的标识符和引用写入闭包命名空间。


命名空间和标识符的创建


创建规则


实际上,到这里其实还有一个重要的重要问题没有解决:“标识符并不是天生就在命名空间内的,命名空间也不是平白无故就产生的,那么什么时候会创建命名空间和标识符呢?”

规则有三:


1、赋值、定义函数和类时产生标识符;

2、类和函数定义(def 和 lambda)执行时产生新的命名空间;

3、标识符产生地点决定标识符所处的命名空间。


这三点就是拿来秒懂的!不过,仍然有一点常常被忽视:类的命名空间:


类的局部命名空间


首先,函数和类执行时都会产生局部命名空间,但类的执行机制不同于函数:


#i.py

def a():

    print('function')

 

class A():

    print(1)

    class B():

        print(2)

        class C():

            print(3)


执行文件,结果为:


1

2

3


如上,类就是一个可执行的代码块,只要该类被加载,就会被执行,这一点不同于函数。

类之所以这么设计的原因在于:类是创建其他实例(生成其他的类或者具体的对象)的对象,因此必须在实例之前被创建,而类又可能涉及到与其他类的继承、重载等一系列问题,故在代码加载时就被创建利于提高效率和降低逻辑复杂度。


其次,与函数不同的是,类的局部命名空间并非作用域


class A():

    a = 1

    b = [a + i for i in range(3)]  #NameError: name 'a' is not defined


执行上段代码,我们可以发现在类 A 内列表推导式无法调取 a 的值,但函数却可以:


def func():

    a = 1

    b = [a + i for i in range(3)]

    print(b)

 

func()  #[1, 2, 3]


因此,A 中的 a 不同于函数 func 中的 a 在局部命名空间中可以被任意读取,之所以说是“不可以被任意”读取而不是“不可被读取”,原因在于在类A 的局部空间内,a 其实一定程度上是可以直接被读取的:


class A():

    a = 1

    c = a + 2


执行上段代码后:


A.c

#3


而上例中 b 的赋值操作不能执行,原因在于列表推导式会创建自己的局部命名空间,因此难以访问到 a。


编译与局部命名空间


Python 是动态语言,很多行为是动态发生的,但 Python 自身也在不断进步,比如为了提高效率,有些行为会在编译时候完成,局部变量的创建就是如此:


def func():

    a = 1

    def inn_func():

        print(a)  # error

        a = 2     # error

    inn_func()


上段程序还未执行,就提示存在有语法错误,原因在于python 解释器发现 inn_func 内存在自身的 a 变量,但却在声明之前就被 print 了。


总结


啰嗦了这么多,终于该结尾了!


我们再来回过头来看下文章开头的栗子:


1、为什么 b.py 只是导入 a.py 中的 class A,却执行了整个 a.py 文件?

答:因为 Python 并不知道 class A 在 a.py 文档的何处,为了能够找到 class A,Python 需要执行整个文档。

2、为什么 b.py 的导入执行了整个 a.py 文档,却在 b 中难以调用 a 的全局变量 va?

答:Python 的全局变量指的是模块全局,因此不可以跨文档,因此 global 语句也是不可以跨文档的。另外, b 只是导入了 a 的 class A,因此并不会导入 a 中所有的标识符,所以 类似a.va 这样的调用也是不起作用的。


关于命名空间:

1、赋值、定义类和函数都会产生新的标识符;

2、全局变量的标识符不能跨文档;

3、各级命名空间相互独立互不影响;

4、Python 总是从当前层逐渐向上寻找标识符;

5、内层作用域若想直接修改上层变量,需要通过 global nonlocal 语句先声明;

6、单纯的 global 语句并不能为所在层级创建相应标识符,但 nonlocal 语句可以在闭包空间中创建相应标识符;

7、类的局部命名空间不是作用域



●本文编号57,以后想阅读这篇文章直接输入57即可。

●输入m可以获取到文章目录


推荐15个技术类公众微信

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多