谈一谈Python当中对象的边界问题

固定边界:自由与孤独

Python 中有一些公民向来我行我素,它们特立独行,与他人之边界划定得清清楚楚。客气的人称它们是定长对象,或者叫不可变对象,然而,懂得一些历史典故的人又叫它们是铁公鸡 。这个典故出自何处呢?亏得我曾恶补过一段历史知识,知道这指的正是激进的道家弟子杨朱。

损一毫利天下,不与也;悉天下奉一身,不取也;  
人人不损一毫,人人不利天下,天下治矣!----春秋·杨朱  

对于定长对象,你不能为它增加元素,不能为它减少元素,不能为它修改元素,甚至不能轻易地复制和删除它!

这些对象自立于世,也自绝于世,你看它们长得是普普通通的,平平凡凡的,然而其灵魂却是自由自在的,其生命是富有尊严而不可侵犯的。若想与这些公民打交道,你就得依着它们的脾气,不可越雷池半步。

t1 = ('python', '666')  
t2 = ('python', '666')  
print(t1 is t2)  # 对象独立   result:False  

t1[1] = '狗'    # 不可修改元素  
TypeError  Traceback (most recent call last)
TypeError: 'tuple' object does not support item assignment  

Python 世界中存在一些“特权种族”,而特权种族无一例外地都出身于定长对象。它们是一脉相承的,其存在的合理性也是相似的,那就是便于共用内存资源,提高内存使用效率
谈一谈Python当中对象的边界问题_第1张图片
上表就是定长对象的一份名单。可知,它们占据了多数。

定长对象的特性让我不由地想到一类人,它们严守自己的边界,刻板而严谨,一心只在乎份内之事,默默承担下自己的责任,追求的是内在的自由。虽然也会时常与别人打交道,但是,它们不贪图扩大自己的利益,也不妄想要侵犯别人的领土。独立的个体养成了个人的品牌,它们的不变性成就了外人能有所依赖的确定性。

key1 = 'python 666'
key2 = ['python still 666']  
dict1 = {key1:  'that is true'}  # 'python 666': 'python still 666'  
dict2 = {key2: 'that is really true'}  # 报错  
TypeError  Traceback (most recent call last)
TypeError: unhashable type: 'list'  

Python 为了维护定长对象的独立性/确定性,在编译机制上做了不少优化,例如 Intern 机制与常量合并机制。其中的好处,我已经多次提及了。还不太了解的同学,请翻阅这篇文章《Python当中字符串的intern机制》。

坏处也有,那就是孤独。它们的孤独不在于没有同类,而在于不能(不容易)复制(深拷贝)自身。以字符串对象为例,你可以尝试多种多样的手段,然而到头来,却发现唯一通用的方法竟然要先把字符串“碎尸万段”,接着重新组装才行!

s = 'python 666'  

# 以下7种方法无法复制s字符串,使用id(x)==id(s)去验证  
s1 = s  
s2 = str(s)  
s3 = s[:]
s4 = s + ''  
s5 = '%s' % s  
s6 = s * 1  
import copy  
s7  = copy.copy(s)  

# 以下方法可以复制字符串,需‘打碎’后再重组  
s8 = ''.join(s)  

那么上面的 s8 字符串还是原来的 s 字符串吗?在 Python 的世界里,判定两个对象是否相同的标准是确定的,也即是看它们的 id 是否相等。因此,借助 Python 来回答这道题,答案会是:如果用 join() 方法把字符串粉碎成字符再组合,新的字符串不再是原来的字符串了

过程很“残忍”,但总归能稍稍释缓自由个体的孤独感了吧。

弹性边界:开放与节制

与定长对象不同,变长对象/可变对象信奉的是另一套哲学。

它们思想开放,采取的是兼容并包的处事观,会因地制宜的伸缩边界。 以列表对象为例,它乐意接纳所有其它的对象,肯花费精力去动态规划,也不惧于拔掉身上所有的“毛”。

l = ['python', '666']  
l.append('非常6')  # ['python', '666', '非常6']  
l.pop(1)  # ['python', '非常6']  
l.clear()  # []  

这些大胆的行为,在定长对象那里,都是不可想象的。在变长对象身上,你似乎能感受到一种海纳百川的风范,相比之下,定长对象的"铁公鸡"形象则立马显得格局忒小了。

