本章先用一个比喻说变量不是盒子,而是标注,然后讨论对象标示,值和别名的概念。随后会揭露元组的一个神奇的特性,元组是不可变的,其中的值可以改变。之后引申到浅复制和深复制。接下来是引用和函数参数。然后最后一节讨论垃圾回收,del 命令以及如何使弱引用 “记住” 对象,而无需对象本身存在

本章的内容是许多 Python 程序中不易察觉的 bug 关键

变量不是盒子

人们经常使用变量是盒子这样的比喻,但是这不易理解面向对象语言中的引用式变量。最好把它们理解为附加在对象上的标注:

In [1]:
a = [1, 2, 3]
b = a
a.append(4)
b
Out[1]:
[1, 2, 3, 4]

这里是 a 和 b 都指向同一块地址,而不是把 a 的内容复制一份给 b

关于赋值方式,是从右面先执行的,下面例子说明了这一点:

In [3]:
class Gizmo:
    def __init__(self):
        print('Gizmo id: %d' % id(self))
        
x = Gizmo()
Gizmo id: 140471873847248
In [4]:
y = Gizmo() * 10
Gizmo id: 140471874056880
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-4-0f524f080953> in <module>()
----> 1 y = Gizmo() * 10

TypeError: unsupported operand type(s) for *: 'Gizmo' and 'int'

这里表明,在尝试求积之前,会创建一个 Gizmo 对象,但是肯定不会创建 y,因为在求乘积时候抛出了异常

标示、相等性和别名

In [6]:
charles = {'name': 'Charles L. Dodgson', 'born': 1832}
lewis= charles
lewis is charles
Out[6]:
True
In [7]:
id(charles), id(lewis)
Out[7]:
(140471873419784, 140471873419784)
In [9]:
lewis['balance'] = 950
charles
Out[9]:
{'balance': 950, 'born': 1832, 'name': 'Charles L. Dodgson'}

这里 lewis 是 charles 的别名

In [11]:
alex = {'balance': 950, 'born': 1832, 'name': 'Charles L. Dodgson'}
alex == charles #比较内容
Out[11]:
True
In [12]:
alex is not charles # 比较对象标识
Out[12]:
True

对象 ID 的真正意义在不同的实现中有所不同。在 CPython 中, id() 返回对象的内存地址,但是在其它 Python 解释器中可能是别的值。关键是,ID 是唯一的数值标注,而且在生命周期中不会改变。

其实编程很少使用 id() 函数,标识最常使用 is 检查,而不是直接比较 ID

在 == 和 is 之间选择

== 运算符比较两个对象的值(对象中保存的数据),而 is 比较对象的标识

通常我们关注的是值,而不是标识,所以 Python 代码中一般 == 比 is 出现的频率要高

然而,在变量和单例值之间比较时,应该使用 is。目前,最常用 is 检查绑定变量的值是不是 None。下面是推荐的写法

x is None

否定的正确写法

x is not None

is 运算符比 == 速度快,因为它不能重载,所以 Python 不用寻找不调用特殊方法,而是直接比较两个整数 ID。而 a == b 是语法糖,等同于 a.__eq__(b)。继承自 object 的 __eq__ 方法比较两个对象的 ID,结果与 is 一样。但是多数内置类型使用更有意义的方式覆盖了 eq 方法,会考虑对象属性的值。相等性测试可能设计大量处理工作,例如,比较大型集合或嵌套层级深的结构时。

元组的相对不变性

元组与多数 Python 集合(列表,字典,集等)一样,保存的也是对象的引用。如果引用的元素是一成不变的,即元组本身不可变,元素依然可变。也就是说,元祖的不可变性其实是指 tuple 数据结构的物理内容(即保存的引用)不变,与引用的对象无关

In [14]:
t1 = (1, 2, [30, 40])
t2 = (1, 2, [30, 40])
t1 == t2
Out[14]:
True
In [15]:
id(t1[-1])
Out[15]:
140471874556104
In [16]:
t1[-1].append(99)
t1
Out[16]:
(1, 2, [30, 40, 99])
In [17]:
id(t1[-1])
Out[17]:
140471874556104
In [18]:
t1 == t2
Out[18]:
False

默认做浅复制

复制列表(或多数内置的可变集合)最简单的方式是使用内置的类型构造方法。例如:

In [19]:
l1 = [3, [55, 44], (7, 8, 9)]
l2 = list(l1)  #创建 l1 的副本
l2
Out[19]:
[3, [55, 44], (7, 8, 9)]
In [20]:
l2 == l1
Out[20]:
True
In [21]:
l2 is l1
Out[21]:
False

