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

《流畅的Python》笔记。

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

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

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

1
2
3
4
5
6
7
8
9
10
11
# 代码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的别名:

1
2
3
4
5
6
7
# 代码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,推荐的写法是:

1
2
3
# 代码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==快。

1.2 元组的相对不可变性

元组和大多数Python集合一样,保存的是对象的引用。元组的不可变性其实是指tuple数据结构的物理内容(即保存的引用)不可变,与引用的对象无关。如果引用的对象可变,即便元组本身不可变,元素依然可变,不变的是元素的标识

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 代码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 默认做浅复制

对列表和其他可变序列来说,我们可以使用构造方法或[:]来创建副本。然而,这两种方法做的都是浅复制,它们只复制了最外层的容器,副本中的元素是源容器中元素的引用。如果所有元素都是不可变的,那这样做没问题,还能节省内存;但如果其中有可变元素,这么做就可能出问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 代码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函数能为任意对象做深复制和浅复制。

1
2
3
4
5
6
7
8
9
10
11
12
13
# 代码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]]

在做深复制时,如果对象之间有循环引用,朴素的深复制算法(换句话说就是你自己写的深复制算法:laughing:)很可能会陷入无限循环,然后报错。deepcopy会记住已经复制的对象,而不会进入无限循环:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 代码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),它指函数的形参获得实参中各个引用的副本,即形参是实参的别名。这种方案的结果就是,函数可能会修改作为参数传入的可变对象,但无法修改这些对象的标识(不能把一个对象替换成另一个对象):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 代码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小节中有所提及。现在我们来看下面这个例子:

首先定义一个类:

1
2
3
4
5
6
7
8
9
10
# 代码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)

下面是这个类的行为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 代码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__属性,它存储了参数的默认值:

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

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

1
2
3
4
5
6
7
8
# 代码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变成了用户传入的参数的别名),则用户传入的参数在运行过程中可能会被修改,而这并不一定是用户想要的,这便违反了“最少惊讶原则”(居然还真有这么个原则:joy_cat:)

4. del和垃圾回收

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

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

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

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

我们可以使用weakref.finalize来演示对象被销毁时的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 代码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的表达式的结果绑定到变量_(下划线)上。这也说明了一个问题:微观管理内存时,隐式赋值会为对象创建新引用,而这有可能会导致一些意外结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 代码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中删除。因此,它经常用于缓存。(查看缓存中变量是否依然存在?给框架用?)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 代码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实例不能作为弱引用的所指对象,但它们的子类则可以;

    1
    2
    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的实例还有共享字符串字面量的行为:

    1
    2
    3
    4
    >>> s1 = "ABC"
    >>> s2 = "ABC"
    >>> s1 is s2
    True

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

7. 总结

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

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

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

某些情况下,可能需要保存对象的引用,但不留存对象本身,比如记录某个类的所有实例,这可以用弱引用解决。

VPointer wechat
欢迎大家关注我的微信公众号"代码港"~~
您的慷慨将鼓励我继续创作~~