变长对象并非没有边界,相反,它们更在乎自身的边界,不惜花费大量的资源来维持动态的稳定。一旦边界确定下来,它们绝不会允许越界行为。跟某些编程语言动不动就数组越界不同,Python 不存在切片越界,因为切片操作始终被控制为边界范围之内,索引超出的部分会自动被舍弃

c = ['a', 'b', 'c', 'd', 'e', 'f']  

# 不允许索引越界  
print(c[8])  
IndexError    Traceback (most recent call last)
IndexError: list index out of range  

# 允许切片越界  
print(c[2: 8])  # result: ['c', 'd', 'e', 'f']  
print(c[-8: 2])  # result: ['a', 'b']  

变长对象在本质上是一种可伸缩的容器,其主要好处就是支持不断添加或者取出元素。对应到计算机硬件层面,就是不断申请或者释放内存空间。这类操作是代价昂贵的操作,为了减少开销,Python 聪明地设计了一套分配超额空间的机制

以列表为例,在内存足够的前提下,最初创建列表时不分配超额空间,第一次 append() 扩充列表时,Python 会根据下列公式分配超额空间,即分配大于列表实际元素个数的内存空间,此后,每次扩充操作先看是否有超额空间,有则直接使用,没有则重新计算,再次分配一个超额空间。公式如下:

new_allocated = (newsize >> 3) + (newsize < 9 ? 3 : 6)  

其中,new_allocated 指的是超额分配的内存大小,newsize 是扩充元素后的实际长度。举例来说,一个长度为 4 的列表,append() 增加一个元素,此时实际长度为 5(即 newsize5),但是,Python 不会只给它分配 5 个内存空间,而是计算后给它超额分配 new_allocated == 3 个内存大小的空间,所以最终加起来,该列表的元素实际占用的内存空间就是 8

如此一来,当列表再次扩充时,只要最终长度不大于 8 ,就不需要再申请新的内存空间。当扩充后长度等于 9 时,new_allocated 等于 7 ,即额外获得 7 个内存大小,以此类推。

以列表长度为横轴,以超额分配的内存大小为纵轴,我们就得到了如下美妙的图表:
谈一谈Python当中对象的边界问题_第2张图片
超额分配的空间就是定长对象的软边界,这意味着它们在扩张时是有法度的,意味着它们在发展时是有大胆计划与适度节制的。如此看来,与定长对象的“固步自封”相比,变长对象就显得既开明又理智了。

引申

那么在Python当中无论是固定边界还是弹性边界,其实就是修身的两种志趣,有的对象独善其身其乐也融融,有的对象兼容并包其理想之光也莹莹。但是,关于边界问题,我们还有可探讨的知识点。

正如儒家经典所阐述:修身–齐家–治国–平天下。里层的势能推展开,会走进更广阔的维度。

Python 对象的边界也不只在自身。这里有一种巧妙的映射关系:对象(身)–函数(家)–模块(国)–包(天下)。个体被纳入到不同的命名空间,并活跃在分层的作用域里。
谈一谈Python当中对象的边界问题_第3张图片

你的名字

我们先来审视一下模块。这是一个合适的尺度,由此展开,可以顺利地连接起函数与包。

模块是什么? 任何以.py 后缀结尾的文件就是一个模块(module)。

模块的好处是什么? 首先,便于拆分不同功能的代码,单一功能的少量代码更容易维护;其次,便于组装与重复利用,Python 以丰富的第三方模块而闻名;最后,模块创造了私密的命名空间,能有效地管理各类对象的命名。

可以说,模块是 Python 世界中最小的一种自恰的生态系统——除却直接在控制台中运行命令的情况外,模块是最小的可执行单位。

前面,我把模块类比成了国家,这当然是不伦不类的,因为你难以想象在现实世界中,会存在着数千数万的彼此殊然有别的国家。

类比法有助于我们发挥思维的作用 ,因此,不妨就做此假设。如此一来,想想模块间的相互引用就太有趣了,这不是国家间的战争入侵,而是一种人道主义的援助啊,至于公民们的流动与迁徙,则可能成为一场探险之旅的谈资。

我还对模块的身份角色感兴趣。恰巧发现,在使用名字的时候,它们耍了一个双姓人的把戏

