Python学习之路27-对象引用、可变性和垃圾回收

《流畅的Python》笔记

本篇是“面向对象惯用方法”的第一篇,一共六篇。本篇主要是一些概念性的讨论,内容有:Python中的变量,对象标识,值,别名,元组的某些特性,深浅复制,引用,函数参数,垃圾回收,del命令,弱引用等,比较枯燥,但却能解决程序中不易察觉的bug。html

1. 变量、标识、相等性和别名

先用一个形象的比喻来讲明Python中的变量:变量是标注而不是盒子。也就是说,Python中的变量更像C++中的引用,最能说明这一点的就是多个变量指向同一个列表,但也有例外,在遇到某些内置类型,好比字符串str时,变量则变成了“盒子”:python

# 代码1
>>> a = [1, 2]  
>>> b = a  # 标注,引用
>>> a.append(3)
>>> b
[1, 2, 3]
>>> c = "c"  
>>> d = c  # “盒子”
>>> c = "cc"
>>> d
'c'

补充:说到了赋值方式,Python和C++同样,也是等号右边先执行。算法

1.1 相等性( == )与标识( is )

用一个更学术的词来替换“标注”,那就是“别名”。在C++中,引用就是变量的别名,Python中也是,好比代码1中的变量b就是变量a的别名,但若是是如下形式,变量b则不是a的别名:编程

# 代码2
>>> a = [1, 2]
>>> b = [1, 2]
>>> a == b   # a和b的值相等
True
>>> a is b   # a和b分别绑定了不一样的对象,虽然对象的值相等 
False

==检测对象的值是否相等,is运算符检测对象的标识(ID)是否相等,id()返回对象标识的整数表示。通常判断两对象的标识是否相等并不直接使用id(),更多的是使用is运算符。缓存

对象ID在不一样的实现中有所不一样:在CPython中,id()返回对象的内存地址,但在其余Python解释器中多是别的值。但无论怎么,对象的ID必定惟一,且在生命周期中保持不变。微信

一般咱们关心的是值,而不是标识,因此==出现的频率比is高。但在变量和单例值之间比较时,应该使用is。目前,最常使用is检测变量绑定的值是否是None,推荐的写法是:数据结构

# 代码3
x is None  # 并不是 x == None
x is not None  # 并不是 x != None

is运算符比==速度快,由于它不能重载,因此Python不用寻找并调用特殊方法,而是直接比较两个对象的ID。a == b实际上是语法糖,实际调用a.__eq__(b)。虽然继承自object__eq__方法也是比较对象的ID,结果和is同样,但大多数内置类型覆盖了该方法,处理过程更复杂,这就是为何is==快。app

1.2 元组的相对不可变性

元组和大多数Python集合同样,保存的是对象的引用。元组的不可变性实际上是指tuple数据结构的物理内容(即保存的引用)不可变,与引用的对象无关。若是引用的对象可变,即使元组自己不可变,元素依然可变,不变的是元素的标识框架

# 代码4
>>> t1 = (1, 2, [30, 40])
>>> t2 = (1, 2, [30, 40])
>>> t1 == t2
True
>>> id(t1[-1])
2019589413704
>>> t1[-1].append(99)
>>> t1
(1, 2, [30, 40, 99])
>>> id(t1[-1])  # 内容变了,标识没有变
2019589413704
>>> t1 == t2
False

这同时也说明,并非每一个元组都是可散列的函数

2.深浅复制

复制对象时,相等性和标识之间的区别有更深刻的影响。副本与源对象相等,但ID不一样。而若是对象内部还有其余对象,这就涉及到了深浅复制的问题:究竟是复制内部对象呢仍是共享内部对象?

2.1 默认作浅复制

对列表和其余可变序列来讲,咱们可使用构造方法或[:]来建立副本。然而,这两种方法作的都是浅复制,它们只复制了最外层的容器,副本中的元素是源容器中元素的引用。若是全部元素都是不可变的,那这样作没问题,还能节省内存;但若是其中有可变元素,这么作就可能出问题:

# 代码5
l1 = [3, [11, 22], (7, 8)]
l2 = list(l1)      # <1>
l1.append(100)
l1[1].remove(22)
print("l1:", l1, "\nl2:", l2)
l2[1] += [33, 44]  # <2>
l2[2] += (10, 11)  # <3>
print("l1:", l1, "\nl2:", l2)

# 结果
l1: [3, [11], (7, 8), 100]  # 追加元素只影响了l1
l2: [3, [11], (7, 8)]       # 但删除l1[1]中的元素影响了两个列表
l1: [3, [11, 33, 44], (7, 8), 100]     # +=对可变对象是就地操做,影响了两个列表
l2: [3, [11, 33, 44], (7, 8, 10, 11)]  # +=对不可变对象会建立新对象,只影响了l2

