《Fluent Python》学习笔记:第 8 章 对象引用、可变性和垃圾回收

本文主要是 Fluent Python 第 8 章的学习笔记。这部分主要是介绍了变量、引用、对象、深拷贝、浅拷贝、垃圾回收等。本章虽然枯燥,但是非常有用。

《Fluent Python》学习笔记:第 8 章 对象引用、可变性和垃圾回收

    • 8.1 变量不是盒子
    • 8.2 标识、相等性和别名
    • 8.3 默认浅拷贝
    • 8.5 del 和垃圾回收
    • 8.6 弱引用
    • 巨人的肩膀

8.1 变量不是盒子

在 Python 中变量是标签(label),不是盒子(box)。Python 中的变量是引用式变量,类似于 Java 中的引用式变量,因此把 Python 中的变量理解为附加在对象上的标签。

通过一个例子感受一下:

a = [1, 2, 3]  # a 标签指向了 [1, 2, 3] 这个列表
b = a  # 把 b 标签也指向了 [1, 2, 3] 列表
a.append(4)
print(b)
[1, 2, 3, 4]

赋值语句的右边先执行,对象在赋值之前就创建了。因此,对于引用式变量,说把变量分配给对象更合理。看下面这个例子:

class Gizmo(object):

    def __init__(self):
        print('Gizmo id: %d' % id(self))


x = Gizmo()
y = Gizmo() * 10  # 可以看得到 Gizmo() 会创建一个新的 Gizmo 实例,求积失败,所以 y 变量不会创建
Gizmo id: 2236148132296
Gizmo id: 2236148132168



---------------------------------------------------------------------------

TypeError                                 Traceback (most recent call last)

 in 
      6
      7 x = Gizmo()
----> 8 y = Gizmo() * 10  # 可以看得到 Gizmo() 会创建一个新的 Gizmo 实例,求积失败,所以 y 变量不会创建


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

因此,为了理解 Python 中的赋值语句,应该始终先读右边。对象在右边创建或获取,在此之后左边的变量才会绑定到对象上,这就像为对象贴上标签。

8.2 标识、相等性和别名

每个对象(object)都有标识(identity)、类型(type)和值(value)。

  • 标识(identity):对象一旦创建,标识就不会改变。对象的标识具有唯一性,并且在对象的生命周期中不会改变。可以把标识理解为对象在内存中的地址。CPython 中,可以用 id() 返回对象的内存地址,用 is 比较两个对象的标识,判断它们是否是同一个对象。
  • 类型:可以用 type() 查看对象的类型。
  • 值。

别名(alias):一个对象可以有多个标签,每个标签就是一个别名。

is== 比较:

is:比较对象的标识。
==:比较对象的值。通常我们比较关注值,而不是标识,所以在 Python 代码中 == 出现的频率比 is 高。
is 运算符比 == 速度快,因为它不能重载,所以 Python 不用寻找并调用特殊方法,而是直接比较两个整数 ID。而 a == b 是语法糖,等同于 a.__eq__(b) ,相等性测试可能涉及大量处理工作。
注意:在变量和单例值之间比较时,应该使用 is。目前,最常使用 is 检查变量绑定的值是不是 None。下面是推荐的写法:

x is None

# 否定的正确写法
x is not None
# 比较 is 和 ==
a = [1, 2, 3]
b = a  # a, b 都是 [1, 2, 3] 对象的别名
c = [1, 2, 3]  # 注意,这里新创建了一个 [1, 2, 3]列表对象
print(f'id(a)={id(a)}, id(b)={id(b)}, id(c)={id(c)}')
print(f'a is b: {a is b}; a == b: {a == b}')
print(f'a is c: {a is c}; a == c: {a == c}')
id(a)=2236148131656, id(b)=2236148131656, id(c)=2236156692488
a is b: True; a == b: True
a is c: False; a == c: True

重新理解元组的不可变:

元组和 Python 大多数集合类型(列表、字典、集合等)一样,都是保存的对象的引用(reference)。如果引用的对象是可变的,即便元组本身不可变,引用的对象依然可变。换句话说就是,元组的不可变性实际上是指元组数据结构的物理内容(即保存的引用)不可变,与引用的对象无关。
看下面的例子:

t1 = (1, 2, [30, 40])
t2 = (1, 2, [30, 40])
print(f'id(t1)={id(t1)}; id(t2)={id(t2)}, t1 is t2: {t1 is t2}; t1 == t2: {t1 == t2}')
print(id(t1[-1]))
t1[-1].append(99)
print(t1)
print(id(t1[-1]))
print(t1 == t2)  # t1 和 t2 值不同了
id(t1)=2236146528568; id(t2)=2236148887864, t1 is t2: False; t1 == t2: True
2236150622728
(1, 2, [30, 40, 99])
2236150622728
False