对于列表其它可变序列来说,还能使用简洁的 l2 = l1[:] 语句创建副本

然而,构造方法或 [:] 做的是浅复制(即复制了最外层容器,副本中的元素是原容器中元素的引用)。如果所有元素是不可变的,这样做没有没有任何问题,还能节省内存,但是如果有可变元素,可能会导致意想不到的后果

In [27]:
l1 = [3, [66, 55, 44], (7, 8, 9)]
l2 = list(l1)
l1.append(100) #对 l2 没有影响
l1[1].remove(55) # l1 和 l2 指向同一个引用,l2 也会有影响
print(l1)
print(l2)
[3, [66, 44], (7, 8, 9), 100]
[3, [66, 44], (7, 8, 9)]
In [28]:
l2[1] += [33, 22] #因为指向同一个引用,所以有影响
l2[2] += (10, 11) # 因为元组不可变,实际上是新建了个元组,l1 和 l2 的元组不是同一个对象了
print(l1)
print(l2)
[3, [66, 44, 33, 22], (7, 8, 9), 100]
[3, [66, 44, 33, 22], (7, 8, 9, 10, 11)]

为任意对象做深复制和浅复制

copy 模块提供的 deepcopy 和 copy 能为任意对象做深复制和浅复制。

为了演示 copy 和 deepcopy 的用法,下面定义了一个简单的类, Bus,这个类表示公交车,在途中乘客会上下车

In [34]:
class Bus:
    def __init__(self, passengers = None):
        if passengers is None:
            self.passengers = []
        else:
            self.passengers = list(passengers)
    
    def pick(self, name):
        self.passengers.append(name)
    
    def drop(self, name):
        self.passengers.remove(name)
In [35]:
import copy

bus1 = Bus(['Alice', 'Bill', 'Claire', 'David'])
bus2 = copy.copy(bus1)
bus3 = copy.deepcopy(bus1)
id(bus1), id(bus2), id(bus3)
Out[35]:
(140471873054704, 140471873054648, 140471873054760)
In [36]:
bus1.drop('Bill')
bus2.passengers
Out[36]:
['Alice', 'Claire', 'David']
In [38]:
id(bus1.passengers), id(bus2.passengers), id(bus3.passengers)
Out[38]:
(140471873472776, 140471873472776, 140471873051912)
In [39]:
bus3.passengers
Out[39]:
['Alice', 'Bill', 'Claire', 'David']

注意,一般来说,深复制不是简单的事,如果对象有循环引用,那么这个朴素的算法会进入无限循环,deepcopy 函数会记住已经复制的对象,因此能优雅地处理循环引用,如下所示

In [41]:
a = [10, 20]
b = [a, 30]
a.append(b)

a
Out[41]:
[10, 20, [[...], 30]]
In [42]:
from copy import deepcopy
c = deepcopy(a)
c
Out[42]:
[10, 20, [[...], 30]]

此外,深赋值有时可能太深了,例如,对象可能会引用不该复制的外部资源或单例值。我们可以实现特殊方法的 __copy__()__deepcopy__(),控制 copy 和 deepcopy 的行为

函数的参数作为引用时

Python 唯一支持的的参数传递方式是共享传参,也就是函数的各个形式的参数获得实参中各个引用的副本,也就是说,函数内部的形参是实参的别名。

这种方案的结果是,如果传入可变对象,可能会受到一些影响

In [43]:
def f(a, b):
    a += b
    return a

x = 1
y = 2 
f(x, y)
Out[43]:
3
In [44]:
a = [1, 2]
b = [3, 4]
f(a, b)
Out[44]:
[1, 2, 3, 4]
In [45]:
a, b
Out[45]:
([1, 2, 3, 4], [3, 4])
In [46]:
t = (10, 20)
u = (30, 40)
f(t, u)
Out[46]:
(10, 20, 30, 40)
In [47]:
t, u
Out[47]:
((10, 20), (30, 40))

不要使用可变类型作为参数的默认值

可选参数可以有默认值,这是一个很棒的特性,然而,我们应该避免使用可变的对象作为参数的默认值。

我们拿上面的公交车例子来说,如果我们的 __init__ 中 passengers 的默认参数不是 None,而是 [ ],会发生什么问题

In [67]:
class HauntedBus:
    '''一个幽灵乘客的公交车'''
    def __init__(self, passengers = []):
        self.passengers = passengers
    
    def pick(self, name):
        self.passengers.append(name)
    
    def drop(self, name):
        self.passengers.remove(name)