以上代码有3点须要解释:

  • <1>:l1[1]l2[1]指向同一列表,l1[2]l2[2]指向同一元组。由于是浅复制,只是复制引用;
  • <2>:+=运算对可变对象来讲是就地运算,不会建立新对象,因此对两个列表都有影响;
  • <3>:+=运算对元组这样的不可变对象来讲,等同于l2[2] = l2[2] + (10, 11),此操做隐式地建立了新对象,l2[2]从新绑定到了新对象,因此只有列表l2[2]发生了改变,而l1[2]没有改变。

2.2 为任意对象作深复制和浅复制

浅复制并不是是一种错误,只是一种选择。而有时咱们须要的是深复制,即副本不共享内部对象的引用。copy模块提供的deepcopycopy函数能为任意对象作深复制和浅复制。

# 代码6
import copy

l1 = [3, [11, 22]]
l2 = copy.copy(l1)      # 浅复制
l3 = copy.deepcopy(l1)  # 深复制
l1[1].append(33)    # 影响了l2,但没有影响l3
print("l1:", l1, "\nl2:", l2, "\nl3:", l3)

# 结果
l1: [3, [11, 22, 33]] 
l2: [3, [11, 22, 33]] 
l3: [3, [11, 22]]

在作深复制时,若是对象之间有循环引用,朴素的深复制算法(换句话说就是你本身写的深复制算法)极可能会陷入无限循环,而后报错。deepcopy会记住已经复制的对象,而不会进入无限循环:

# 代码7
>>> a = [10, 20]
>>> b = [a, 30]  # 包含a的引用
>>> b
[[10, 20], 30]
>>> a.append(b)  # 相互引用
>>> a
[10, 20, [[...], 30]]
>>> a[2][0]
[10, 20, [[...], 30]]
>>> a[2][0][2][0]
[10, 20, [[...], 30]]
>>> from copy import deepcopy
>>> c = deepcopy(a) # 不会报错,能正确处理相互引用的问题
>>> c
[10, 20, [[...], 30]]

此外,深复制有时可能太深了。例如,对象可能会引用不应复制的外部资源或单例值,这时,深复制就不该该复制这些值。若是要控制copydeepcopy的行为,咱们能够在对象中重写特殊方法__copy____deepcopy__,具体内容这里就不展开了,你们能够参考copy模块的官方文档

3. 函数参数

经过别名共享对象还能解释Python中传递参数的方式,以及使用可变类型做为参数默认值引发的问题。

3.1 函数的参数做为引用时

Python惟一支持的参数传递模式是共享传参(call by sharing),它指函数的形参得到实参中各个引用的副本,即形参是实参的别名。这种方案的结果就是,函数可能会修改做为参数传入的可变对象,但没法修改这些对象的标识(不能把一个对象替换成另外一个对象):

# 代码8
def f(a, b):
    a += b
    return a

x, y = 1, 2
print(f(x, y), x, y)
a, b = [1, 2], [3, 4]
print(f(a, b), a, b)
t, u = (10, 20), (30, 40)
print(f(t, u), t, u)

# 结果
3 1 2 # x, y是不可变对象,没有影响到x, y
[1, 2, 3, 4] [1, 2, 3, 4] [3, 4]   # x是可变对象,影响到了x
(10, 20, 30, 40) (10, 20) (30, 40) # x没有指向新的元组,但形参a指向了新的元组

3.2 参数默认值

不要使用可变类型做为参数的默认值!其实这个问题在以前的文章“Python学习之路7-函数”的2.3小节中有所说起。如今咱们来看下面这个例子:

首先定义一个类:

# 代码9
class Bus:
    def __init__(self, passengers=[]):  # 默认值是个可变对象
        self.passengers = passengers
        
    def pick(self, name):
        self.passengers.append(name)
    
    def drop(self, name):
        self.passengers.remove(name)

下面是这个类的行为:

# 代码10
>>> bus1 = Bus(["Alice", "Bill"]) # 直到第8行Bus的表现都是正常的
>>> bus1.passengers
['Alice', 'Bill']
>>> bus1.pick("Charlie")
>>> bus1.drop("Alice")
>>> bus1.passengers
['Bill', 'Charlie']
>>> bus2 = Bus()  # 使用默认值
>>> bus2.pick("Carrie")
>>> bus2.passengers
['Carrie']   # 到目前为止也是正常的
>>> bus3 = Bus()  # 也是用默认值
>>> bus3.passengers
['Carrie']   # 不正常了!
>>> bus3.pick("Dave")
>>> bus2.passengers
['Carrie', 'Dave']  # bus2的值也被改变了
>>> bus2.passengers is bus3.passengers  # 这俩是同一对象的别名
True
>>> bus1.passengers # bus1依然正常
['Bill', 'Charlie']