上述例子说明,元组的值会随着引用的可变对象的变化而变。元组中不可变的是元素的标识。所以有些元组是不可散列的。

8.3 默认浅拷贝

Python 中使用构造方法和切片默认做浅拷贝(shallow copy),即复制了最外层容器,副本中的元素是源容器中元素的引用。如果所有元素都是不可变的,这么做没有问题,还能节省内存。但是如果有可变元素,可能就会导致意想不到的问题。

深拷贝(deep copy):即副本不共享内部对象的引用。

copy 模块中的 deepcopy 和 copy 函数能够为任意对象做深拷贝或者浅拷贝。
关于深浅拷贝,看个例子:

# 校车乘客在途中上车和下车
import copy

class Bus(object):

    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)


bus1 = Bus(['Alice', 'Bill', 'Claire', 'David'])
bus2 = copy.copy(bus1)  # 浅拷贝
bus3 = copy.deepcopy(bus1)  # 深拷贝
print(f' id(bus1)={id(bus1)} \n id(bus2)={id(bus2)} \n id(bus3)={id(bus3)}')  # 深浅拷贝都和原对象的 ID 不同
bus1.drop('Bill')  # bus2 也会受影响
print(f' bus1.passengers={bus1.passengers} \n bus2.passengers={bus2.passengers} \n bus3.passengers={bus3.passengers}')
# bus1 和 bus2 共用同一个passengers 列表对象
print(f''' id(bus1.passengers)={id(bus1.passengers)} \n id(bus2.passengers)={id(bus2.passengers)} \n id(bus3.passengers)={id(bus3.passengers)}''')
 id(bus1)=2236156482888
 id(bus2)=2236144713544
 id(bus3)=2236156999176
 bus1.passengers=['Alice', 'Claire', 'David']
 bus2.passengers=['Alice', 'Claire', 'David']
 bus3.passengers=['Alice', 'Bill', 'Claire', 'David']
 id(bus1.passengers)=2236144711688
 id(bus2.passengers)=2236144711688
 id(bus3.passengers)=2236156482632

注意:一般来说,深拷贝不是件简单的事。如果有对象有循环引用(cyclic reference),那么这个朴素的算法会进入无限循环。 deepcopy 函数会记住已经复制的对象,因此能够优雅的处理循环引用。
如下面这个例子:

# 循环引用:b 引用 a,然后追加到 a 中;deepcopy 会想办法复制 a
from copy import deepcopy

a = [10, 20]
b = [a, 30]
a.append(b)
print(a)
c = deepcopy(a)
print(c)
[10, 20, [[...], 30]]
[10, 20, [[...], 30]]

深拷贝有时可能太深,对象可能会引用不该赋值的外部资源或单例值。我们可以实现 __copy__()__deepcopy__() 特殊方法,控制 copy 和 deepcopy 的行为。具体参考 copy 模块文档。

Python 唯一支持的参数传递模式是共享传参(call by sharing)。Java 中引用类型是传引用,基本类型是按值传参。
共享传参值函数的各个形参获得实参中各个引用的副本。也就是说,函数内部的形参是实参的别名。

这个方案的结果是:函数可能会修改作为参数传入的可变对象,但是无法修改这些对象的标识(即不能把一个对象替换成另一个对象)。下面这个例子展示了把数字、列表、元组传入函数,实际传入的实参会以不同的方式受到影响:

# 函数可能会修改接收到的人和可变对象
def f(a, b):
    a += b
    return a

x, y = 1, 2
print(f(x, y))
print(x, y)  # 数字 x 不变
a, b = [1, 2], [3, 4]
print(f(a, b))
print(a, b)  # 列表 a 变了
t, u = (10, 20), (30, 40)
print(f(t, u))
print(t, u)  # 元组 t 不变
3
1 2
[1, 2, 3, 4]
[1, 2, 3, 4] [3, 4]
(10, 20, 30, 40)
(10, 20) (30, 40)

不要使用可变类型作为参数的默认值。
以下例子说明可变默认值的危险:

# 一个简单的类,说明可变默认值的危险

class HauntedBus(object):
    """备受幽灵乘客折磨的校车"""
    def __init__(self, passengers=[]):  # 没有传入passengers参数,使用默认绑定的列表对象,一开始是空列表
        self.passengers = passengers

    def pick(self, name):
        self.passengers.append(name)

    def drop(self, name):
        self.passengers.remove(name)


bus1 = HauntedBus(['Alice', 'Bill'])
print(bus1.passengers)
bus1.pick('Charlie')
bus1.drop('Alice')
print(bus1.passengers)  # 到这里都一切正常
bus2 = HauntedBus()  # 一开始bus2是空的,因此会把默认的空列表赋值给self.passengers
bus2.pick('Carrie')
print(bus2.passengers)
bus3 = HauntedBus()  # bus3一开始也是空的,因此还是赋值默认的列表
print(bus3.passengers)  # 但是默认列表不为空!
bus3.pick('Dave')
print(bus2.passengers)  # 登上bus3 的Dave也出现在了bus2
print(bus2.passengers is bus3.passengers)  # bus2.passengers 和 bus3.passengers 指向同一个列表
print(bus1.passengers)  # 但是bus1.passengers是不同的列表

