流畅的 Python 第二版(GPT 重译)(三)(3)https://developer.aliyun.com/article/1484432
示例 6-7. 示例 6-6 的输出
l1: [3, [66, 44], (7, 8, 9), 100] l2: [3, [66, 44], (7, 8, 9)] l1: [3, [66, 44, 33, 22], (7, 8, 9), 100] l2: [3, [66, 44, 33, 22], (7, 8, 9, 10, 11)]
图 6-4. l1
和l2
的最终状态:它们仍然共享对同一列表对象的引用,现在包含[66, 44, 33, 22]
,但操作l2[2] += (10, 11)
创建了一个新的元组,内容为(7, 8, 9, 10, 11)
,与l1[2]
引用的元组(7, 8, 9)
无关。 (图示由 Online Python Tutor 生成。)
现在应该清楚了,浅复制很容易实现,但可能并不是你想要的。如何进行深复制是我们下一个话题。
任意对象的深复制和浅复制
使用浅复制并不总是问题,但有时你需要进行深复制(即不共享嵌入对象引用的副本)。copy
模块提供了deepcopy
和copy
函数,用于返回任意对象的深复制和浅复制。
为了说明copy()
和deepcopy()
的用法,示例 6-8 定义了一个简单的类Bus
,代表一辆载有乘客的校车,然后在路线上接送乘客。
示例 6-8. 公共汽车接送乘客
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)
现在,在交互式示例 6-9 中,我们将创建一个bus
对象(bus1
)和两个克隆体—一个浅复制(bus2
)和一个深复制(bus3
)—来观察当bus1
放下一个学生时会发生什么。
示例 6-9. 使用copy
和deepcopy
的效果
>>> import copy >>> bus1 = Bus(['Alice', 'Bill', 'Claire', 'David']) >>> bus2 = copy.copy(bus1) >>> bus3 = copy.deepcopy(bus1) >>> id(bus1), id(bus2), id(bus3) (4301498296, 4301499416, 4301499752) # ① >>> bus1.drop('Bill') >>> bus2.passengers ['Alice', 'Claire', 'David'] # ② >>> id(bus1.passengers), id(bus2.passengers), id(bus3.passengers) (4302658568, 4302658568, 4302657800) # ③ >>> bus3.passengers ['Alice', 'Bill', 'Claire', 'David'] # ④
①
使用copy
和deepcopy
,我们创建了三个不同的Bus
实例。
②
在bus1
删除'Bill'
后,bus2
也缺少了他。
③
检查passengers
属性显示bus1
和bus2
共享相同的列表对象,因为bus2
是bus1
的浅拷贝。
④
bus3
是bus1
的深拷贝,因此其passengers
属性引用另一个列表。
请注意,在一般情况下,制作深拷贝并不是一件简单的事情。对象可能具有导致天真算法陷入无限循环的循环引用。deepcopy
函数记住已复制的对象,以优雅地处理循环引用。这在示例 6-10 中有演示。
示例 6-10。循环引用:b
引用a
,然后附加到a
;deepcopy
仍然成功复制a
>>> a = [10, 20] >>> b = [a, 30] >>> a.append(b) >>> a [10, 20, [[...], 30]] >>> from copy import deepcopy >>> c = deepcopy(a) >>> c [10, 20, [[...], 30]]
此外,在某些情况下,深拷贝可能太深。例如,对象可能引用不应复制的外部资源或单例。您可以通过实现__copy__()
和__deepcopy__()
特殊方法来控制copy
和deepcopy
的行为,如copy
模块文档中所述。
通过别名共享对象也解释了 Python 中参数传递的工作原理,以及在参数默认值中使用可变类型的问题。接下来将介绍这些问题。
函数参数作为引用
Python 中的唯一参数传递模式是共享调用。这是大多数面向对象语言使用的模式,包括 JavaScript、Ruby 和 Java(这适用于 Java 引用类型;基本类型使用按值调用)。共享调用意味着函数的每个形式参数都会得到每个参数中引用的副本。换句话说,函数内部的参数成为实际参数的别名。
这种方案的结果是函数可以更改作为参数传递的任何可变对象,但它不能更改这些对象的标识(即,它不能完全用另一个对象替换对象)。示例 6-11 展示了一个简单函数在其中一个参数上使用+=
的情况。当我们将数字、列表和元组传递给函数时,传递的实际参数会以不同的方式受到影响。
示例 6-11。一个函数可以更改它接收到的任何可变对象
>>> def f(a, b): ... a += b ... return a ... >>> x = 1 >>> y = 2 >>> f(x, y) 3 >>> x, y # ① (1, 2) >>> a = [1, 2] >>> b = [3, 4] >>> f(a, b) [1, 2, 3, 4] >>> a, b # ② ([1, 2, 3, 4], [3, 4]) >>> t = (10, 20) >>> u = (30, 40) >>> f(t, u) # ③ (10, 20, 30, 40) >>> t, u ((10, 20), (30, 40))
①
数字x
保持不变。
②
列表a
已更改。
③
元组t
保持不变。
与函数参数相关的另一个问题是在默认情况下使用可变值,如下所述。
将可变类型用作参数默认值:不好的主意
具有默认值的可选参数是 Python 函数定义的一个很好的特性,允许我们的 API 在保持向后兼容的同时发展。但是,应避免将可变对象作为参数的默认值。
为了说明这一点,在示例 6-12 中,我们从示例 6-8 中获取Bus
类,并将其__init__
方法更改为创建HauntedBus
。在这里,我们试图聪明地避免在以前的__init__
中使用passengers=None
的默认值,而是使用passengers=[]
,从而避免了if
。这种“聪明”让我们陷入了麻烦。
示例 6-12。一个简单的类来说明可变默认值的危险
class HauntedBus: """A bus model haunted by ghost passengers""" def __init__(self, passengers=[]): # ① self.passengers = passengers # ② def pick(self, name): self.passengers.append(name) # ③ def drop(self, name): self.passengers.remove(name)
①
当未传递passengers
参数时,此参数绑定到默认的空列表对象。
②
这个赋值使得self.passengers
成为passengers
的别名,而passengers
本身是默认列表的别名,当没有传递passengers
参数时。
③
当使用.remove()
和.append()
方法与self.passengers
一起使用时,实际上是在改变函数对象的属性的默认列表。
示例 6-13 展示了HauntedBus
的诡异行为。
示例 6-13. 被幽灵乘客缠身的公交车
>>> bus1 = HauntedBus(['Alice', 'Bill']) # ① >>> bus1.passengers ['Alice', 'Bill'] >>> bus1.pick('Charlie') >>> bus1.drop('Alice') >>> bus1.passengers # ② ['Bill', 'Charlie'] >>> bus2 = HauntedBus() # ③ >>> bus2.pick('Carrie') >>> bus2.passengers ['Carrie'] >>> bus3 = HauntedBus() # ④ >>> bus3.passengers # ⑤ ['Carrie'] >>> bus3.pick('Dave') >>> bus2.passengers # ⑥ ['Carrie', 'Dave'] >>> bus2.passengers is bus3.passengers # ⑦ True >>> bus1.passengers # ⑧ ['Bill', 'Charlie']
①
bus1
从一个有两名乘客的列表开始。
②
到目前为止,bus1
没有什么意外。
③
bus2
从空开始,所以默认的空列表被分配给了self.passengers
。
④
bus3
也是空的,再次分配了默认列表。
⑤
默认值不再是空的!
⑥
现在被bus3
选中的Dave
出现在了bus2
中。
⑦
问题在于bus2.passengers
和bus3.passengers
指向同一个列表。
⑧
但bus1.passengers
是一个独立的列表。
问题在于没有初始乘客列表的HauntedBus
实例最终共享同一个乘客列表。
这类 bug 可能很微妙。正如示例 6-13 所展示的,当使用乘客实例化HauntedBus
时,它的表现如预期。只有当HauntedBus
从空开始时才会发生奇怪的事情,因为这时self.passengers
变成了passengers
参数的默认值的别名。问题在于每个默认值在函数定义时被计算—即通常在模块加载时—并且默认值变成函数对象的属性。因此,如果默认值是一个可变对象,并且你对其进行更改,这种更改将影响到函数的每次未来调用。
在运行示例 6-13 中的代码后,你可以检查HauntedBus.__init__
对象,并看到幽灵学生缠绕在其__defaults__
属性中:
>>> dir(HauntedBus.__init__) # doctest: +ELLIPSIS ['__annotations__', '__call__', ..., '__defaults__', ...] >>> HauntedBus.__init__.__defaults__ (['Carrie', 'Dave'],)
最后,我们可以验证bus2.passengers
是绑定到HauntedBus.__init__.__defaults__
属性的第一个元素的别名:
>>> HauntedBus.__init__.__defaults__[0] is bus2.passengers True
可变默认值的问题解释了为什么None
通常被用作可能接收可变值的参数的默认值。在示例 6-8 中,__init__
检查passengers
参数是否为None
。如果是,self.passengers
绑定到一个新的空列表。如果passengers
不是None
,正确的实现将该参数的副本绑定到self.passengers
。下一节将解释为什么复制参数是一个好的实践。
使用可变参数进行防御性编程
当你编写一个接收可变参数的函数时,你应该仔细考虑调用者是否希望传递的参数被更改。
例如,如果你的函数接收一个dict
并在处理过程中需要修改它,那么这种副作用是否应该在函数外部可见?实际上这取决于上下文。这实际上是对函数编写者和调用者期望的一种调整。
本章中最后一个公交车示例展示了TwilightBus
如何通过与其客户共享乘客列表来打破期望。在研究实现之前,看看示例 6-14 中TwilightBus
类如何从类的客户的角度工作。
示例 6-14。当被TwilightBus
放下时,乘客消失了
>>> basketball_team = ['Sue', 'Tina', 'Maya', 'Diana', 'Pat'] # ① >>> bus = TwilightBus(basketball_team) # ② >>> bus.drop('Tina') # ③ >>> bus.drop('Pat') >>> basketball_team # ④ ['Sue', 'Maya', 'Diana']
①
basketball_team
拥有五个学生名字。
②
一个TwilightBus
装载着球队。
③
公交车放下一个学生,然后又一个。
④
被放下的乘客从篮球队中消失了!
TwilightBus
违反了“最少惊讶原则”,这是接口设计的最佳实践。³ 当公交车放下一个学生时,他们的名字从篮球队名单中被移除,这确实令人惊讶。
示例 6-15 是TwilightBus
的实现以及问题的解释。
示例 6-15。一个简单的类,展示了修改接收参数的危险性
class TwilightBus: """A bus model that makes passengers vanish""" 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) # ③
①
当passengers
为None
时,我们小心地创建一个新的空列表。
②
然而,这个赋值使self.passengers
成为passengers
的别名,而passengers
本身是传递给__init__
的实际参数的别名(即示例 6-14 中的basketball_team
)。
③
当使用.remove()
和.append()
方法与self.passengers
一起使用时,实际上是在修改作为构造函数参数传递的原始列表。
这里的问题是公交车别名化了传递给构造函数的列表。相反,它应该保留自己的乘客列表。修复方法很简单:在__init__
中,当提供passengers
参数时,应该用其副本初始化self.passengers
,就像我们在示例 6-8 中正确做的那样:
def __init__(self, passengers=None): if passengers is None: self.passengers = [] else: self.passengers = list(passengers) # ①
①
复制passengers
列表,或者如果它不是列表,则将其转换为list
。
现在我们对乘客列表的内部处理不会影响用于初始化公交车的参数。作为一个额外的好处,这个解决方案更加灵活:现在传递给passengers
参数的参数可以是一个tuple
或任何其他可迭代对象,比如一个set
甚至是数据库结果,因为list
构造函数接受任何可迭代对象。当我们创建自己的列表来管理时,我们确保它支持我们在.pick()
和.drop()
方法中使用的必要的.remove()
和.append()
操作。
提示
除非一个方法明确意图修改作为参数接收的对象,否则在类中简单地将其分配给实例变量会导致别名化参数对象。如果有疑问,请复制。你的客户会更加满意。当然,复制并非免费:在 CPU 和内存方面会有成本。然而,导致微妙错误的 API 通常比稍慢或使用更多资源的 API 更大的问题。
现在让我们谈谈 Python 语句中最被误解的之一:del
。
del 和 垃圾回收
对象永远不会被显式销毁;然而,当它们变得不可达时,它们可能被垃圾回收。
The Python Language Reference 中 “Data Model” 章节
del
的第一个奇怪之处在于它不是一个函数,而是一个语句。我们写 del x
而不是 del(x)
—尽管后者也可以工作,但只是因为在 Python 中表达式 x
和 (x)
通常表示相同的东西。
第二个令人惊讶的事实是 del
删除的是引用,而不是对象。Python 的垃圾收集器可能会间接地将对象从内存中丢弃,作为 del
的间接结果,如果被删除的变量是对象的最后一个引用。重新绑定一个变量也可能导致对象的引用数达到零,从而导致其销毁。
>>> a = [1, 2] # ① >>> b = a # ② >>> del a # ③ >>> b # ④ [1, 2] >>> b = [3] # ⑤
①
创建对象 [1, 2]
并将 a
绑定到它。
②
将 b
绑定到相同的 [1, 2]
对象。
③
删除引用 a
。
④
[1, 2]
没有受到影响,因为 b
仍然指向它。
⑤
将 b
重新绑定到不同的对象会移除对 [1, 2]
的最后一个引用。现在垃圾收集器可以丢弃该对象。
警告
有一个 __del__
特殊方法,但它不会导致实例的销毁,并且不应该被您的代码调用。__del__
在实例即将被销毁时由 Python 解释器调用,以便让它有机会释放外部资源。您很少需要在自己的代码中实现 __del__
,但一些 Python 程序员却花时间编写它却没有好的理由。正确使用 __del__
是相当棘手的。请参阅 The Python Language Reference 中 “Data Model” 章节的 __del__
特殊方法文档。
在 CPython 中,垃圾回收的主要算法是引用计数。基本上,每个对象都会记录指向它的引用计数。一旦该 refcount 达到零,对象立即被销毁:CPython 调用对象的 __del__
方法(如果定义了)然后释放为对象分配的内存。在 CPython 2.0 中,添加了一种分代垃圾回收算法,用于检测涉及引用循环的对象组—即使有指向它们的未解除引用,当所有相互引用都包含在组内时。Python 的其他实现具有更复杂的垃圾收集器,不依赖于引用计数,这意味着当没有更多引用指向对象时,__del__
方法可能不会立即被调用。请参阅 A. Jesse Jiryu Davis 的 “PyPy、垃圾回收和死锁” 讨论 __del__
的不当和适当使用。
为了演示对象生命周期的结束,示例 6-16 使用 weakref.finalize
注册一个回调函数,当对象被销毁时将被调用。
示例 6-16. 当没有更多引用指向对象时观察对象结束
>>> import weakref >>> s1 = {1, 2, 3} >>> s2 = s1 # ① >>> def bye(): # ② ... print('...like tears in the rain.') ... >>> ender = weakref.finalize(s1, bye) # ③ >>> ender.alive # ④ True >>> del s1 >>> ender.alive # ⑤ True >>> s2 = 'spam' # ⑥ ...like tears in the rain. >>> ender.alive False
①
s1
和 s2
是指向相同集合 {1, 2, 3}
的别名。
②
此函数不得是即将被销毁的对象的绑定方法或以其他方式保留对它的引用。
③
在s1
引用的对象上注册bye
回调。
④
在调用finalize
对象之前,.alive
属性为True
。
⑤
正如讨论的那样,del
并没有删除对象,只是删除了对它的s1
引用。
⑥
重新绑定最后一个引用s2
会使{1, 2, 3}
变得不可访问。它被销毁,bye
回调被调用,ender.alive
变为False
。
示例 6-16 的重点在于明确del
并不会删除对象,但对象可能在使用del
后变得不可访问而被删除。
你可能想知道为什么在示例 6-16 中{1, 2, 3}
对象被销毁。毕竟,s1
引用被传递给finalize
函数,该函数必须保持对它的引用以便监视对象并调用回调。这是因为finalize
持有对{1, 2, 3}
的弱引用。对对象的弱引用不会增加其引用计数。因此,弱引用不会阻止目标对象被垃圾回收。弱引用在缓存应用中很有用,因为你不希望缓存的对象因为被缓存引用而保持活动状态。
注意
弱引用是一个非常专业的主题。这就是为什么我选择在第二版中跳过它。相反,我在fluentpython.com上发布了“弱引用”。
Python 对不可变对象的戏法
注意
这个可选部分讨论了一些对 Python 的用户来说并不重要的细节,可能不适用于其他 Python 实现甚至未来的 CPython 版本。尽管如此,我看到有人遇到这些边缘情况,然后开始错误地使用is
运算符,所以我觉得值得一提。
令人惊讶的是,对于元组t
,t[:]
并不会创建一个副本,而是返回对同一对象的引用。如果写成tuple(t)
也会得到对同一元组的引用。⁴ 示例 6-17 证明了这一点。
示例 6-17. 从另一个元组构建的元组实际上是完全相同的元组
>>> t1 = (1, 2, 3) >>> t2 = tuple(t1) >>> t2 is t1 # ① True >>> t3 = t1[:] >>> t3 is t1 # ② True
①
t1
和t2
绑定到同一个对象。
②
t3
也是如此。
相同的行为也可以观察到str
、bytes
和frozenset
的实例。请注意,frozenset
不是一个序列,因此如果fs
是一个frozenset
,fs[:]
不起作用。但fs.copy()
具有相同的效果:它欺骗性地返回对同一对象的引用,根本不是副本,正如示例 6-18 所示。⁵
示例 6-18. 字符串字面量可能创建共享对象
>>> t1 = (1, 2, 3) >>> t3 = (1, 2, 3) # ① >>> t3 is t1 # ② False >>> s1 = 'ABC' >>> s2 = 'ABC' # ③ >>> s2 is s1 # ④ True
①
从头开始创建一个新元组。
②
t1
和t3
相等,但不是同一个对象。
③
从头开始创建第二个str
。
④
令人惊讶:a
和b
指向同一个str
!
共享字符串字面量是一种名为内部化的优化技术。CPython 使用类似的技术来避免程序中频繁出现的数字(如 0、1、-1 等)的不必要重复。请注意,CPython 并不会对所有字符串或整数进行内部化,它用于执行此操作的标准是一个未记录的实现细节。
警告
永远不要依赖于str
或int
的内部化!始终使用==
而不是is
来比较字符串或整数的相等性。内部化是 Python 解释器内部使用的优化。
本节讨论的技巧,包括frozenset.copy()
的行为,是无害的“谎言”,可以节省内存并使解释器更快。不要担心它们,它们不应该给你带来任何麻烦,因为它们只适用于不可变类型。也许这些琐事最好的用途是与其他 Python 爱好者打赌。⁶
章节总结
每个 Python 对象都有一个标识、一个类型和一个值。对象的值随时间可能会改变,只有对象的值可能会随时间改变。⁷
如果两个变量引用具有相等值的不可变对象(a == b
为True
),实际上很少关心它们是引用副本还是别名引用相同对象,因为不可变对象的值不会改变,只有一个例外。这个例外是不可变集合,例如元组:如果不可变集合保存对可变项的引用,那么当可变项的值发生变化时,其值实际上可能会改变。在实践中,这种情况并不常见。在不可变集合中永远不会改变的是其中对象的标识。frozenset
类不会受到这个问题的影响,因为它只能保存可散列的元素,可散列对象的值根据定义永远不会改变。
变量保存引用在 Python 编程中有许多实际后果:
- 简单赋值不会创建副本。
- 使用
+=
或*=
进行增强赋值会在左侧变量绑定到不可变对象时创建新对象,但可能会就地修改可变对象。 - 将新值分配给现有变量不会更改先前绑定到它的对象。这被称为重新绑定:变量现在绑定到不同的对象。如果该变量是先前对象的最后一个引用,那么该对象将被垃圾回收。
- 函数参数作为别名传递,这意味着函数可能会改变作为参数接收的任何可变对象。除了制作本地副本或使用不可变对象(例如,传递元组而不是列表)外,没有其他方法可以阻止这种情况发生。
- 使用可变对象作为函数参数的默认值是危险的,因为如果参数在原地更改,则默认值也会更改,影响到依赖默认值的每个未来调用。
在 CPython 中,对象一旦引用数达到零就会被丢弃。如果它们形成具有循环引用但没有外部引用的组,它们也可能被丢弃。
在某些情况下,保留对一个对象的引用可能是有用的,这个对象本身不会保持其他对象的存活。一个例子是一个类想要跟踪其所有当前实例。这可以通过弱引用来实现,这是更有用的集合WeakValueDictionary
、WeakKeyDictionary
、WeakSet
以及weakref
模块中的finalize
函数的基础机制。有关更多信息,请参阅fluentpython.com上的“弱引用”章节。
进一步阅读
Python 语言参考的“数据模型”章节以清晰的方式解释了对象的标识和值。
Wesley Chun,Core Python 系列书籍的作者,在 2011 年的 EuroPython 上做了题为Understanding Python’s Memory Model, Mutability, and Methods的演讲,不仅涵盖了本章的主题,还涉及了特殊方法的使用。
Doug Hellmann 撰写了关于“copy – Duplicate Objects”和“weakref—Garbage-Collectable References to Objects”的帖子,涵盖了我们刚讨论过的一些主题。
更多关于 CPython 分代垃圾收集器的信息可以在gc 模块文档中找到,其中以“此模块提供了一个可选垃圾收集器的接口。”开头。这里的“可选”修饰词可能令人惊讶,但“数据模型”章节也指出:
实现可以延迟垃圾收集或完全省略它——垃圾收集的实现质量如何取决于实现,只要不收集仍然可达的对象。
Pablo Galindo 在Python 开发者指南中深入探讨了 Python 的 GC 设计,针对 CPython 实现的新手和有经验的贡献者。
CPython 3.4 垃圾收集器改进了具有__del__
方法的对象的处理,如PEP 442—Safe object finalization中所述。
维基百科有一篇关于string interning的文章,提到了这种技术在几种语言中的使用,包括 Python。
维基百科还有一篇关于“Haddocks’ Eyes”的文章,这是我在本章开头引用的 Lewis Carroll 的歌曲。维基百科编辑写道,这些歌词被用于逻辑和哲学作品中“阐述名称概念的符号地位:名称作为识别标记可以分配给任何东西,包括另一个名称,从而引入不同级别的符号化。”
¹ Lynn Andrea Stein 是一位屡获殊荣的计算机科学教育家,目前在奥林工程学院任教。
² 相比之下,像str
、bytes
和array.array
这样的扁平序列不包含引用,而是直接保存它们的内容——字符、字节和数字——在连续的内存中。
³ 在英文维基百科中查看最少惊讶原则。
⁴ 这是明确记录的。在 Python 控制台中键入help(tuple)
以阅读:“如果参数是一个元组,则返回值是相同的对象。”在写这本书之前,我以为我对元组了解一切。
⁵ 使copy
方法不复制任何内容的无害谎言是为了接口兼容性:它使frozenset
更兼容set
。无论两个相同的不可变对象是相同的还是副本,对最终用户都没有影响。
⁶ 这些信息的可怕用途是在面试候选人或为“认证”考试编写问题时询问。有无数更重要和更有用的事实可用于检查 Python 知识。
⁷ 实际上,通过简单地将不同的类分配给其__class__
属性,对象的类型可以更改,但这是纯粹的邪恶,我后悔写下这个脚注。