In [68]:
bus1 = HauntedBus(['Alice', 'Bill'])
bus1.passengers
Out[68]:
['Alice', 'Bill']
In [69]:
bus1.pick('Charlie')
bus1.drop('Alice')
bus1.passengers
Out[69]:
['Bill', 'Charlie']
In [70]:
bus2 = HauntedBus()
bus2.pick('Carrie') # 此时默认列表不为空
bus2.passengers
Out[70]:
['Carrie']
In [71]:
bus3 = HauntedBus()
bus3.passengers # 看到默认列表不为空
Out[71]:
['Carrie']
In [72]:
bus3.pick('Dave')
bus2.passengers
Out[72]:
['Carrie', 'Dave']
In [73]:
bus2.passengers is bus3.passengers
Out[73]:
True
In [74]:
bus1.passengers
Out[74]:
['Bill', 'Charlie']

问题在于,没有指定出使乘客的 HauntedBus 实例会共享同一个乘客列表,这种问题很难发现。如果传入乘客,会按照预期运作,如果不传,奇怪的事就发生了,这是因为 self.passengers 变成了 passengers 参数默认值的别名。出现这个问题的根源是,默认值在定义函数时计算(通常是在加载模块时),因此默认值变成了函数对象的属性。因此,如果默认值是可变对象,而且修改了它的值,那么后续函数调用都会收到影响

我们可以审查 HauntedBus.__init__ 对象,看它的 __defaults__ 属性中的那些幽灵乘客

In [78]:
dir(HauntedBus.__init__)
Out[78]:
['__annotations__',
 '__call__',
 '__class__',
 '__closure__',
 '__code__',
 '__defaults__',
 '__delattr__',
 '__dict__',
 '__dir__',
 '__doc__',
 '__eq__',
 '__format__',
 '__ge__',
 '__get__',
 '__getattribute__',
 '__globals__',
 '__gt__',
 '__hash__',
 '__init__',
 '__kwdefaults__',
 '__le__',
 '__lt__',
 '__module__',
 '__name__',
 '__ne__',
 '__new__',
 '__qualname__',
 '__reduce__',
 '__reduce_ex__',
 '__repr__',
 '__setattr__',
 '__sizeof__',
 '__str__',
 '__subclasshook__']
In [79]:
HauntedBus.__init__.__defaults__
Out[79]:
(['Carrie', 'Dave'],)

最后,我们可以验证 bus2.passengers 是一个别名,它绑定到 HauntedBus.__init__.__defaults__ 属性的第一个元素上

In [80]:
HauntedBus.__init__.__defaults__[0] is bus2.passengers
Out[80]:
True

可变默认值导致的这个问题说明了为什么通常使用 None 作为接收可变参数的默认值。在一开始公交车例子中,检查参数值是不是 None,如果是就把新的空列表赋值给 self.passengers

自己发现,下面的这种方法也可以简洁的完成此功能:

In [1]:
def __init__(self, passengers = []):
    self.passengers = list(passengers) #这样列表即使是空的,也会把它的一个副本给 self.passengers

防御可变参数

如果定义的函数接收可变参数,应该谨慎考虑调用方是否期望修改传入的参数,我们来看一个公交车的行为:

In [3]:
basketball_team = ['Sue', 'Tina', 'Maya', 'Diana', 'Pat']
bus = TwilightBus(basketball_team)
bus.drop('Tina')
bus.drop('Pat')
basketball_team
Out[3]:
['Sue', 'Maya', 'Diana']

看到从车上下去人之后,它的名字就从篮球队中消失了,这违反了设计接口的最佳实践,即 ”最少惊讶原则“,下面是 Twilight 的实现:

In [5]:
class TwilightBus:
    '''让乘客销声匿迹的公交车'''
    def __init__(self, passengers = None):
        if passengers is None:
            self.passengers = []
        else:
            self.passengers = passengers
    
    def pick(self, name):
        self.passengers.append(name)
    
    def drop(self, name):
        self.passengers.remove(name)

这里的问题是直接为传进来的列表创建了别名。正常应该像第一个小车例子那么做

In [6]:
def __init__(self, passengers = None):
    if passengers is None:
        self.passengers = []
    else:
        self.passengers = list(passengers)

除非真的想修改传入的对象,否则在勒种直接把参数赋值给实例变量一定要三思,因为这样会创建别名。如果不确定,就创建副本,会少些麻烦

del 和垃圾回收