['Alice', 'Bill']
['Bill', 'Charlie']
['Carrie']
['Carrie']
['Carrie', 'Dave']
True
['Bill', 'Charlie']

这里的问题在于,没有指定初始乘客的 HauntedBus 实例会共享同一个乘客列表。这种问题很难发现。出现这个问题的根源是,默认值在定义函数时计算(通常在加载模块时),因此默认值变成了函数对象的属性。因此,如果默认值是可变对象,而且修改了它的值,那么后续的函数调用都会受到影响。

我们可以审查 HauntedBus.__init__ 对象,看看它的 __defaults__ 属性有哪些幽灵学生。

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

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

print(HauntedBus.__init__.__defaults__[0] is bus2.passengers)
True

因此通常使用 None 作为接收可变值参数的默认值。因此我们需要在 __init__ 方法做检查,如果 passengers 是 None,就把一个新的空列表赋值给 self.passengers,如果不是,正确的实现是把passengers的副本赋值给 self.passengers

下面这个 TwilightBus 实例与客户共享乘客列表,这会产生意外的结果,如下:

# 一个简单的类,说明接收可变参数的风险
class TwilightBus(object):
    """让乘客销声匿迹的校车"""
    def __init__(self, passengers=None):
        if passengers is None:
            self.passengers = []  # 这里谨慎处理,当passengers为None时,创建一个新的空列表
        else:
            self.passengers = passengers  # self.passengers 变成了passengers的别名,即实参的别名

    def pick(self, name):
        self.passengers.append(name)  # 会修改传入的 passengers 列表

    def drop(self, name):
        self.passengers.remove(name)  # 会修改传入的 passengers 列表


basketball_team = ['Sue', 'Tina', 'Maya', 'Diana', 'Pat']
bus = TwilightBus(basketball_team)
bus.drop('Tina')
bus.drop('Pat')
print(basketball_team)  # basketball_team 被改变了
['Sue', 'Maya', 'Diana']

这里的 TwilightBus 违反了设计接口的最佳实践,即“最少惊讶原则”。学生下车后,她的名字就从篮球队的名单消失了,这不是我们想要的。
这里的问题是,校车为传给构造方法的列表创建了别名。
正确做法是,校车自己维护乘客列表。因此我们只需要把参数值的副本赋值给 self.passengers 就可以了。

def __init__(self, passengers=None):
    if passengers is None:
        self.passengers = []
    else:
        self.passengers = list(passengers)  # 创建passengers列表的副本,如果不是列表,就把它转换成列表

这种处理方式更加灵活,passengers 可以是任何可迭代对象。

建议:除非这个方法确实想修改通过参数传入的对象,否则在类中直接把参数赋值给实例变量之前一定要三思,因为这样会为参数对象创建别名。如果不确定,那就创建副本。减少客户的麻烦。

8.5 del 和垃圾回收

del 语句时删除名称,而不是对象。del 命令可能会导致对象被当做垃圾回收,但是仅当删除的变量保存的是对象最后一个引用,或者无法得到对象时。

重新绑定也可能会导致对象的引用数量归零,导致对象被销毁。

在 CPython 中垃圾回收使用的主要算法是引用计数。每个对象都会统计有多少个引用指向自己。当引用计数归 0 时,对象立即被销毁:CPython 会在对象上调用 __del__方法(如果定义了),然后释放分配给对象的内存。CPython 2.0 增加了分代垃圾回收算法,用于检测引用循环中涉及的对象组——如果一组对象之间全是相互引用,即使再出色的引用方式也会导致组中的对象不可获取。

8.6 弱引用

弱引用(weak references):弱引用不会增加对象的引用数量。弱引用的目标对象称为所指对象(referent)。所以弱引用不会妨碍所指对象被当做垃圾回收。

弱引用在缓存应用中非常有用。因为我们不想仅因为被缓存引用着而始终保存缓存对象。

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

弱引用的局限:不是每个 Python 对象都能作为弱引用的目标。基本的 list 和 dict 实例不能作为所指对象,但它们的子类可以轻松解决这个问题。
set 实例和用户定义的类型可以作为弱引用的目标。但是 int 和 tuple 实例不能作为弱引用的目标,甚至它们的子类也不行。

巨人的肩膀

  1. 《Fluent Python》
  2. 《流畅的 Python》

你可能感兴趣的:(《Fluent Python》学习笔记:第 8 章 对象引用、可变性和垃圾回收)