下面请看表演。先创建两个模块,A.pyB.py,它们的内容如下:

# A模块的内容:  
print('module A:', __name__)  

# B模块的内容:  
import A  
print('module B:', __name__)  

其中,__name__ 指的是当前模块的名字。代码的逻辑是:A 模块会打印本模块的名字,B 模块由于引入了 A 模块,因此会先打印 A 模块的名字,再打印本模块的名字。

那么我们来看看执行结果:

执行A.py的结果:

module A: __main__  

执行B.py的结果:

module A: A  
module B: __main__  

你们看出问题的所在了吧!模块 A 前后竟然出现了两个不同的名字。这两个名字是什么意思,又为什么会有这样的不同呢?

我想这正体现的是名字的本质吧——相对于自己来说,我就是我,并不需要一个名字来标记;而相对于他人来说,ta 是芸芸众生的一个,唯有命名才能区分。

所以,一个模块自己称呼自己的时候(即执行自身时)是“__main__”,而给他人来称呼的时候(即被引用时),就会是该模块的本名。这真是一个巧妙的设定。

由于模块的名称二重性,我们可以加个判断,将某个模块不对外的内容隐藏起来。

# A模块的内容  
print('module A:', __name__)  

if __name__ == '__main__':  
	print('private info.')  

以上代码中,只有在执行 A 模块本身时,才会打印“private info”,而当它被导入到其它模块中时,则不会执行到该语句部分的内容。

名字的时空

对于生物来说,我们有各种各样的属性,例如姓名、性别、年龄,等等。

对于 Python 的对象来说,它们也有各种属性。模块是一种对象,”__name__“就是它的一个属性。除此之外,模块还有如下最基本的属性:

import A  
print(dir(A))  

# result: ['__builtins__', '__cached__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__spec__']  

在一个模块的全局空间里,有些属性是全局起作用的,Python 称之为全局变量 ,而其它在局部起作用的属性,会被称为局部变量

一个变量对应的是一个属性的名字,会关联到特定的值。通过globals()locals(),可以将变量的“名值对”打印出来。

x = 1

def foo():
 y = 2
	print("全局变量:", globals())
	print("局部变量:", locals())

foo()  

# result:  
# 全局变量: {'__name__': '__main__', '__doc__': None, '__package__': None, '__loader__': <_frozen_importlib_external.SourceFileLoader object at 0x000001AC1EB7A400>, '__spec__': None, '__annotations__': {}, '__builtins__': , '__file__': 'C:/pythoncat/A.py', '__cached__': None, 'x': 1, 'foo': }
# 局部变量: {'y': 2}  

可以看出,x 是一个全局变量,对应的值是 1,而 y 是一个局部变量,对应的值是 2

两种变量的作用域不同 :局部变量作用于函数内部,不可直接在外部使用;全局变量作用于全局,但是在函数内部只可访问,不可修改。

与 Java、C++ 等语言不同,Python 并不屈服于解析的便利,并不使用呆滞的花括号来编排作用域,而是用了轻巧简明的缩进方式。不过,所有编程语言在区分变量类型、区分作用域的意图上都是相似的:控制访问权限与管理变量命名

关于控制访问权限,在上述例子中,局部变量 y 的作用域仅限于 foo 方法内,若直接在外部使用,则会报错“NameError: name 'y' is not defined”。

关于管理变量命名,不同的作用域管理着各自的独立的名册,一个作用域内的名字所指的是唯一的对象,而在不同作用域内的对象则可以重名。修改上述例子:

x = 1
y = 1

def foo():
    y = 2
    x = 2
    print("inside foo : x = " + str(x) + ", y = " + str(y))

foo()
print("outside foo : x = " + str(x) + ", y = " + str(y))  

# result:  
# inside foo : x = 2, y = 2
# outside foo : x = 1, y = 1  

可见,同一个名字可以出现在不同的作用域内,互不干扰。

那么,如何判断一个变量在哪个作用域内?对于嵌套作用域,以及变量名存在跨域分布的情况,要采用何种查找策略呢?