对象绝不会自行销毁,然而,无法得到对象时,可能会被当做垃圾回收 -- Python 语言参考手册

del 删除名称,而不是对象,del 命令可能会导致对象被当做垃圾回收,但是仅当删除的变量保存的是对象的最后一个引用,或者无法得到对象时。重新绑定也可能会导致对象的引用数量归零,从而导致对象销毁

有个 __del__ 特殊方法,但是它不会销毁实例,不应该在代码中调用。即销毁实例时,Python 解释器会调用此方法,给实例最后的机会释放外部资源,但自己很少需要这个方法

在 CPython 中,垃圾回收使用的主要算法是引用计数,实际上,每个对象都会统计有多少引用指向自己。当引用计数归零时,对象立即销毁:CPython 会在对象上调用 __del__ 方法(如果定义了),然后释放分配给对象的内存。CPython 2.0 增加了 分代垃圾回收算法,用于检测引用循环中涉及的对象组 -- 如果一组对象之间全是相互引用,即使再出色的引用方式也会导致数组中的对象不可获取,Python 的其它实现有更复杂的垃圾回收程序,而且不依赖引用计数,这意味着,对象引用计数为 0 时可能不会立即调用 __del__ 方法。

为了演示对象生命结束时的情形,下面使用了 weakre.finalize 注册一个回调函数,在对象销毁时候调用。下面的例子演示了没有指向对象的引用时,监视对象生命结束时的情形:

In [12]:
import weakref
s1 = {1, 2, 3}
s2 = s1
def bye():
    print('Gone with the wind...')
    
ender = weakref.finalize(s1, bye)
ender.alive
Gone with the wind...
Out[12]:
True
In [13]:
del s1
ender.alive
Out[13]:
True
In [14]:
s2 = 'spam'
Gone with the wind...
In [15]:
ender.alive
Out[15]:
False

弱引用

正是因为有引用,对象才会在内存中存在。当对象引用数量归零后,垃圾回收程序会把对象销毁。但是,有时需要引用对象,而不让对象存在的时间超过所需的时间。这经常用在缓存中

弱引用不会增加对象的引用数量。引用的目标对象称为所指对象(referent)。因此我们说,弱引用不会妨碍所指对象被当做垃圾回收。弱引用在缓存应用中很有用,因为我们不想仅因为被缓存引用者而始终保存缓存对象

下面例子展示了如何使用 weakref.ref 实例获取所指对象。如果对象存在,调用弱引用可以获取对象,否则返回 None

下面的这段控制台程序,Python 会自动把 _ 变量绑定到结果不为 None 的表达式结果上。这对演示的行为有影响,不过却凸显了一个实际的问题:微观管理内存时,往往会得到意外的结果,因为不明显的隐式赋值会为对象创建新的引用。下面的 _ 变量是一例。调用跟踪对象也常导致意料之外的引用

弱引用是可调用对象,返回的是被引用的对象;如果所指对象不存在了,返回 None

In [30]:
'''
>>> import weakref
>>> a_set = {0, 1}
>>> wref = weakref.ref(a_set) # 创建弱引用对象
>>> wref
<weakref at 0x7fa060e36050; to 'set' at 0x7fa060e40d00>
>>> wref() # 调用 wref() 返回的是被引用的对象{0, 1},因为这是控制台会话,索引{0, 1} 会被绑定给 _ 变量
set([0, 1])
>>> a_set = {2, 3, 4} # a_set 不再指向 {0, 1} 集合,因此集合引用数量减少了。但是 _ 变量仍然指代它
>>> wref() # 调用 wref() 仍然返回 {0, 1}
set([0, 1])
>>> wref() is None  # 计算这个表达式时, {0, 1} 存在,因此 wref() 不是 None。但是随后 _ 绑定到结果值为 False,现在 {0, 1} 没有强引用了
False
>>> wref() is None # 因为 {0, 1} 对象不存在了,所以 wref() 返回 None
True
'''
print("上面程序在控制台才会有这个效果")
上面程序在控制台才会有这个效果

weakref.ref 类其实是低层接口,供高级用途使用,多数程序最好使用weakref 集合和 finalize。也就是说,应该使用 WeakKeyDictionary, WeakValueDictionary, WeakSet 和 finalize。不要自己创建并处理 weakref.ref 实例。我们上面例子是希望通过使用 weakref.ref 来褪去它的神秘色彩。但实际上,多数 Python 程序使用 weakref 集合

WeakValueDictionary 简介