上述行为的缘由在于,参数的默认值在导入模块时计算,方法或函数的形参指向这个默认值。而在上面这个例子中,类的属性self.passengers其实是形参passengers所指向的对象(所指对象,referent)的别名。而bus1行为正常是由于从一开始它的passengers就没有指向默认值。

这里有点像单例模式:参数的默认值是惟一的只要采用默认值,无论建立多少个Bus的实例,它们的self.passengers都是同一个空列表[]对象的别名,不会为每个实例单首创建一个专属的[]

运行上述代码以后,能够查看Bus.__init__对象的__defaults__属性,它存储了参数的默认值:

# 代码11
>>> Bus.__init__.__defaults__
(['Carrie', 'Dave'],)
>>> Bus.__init__.__defaults__[0] is bus2.passengers  # self.passengers就是一个别名!
True

这也说明了为何要用None做为接收可变值的参数的默认值:

# 代码12
class Bus:
    def __init__(self, passengers=None):  # 默认值是个可变对象
        if passengers is None:  # 并不推荐 if passengers == None 这种写法
            self.passengers = []
        else:
            self.passengers = list(passengers)  # 注意这里!
    -- snip --

代码12中的第7行并非直接把形参passengers赋值给self.passengers,而是形参的副本(这里是浅复制)。若是直接赋值,即self.passengers = passengersself.passengers变成了用户传入的参数的别名),则用户传入的参数在运行过程当中可能会被修改,而这并不必定是用户想要的,这便违反了"最少惊讶原则"(竟然还真有这么个原则

4. del和垃圾回收

对象毫不会自行销毁;然而,没法获得对象时,可能会被当作垃圾回收。——Python语言参考手册

del语句删除变量(即"引用"),而不是对象。del命令可能致使对象被当作垃圾回收,但这仅发生在当删除的变量保存的是对象的最后一个引用,或者没法获得对象时(若是两个对象相互引用,如代码7,当它们的引用只存在两者之间时,垃圾回收程序会断定它们都没法获取,进而把它们都销毁)。从新绑定也可能会致使对象的引用数量归零,进而对象被销毁。

在CPython中,垃圾回收使用的主要算法是引用计数。实际上,每一个对象都会统计有多少个引用指向本身。当引用计数归零时,对象当即被销毁。但在其余Python解释器中则不必定是引用计数算法。

补充:有个__del__特殊方法,它不是用来销毁实例的,而是在实例被销毁前用来执行一些最后的操做,好比释放外部资源等。咱们不该该在代码中调用它,Python解释器会在销毁实例时先调用它(若是定义了),而后再释放内存。它至关于C++中的析构函数。

咱们可使用weakref.finalize来演示对象被销毁时的状况:

# 代码13
>>> import weakref
>>> s1 = {1, 2, 3}
>>> s2 = s1
>>> def bye(): # 它充当一个回调函数
...     print("Gone with the wind...")
# 必定不要传入待销毁对象的绑定方法,不然会有一个指向对象的引用
>>> ender = weakref.finalize(s1, bye) # 在s1引用的对象上注册bye回调
>>> ender.alive
True
>>> del s1
>>> ender.alive
True  # 说明 del s1并无删除对象
>>> s2 = "spam" 
Gone with the wind...  # 引用计数为零,对象被删除
>>> ender.alive
False

5. 弱引用

不知道你们看到上述代码第15行时会不会产生以下疑惑:第8行代码明明把s1引用传给了finalize函数(为了监控对象和调用回调,必需要有引用),那么对象{1, 2, 3}则应该至少有三个引用,可为何最后它仍是被销毁了呢?这就牵扯到了弱引用这个概念。

5.1 weakref.ref

弱引用不会妨碍所指对象被当作垃圾回收,即弱引用不会增长对象的引用计数。(弱引用常被用于缓存,但具体用在缓存的哪些地方目前笔者还不清楚.....)

弱引用仍是可调用对象,下面的代码展现了如何使用weakref.ref实例获取所指对象。

补充在代码以前:Python控制台会自动把结果不为None的表达式的结果绑定到变量_(下划线)上。这也说明了一个问题:微观管理内存时,隐式赋值会为对象建立新引用,而这有可能会致使一些意外结果。

# 代码14
>>> import weakref
>>> a_set = {1, 2} # 对象{1, 2}的引用数+1
>>> wref = weakref.ref(a_set) # 并无增长所指对象的引用数
>>> wref
<weakref at 0x0000013D739E2D18; to 'set' at 0x0000013D739BE588>
>>> wref() # 弱引用是个可调用对象
{1, 2} # 发生了隐式赋值,变量 _ 指向了对象{1, 2},引用数+1
>>> a_set = {2, 3} # 引用数 -1
>>> wref() # 所指对象依然存在,尚未被销毁
{1, 2}
>>> wref() is None  # 此时所指对象依然存在
False # 变量 _ 指向了对象False,对象{1, 2}引用数归零,销毁
>>> wref() is None  # 验证所指对象已被销毁
True

5.2 weakref集合

weakref.ref类实际上是底层接口,供高级用途使用,通常程序最好使用werakref集合和finalize函数,即最好使用WeakKeyDictionaryWeakValueDictionaryWeakSetfinalize(它们在内部使用弱引用),不推荐本身动手建立并处理weakref.ref实例,除非你的工做就是专门和这些东西打交道的。

WeakValueDictionary类实现的是一种可变映射,里面的("键值对"中的"值",而不是字典中的"值")是对象的弱引用。被引用的对象在程序中的其余地方被当作垃圾回收后,对应的键会自动从WeakValueDictionary中删除。所以,它常常用于缓存。(查看缓存中变量是否依然存在?给框架用?)

# 代码15
>>> import weakref
>>> class Cheese:
...     def __init__(self, kind):
...         self.kind = kind
...
>>> stock = weakref.WeakValueDictionary()
>>> catalog = [Cheese("Red Leicester"), Cheese("Parmesan")]
>>> for cheese in catalog:
...     stock[cheese.kind] = cheese
...
>>> sorted(stock.keys())  
['Red Leicester', 'Parmesan']   # 表现正常
>>> del catalog
>>> sorted(stock.keys())
['Parmesan']  # 这是怎么回事?
>>> del cheese  # 这是问题所在
>>> sorted(stock.keys())
[]

临时变量引用了对象,这可能会致使该变量的存在时间比预期长。一般,这对局部变量来讲不是问题,由于它们在函数返回时会被销毁。但上述代码中,for循环中的变量cheese是全局变量,除非显示删除,不然不会消失。

WeakValueDictionary对应的是WeakKeyDictionary,后者的是弱引用,它的一些可能用途以下:

它的实例能够为应用中其余部分拥有的对象附加数据,这样就无需为对象添加属性。这对属性访问受限的对象尤为有用。

WeakSet类的用途则很简单:"保存元素弱引用的集合。当某元素没有强引用时,集合会把它删除。"若是一个类须要知道它的全部实例,一种好的方案是建立一个WeakSet类型的类属性,保存实例的弱引用。

5.3 弱引用的局限

weakref集合以及通常的弱引用,能处理的对象类型有限:

  • 基本的listdict实例不能做为弱引用的所指对象,但它们的子类则能够;

    class MyList(list):
        """MyList的实例可做为弱引用的所指对象"""
  • set的实例可做为所指对象;
  • 自定义类的实例能够;
  • inttuple的实例不能做为弱引用的所指对象,它们的子类也不行。

但这些局限基本上是CPython的实现细节,其余Python解释器的状况可能不一样。

6. CPython对不可变类型走的捷径

本节内容是Python实现的细节,能够跳过

这些细节是CPython核心开发者走的捷径和优化措施,利用这些细节写的代码在其余Python解释器中可能没用,在CPython将来的版本中也可能没用。下面是具体内容:

  • 对元组t来讲,t[:]tuple(t)不建立副本,而是返回同一个对象的引用;
  • strbytesfrozenset实例也是如此,而且frozensetcopy方法返回的也不是副本(注意,frozenset的实例fs不能用fs[:],由于fs不是序列);
  • str的实例还有共享字符串字面量的行为:

    >>> s1 = "ABC"
    >>> s2 = "ABC"
    >>> s1 is s2
    True

    这叫作"驻留"(interning),这是一种优化措施。CPython还会在小的整数上使用这种优化,防止重复建立经常使用数字,如0,-1。但CPython不会驻留全部字符串和数字,驻留的条件是实现细节,并且没有文档说明。因此千万不要依赖这个特性!(比较字符串或数字请用==,而不是is!)

7. 总结

每一个Python对象都有标识、类型和值,只有对象的值可能变化。

变量保存的是引用,这对Python编程有不少实际的影响:

  • 简单的赋值不会建立副本;
  • +=*=等运算符来讲,若是左边的变量绑定了不可变对象,则会建立新对象,而后从新绑定;若是是可变对象,则就地修改;
  • 对现有的变量赋予新值不会修改以前绑定的对象。这叫从新绑定:现有变量绑定了其它对象。若是变量是以前那个对象的最后一个引用,该对象会被回收;
  • 函数的参数以别名的形式传递,这意味着,函数可能会修改经过参数传入的可变对象。这一行为没法避免,除非在函数内部建立副本,或者使用不可变对象;
  • 不要使用可变类型做为函数的默认值!
  • ==用于比较值,is用于比较引用。

某些状况下,可能须要保存对象的引用,但不留存对象自己,好比记录某个类的全部实例,这能够用弱引用解决。


迎你们关注个人微信公众号"代码港" & 我的网站 www.vpointer.net ~

相关文章
相关标签/搜索