Python 设计了命名空间(namespace) 机制,一个命名空间在本质上是一个字典、一个花名册,登记了所有变量的名字以及对应的值。 按照记录内容的不同,可分为四类:

  • 局部命名空间(local namespace),记录了函数的变量,包括函数的参数和局部定义的变量。可通过内置函数 locals() 查看。在函数被调用时创建,在函数退出时删除
  • 全局命名空间(global namespace),记录了模块的变量,包括函数、类、其它导入的模块、模块级的变量和常量。可通过内置函数 globals() 查看。在模块加载时创建,一直存在
  • 内置命名空间(build-in namespace),记录了所有模块共用的变量,包括一些内置的函数和异常。在解释器启动时创建,一直存在
  • 命名空间包(namespace packages),包级别的命名空间,进行跨包的模块分组与管理

命名空间总是存在于具体的作用域内,而作用域存在着优先级,查找变量的顺序是:局部/本地作用域 --> 全局/模块/包作用域 --> 内置作用域

命名空间扮演了变量与作用域之间的桥梁角色,承担了管理命名、记录名值对与检索变量的任务。无怪乎《Python之禅》(The Zen of Python)在最后一句中说:

Namespaces are one honking great idea -- let's do more of those!

译:命名空间是个牛bi哄哄的主意,应该多加运用!  

看不见的客人

名字(变量)是身份问题,空间(作用域)是边界问题,命名空间兼而有之。

这两个问题恰恰是所有猿儿们困扰的最核心的问题之二。它们的特点是:无处不在、层出不穷、像一个超级大的被扯乱了的毛线球。

Python 是一种人工造物,它继承了人类的这些麻烦(这是不可避免的),所幸的是,这种简化版的麻烦能够得到解决。

这里就有几个问题(注:每个例子相互独立):

# 例1:
x = x + 1

# 例2:
x = 1
def foo():
    x = x + 1
foo()

# 例3:
x = 1
def foo():
    print(x)
    x = 2
foo()

# 例4:
def foo():
    if False:
        x = 3
    print(x)
foo()

# 例5:
if False:
    x = 3
print(x)

下面给出几个选项,请读者们思考一下,给每个例子选一个答案:

1、没有报错

2、报错:name 'x' is not defined

3、报错:local variable 'x' referenced before assignment  

下面我来公布答案了:

全部例子都报错,其中例 1例 5 是第一类报错,即变量未经定义不可使用,而其它例子都是第二类报错,即已定义却未赋值的变量不可使用。为什么会报错?为什么报错会不同?下面逐一解释。

  1. 例 1 是一个定义变量的过程,本身未完成定义,而等号右侧就想使用变量 x,因此报变量未定义。

  2. 例 2例 3 中,已经定义了全局变量 x,如果只在 foo 函数中引用全局变量 x 或者只是定义新的局部变量 x 的话,都不会报错,但现在既有引用又有重名定义,这引发了一个新的问题。请看下例的解释。

  3. 例 4 中,if 语句判断失效,因此不会执行到 “x=3” 这句,照理来说 x 是未被定义。这时候,在 locals() 局部命名空间中也是没有内容的(读者可以试一下)。但是 print 方法却报找到了一个未赋值的变量 x,这是为什么呢?我们使用 dis 模块来查看一下 foo 函数的字节码:
    谈一谈Python当中对象的边界问题_第4张图片
    LOAD_FAST 说明它在局部作用域中找到了变量名 x,结果 0 说明未找到变量 x 所指向的值。既然此时在 locals() 局部命名空间中没有内容,那局部作用域中找到的 x 是来自哪里的呢?
    实际上,Python 虽然是所谓的解释型语言,但它也有编译的过程 (跟 Java 等语言的编译过程不同)。对这个知识点不熟悉的同学,可以戳这《Python程序的执行过程(解释型语言和编译型语言)》。在例 2-4 中,编译器先将 foo 方法解析成一个抽象语法树abstract syntax tree),然后扫描树上的名字(name)节点,接着,所有被扫描出来的变量名,都会作为局部作用域的变量名存入内存(栈对象)当中
    在编译期之后,局部作用域内的变量名已经确定了,只是没有赋值。在随后的解释期(即代码执行期),如果有赋值过程,则变量名与值才会被存入局部命名空间中,可通过 locals() 查看。只有存入了命名空间,变量才算真正地完成了定义(声明+赋值)
    而上述 3 个例子之所以会报错,原因就是变量名已经被解析成局部变量,但是却未曾被赋值。
    可以推论:在局部作用域中查找变量,实际上是分查内存与查命名空间两步的。另外,若想在局部作用域内修改全局变量,需要在作用域中写上 “global x”。

  4. 例 5 是作为例 4 的对比,也是对它的原理的补充。它们的区别是,一个不在函数内,一个在函数内,但是报错完全不同。前面分析了例 4 的背后原理是编译过程和抽象语法树,如果这个原理对例 5 也生效,那两者的报错应该是一样的。现在出现了差异,为什么呢?
    我得承认,这触及到了我的知识盲区。我们可以推测,说例 5 的编译过程不同,它没有解析抽象语法树的步骤,但是,继续追问下去,为什么不同,为什么没有解析语法树的步骤呢?如果说是出于对解析函数与解析模块的代价考虑,或者其它考虑,那么新的问题是,编译与解析的底层原理是什么,如果有其它考虑,会是什么?
    这些问题真不可爱,一个都答不上。但是,自己一步一步地思考探寻到这一层,又能怪谁呢?