WeakValueDictionary 类实现的是一种可变映射,里面的值是对象的弱引用。被引用的对象在程序中的其它地方被当做垃圾回收,对应的键会自动从 WeakValueDictionary 中删除。因此,WeakValueDictionary 经常用于缓存。

下面实现了一个简单的类,表示各种奶酪。

In [56]:
class Cheese:
    def __init__(self, kind):
        self.kind = kind
    def __repr__(self):
        return 'Cheese(%r)' % self.kind

然后敲以下代码,思考为什么 Parmesan 奶酪比其它的保存的时间长

In [57]:
import weakref
stock = weakref.WeakValueDictionary()
catalog = [Cheese('Red Leicester'), Cheese('Tilsit'),
           Cheese('Brie'), Cheese('Parmesan')]

for cheese in catalog:
    stock[cheese.kind] = cheese
    
sorted(stock.keys())
Out[57]:
['Brie', 'Parmesan', 'Red Leicester', 'Tilsit']
In [58]:
del catalog
sorted(stock.keys())
Out[58]:
['Parmesan']
In [59]:
del cheese
sorted(stock.keys())
Out[59]:
[]

这是因为临时变量 cheese 引用了对象,这可能会导致该变量的存在时间比预期的长。通常,这对局部变量来说不是问题,因为它们在函数返回时会被销毁。但是在上面例子中,for 循环中的 cheese 是全局变量,除非显示删除,否则不会消失

与 WeakValueDictionary 对应的是 WeakKeyDictionary,后者的键是弱引用。后者的一般的应用如下:可以为应用中其他部分拥有的对象附加数据,这样就无需为对象添加属性。这对覆盖属性访问权限很有用

weakref 模块中还提供了 WeakSet 类,按照文档说明,这个类的作用很简单:保存元素弱引用的集合类。元素没有强引用时,集合会把它删除。如果一个类需要知道所有实例,一种好的方案是创建一个 WeakSet 类型的类属性,保存实例的引用。如果使用常规的 set,实例永远不会被垃圾回收,因为类中有实例的强引用,而类存在的时间与 Python 进程一样长,除非显式删除类

弱引用局限

不是每个类都能做弱引用目标(或称所指对象)。基本的 list 和 dict 实例不能作为所指对象,但是它们的子类可以轻松解决这个问题

class MyList(list):
    '''list 的子类,实例可以作为弱引用的目标'''

a_list = MyList(range(10)

#a_list 可以作为弱引用目标
wref_to_a_list = weakref.ref(a_list)

set 实例可以作为所指对象,因此前面这段采用 set 实例:

>>> import weakref
>>> a_set = {0, 1}
>>> wref = weakref.ref(a_set) # 创建弱引用对象
>>> wref
...

其次用户定义的类型也可以,这也就解释了为什么上面使用 Cheese 类。但是,int 和 tuple 实例不能作为弱引用的目标,它们的子类也不行。

这些局限基本是 CPython 的实现细节,在其它 Python 解释器可能不一样。这些局限内部优化导致的结果

Python 对不可变类型施加的把戏

对于元组 t 来说, t[:] 不创建副本,而是返回同一个对象的引用。此外,tuple(t) 获得的也是同一个元组的引用。

In [62]:
t1 = (1, 2, 3)
t2 = tuple(t1)
t2 is t1
Out[62]:
True
In [63]:
t3 = t1[:]
t3 is t1
Out[63]:
True

str, bytes 和 frozenset 实例也有这些行为,注意,frozenset 实例不是序列,因此不能使用 fs[:](fs 是一个 frozenset 实例)。但是 fs.copy() 具有相同的效果:它会欺骗你,返回同一个对象的引用,而不是创建一个副本

In [64]:
t1 = (1, 2, 3)
t3 = (1, 2, 3)
t3 is t1
Out[64]:
False
In [65]:
s1 = 'ABC'
s2 = 'ABC'
s2 is s1 # 共享字面量
Out[65]:
True

共享字符串字面量是一种优化措施,称为 驻留(interning)。CPython 还会在小的整数上使用这个优化措施,防止重复创建“热门”数字,如 0,-1 和 42.注意,CPython 不会驻留所有字符串和整数,驻留的条件是实现细节,而且没有文档说明,所以千万不要依赖字符串或整数驻留,当你比较字符串或整数是否相等的时候,一定要使用 == 而不是 is。

这些讨论的把戏,包括 frozenset.copy 行为,是 “善意的谎言” 能节省内存,提供解释器的速度,不会为你带来麻烦,它对你唯一的用处应该是用来和其它程序员打赌= =