回到前面说过的话题,命名空间是身份与边界的集成问题,它跟作用域密切相关。如今看来,编译器还会掺和一脚,把这些问题搅拌得更加复杂。

本来是在探问 Python 中的边界问题,到头来,却触碰到了自己的知识边界。真是反讽啊。

边界内外的边界

暂时把那些不可爱的问题抛开吧,继续说修身齐家治国平天下。

想要把国治理好,就不得不面对更多的国内问题与国际问题。

先看一个大家与小家的问题:

def make_averager():
    count = 0
    total = 0
    def averager(new_value):
        nonlocal count, total
        count += 1
        total += new_value
        return total / count
    return averager

averager = make_averager()
print(averager(10))
print(averager(11))

### result:
# 10.0
# 10.5  

这里出现了嵌套函数,即函数内还包含其它函数。外部–内部函数的关系,就类似于模块–外部函数的关系,同样地,它们的作用域关系也相似:外部函数作用域–内部函数作用域,以及模块全局作用域–外部函数作用域。在内层作用域中,可以访问外层作用域的变量,但是不能直接修改,除非使用 nonlocal 作转化。

Python 3 中引入了 nonlocal 关键字来标识外部函数的作用域,它处于全局作用域与局部作用域之间,即 global--nonlocal--local 。也就是说,国–大家–小家。

上例中,nonlocal 关键字使得小家(内部函数)可以修改大家(外部函数)的变量,但是该变量并不是创建于小家,当小家函数执行完毕时,它并无权限清理这些变量。

nonlocal 只带来了修改权限,并不带来回收清理的权限 ,这导致外部函数的变量突破了原有的生命周期,成为自由变量。上例是一个求平均值的函数,由于自由变量的存在,每次调用时,新传入的参数会跟自由变量一起计算。

在计算机科学中,引用了自由变量的函数被称为闭包(Closure)。 在本质上,闭包就是一个突破了局部边界,所谓“跳出三界外,不在五行中”的法外之物。每次调用闭包函数时,它可以继续使用上次调用的成果,这不就好比是一个转世轮回的人(按照某种宗教的说法),仍携带着前世的记忆与技能么?

打破边界,必然带来新的身份问题,此是明证。

然而,人类并不打算 fix 它,因为他们发现了这种身份异化的特性可以在很多场合发挥作用,例如装饰器与函数式编程。适应身份异化,并从中获得好处,这可是地球人类的天赋。

讲完了这个分家的话题,让我们放开视野,看看天下事。

计算机语言中的包(package)实际是一种目录结构,以文件夹的形式进行封装与组织,内容可涵盖各种模块(.py 文件)、配置文件、静态资源文件等。

与包相关的话题可不少,例如内置包、第三方包、包仓库、如何打包、如何用包、虚拟环境,等等。这是可理解的,更大的边界,意味着更多的关系,更大的边界,也意味着更多的知识与未知。

运用命名空间包的设计,不同包中的相同的命名空间可以联合起来使用,由此,不同目录的代码就被归纳到了一个共同的命名空间。也就是说,多个本来是相对独立的包,借由同名的命名空间,竟然实现了超远距离的瞬间联通,简直奇妙

注:感谢Python猫的倾情分享!

你可能感兴趣的:(python)