本文是《流畅的Python》第八章的读书笔记,本文先讨论对象标识、值和别名等概念。随后,会揭露元组的一个神奇特性:元组是不可变的,但是其中的值可以改变。然后就引申到浅复制和深复制。最后的话题是引用和函数参数:可变的参数默认值导致的问题,以及如何安全地处理函数的调用者传入的可变参数。
本文的内容有点儿枯燥,但是这些话题却是解决 Python 程序中很多不易察觉的 bug 的关键。
标识、相等性和别名
在Python中,每个变量都有标识、类型和值。对象一旦创建,它的标识绝不会变;可以把标识理解为对象在内存中的地址。 is 运算符比较两个对象的标识,而==比较两个对象的值; id() 函数返回对象的内存地址。
t1 = {'name': 'Lili', 'born': 2001}t2 = {'name': 'Lili', 'born': 2001}t1 == t2
True
t1 is t2
False
(id(t1), id(t2))
(140059319293992, 140059319293848)
当然,你可以在自己的类中定义 __eq__ 方法,从而决定 == 如何比较实例。如果不覆盖 __eq__ 方法,那么从 object 继承的方法比较对象的 ID,因此这种后备机制认为用户定义的类的各个实例是不同的。
元组与多数 Python 集合(列表、字典、集,等等)一样,保存的是对象的引用。 如果引用的元素是可变的,即便元组本身不可变,元素依然可变。也就是说,元组的不可变性其实是指 tuple 数据结构的物理内容(即保存的引用)不可变,与引用的对象无关。
t1 = (1, 2, [30, 40])t1[2].append(100)t1
(True, False)
默认做浅复制
复制列表(或多数内置的可变集合)最简单的方式是使用内置的类型构造方法。例如:
l1 = [3, [66, 55, 44], (7, 8, 9)]l2 = list(l1)(l2 == l1, l2 is l1)
(True, False)
(id(l1), id(l2))
(140059319253960, 140059319197384)
可以看出,副本与源列表相等,但是二者指代不同的对象。对列表和其他可变序列来说,还能使用简洁的 l2 = l1[:] 语句创建副本。
然而,构造方法或 [:] 做的是浅复制(即复制了最外层容器,副本中的元素是源容器中元素的引用)。如果所有元素都是不可变的,那么这样没有问题,还能节省内存。但是,如果有可变的元素,可能就会导致意想不到的问题。看看下面的代码:
l1 = [3, [66, 55, 44], (7, 8, 9)]l2 = list(l1) # ➊l1.append(100) # ➋l1[1].remove(55) # ➌print('l1:', l1)print('l2:', l2)l2[1] += [33, 22] # ➍l2[2] += (10, 11) # ➎print('l1:', l1)print('l2:', l2)
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)]
➊ l2 是 l1 的浅复制副本。此时的状态如图。
➋ 把 100 追加到 l1 中,对 l2 没有影响。
➌ 把内部列表 l1[1] 中的 55 删除。这对 l2 有影响,因为 l2[1] 绑定的列表与 l1[1]是同一个。
➍ 对可变的对象来说,如 l2[1] 引用的列表, += 运算符就地修改列表。这次修改在l1[1] 中也有体现,因为它是 l2[1] 的别名。
➎ 对元组来说, += 运算符创建一个新元组,然后重新绑定给变量 l2[2]。现在, l1 和 l2 中最后位置上的元组不是同一个对象。
为任意对象做深复制和浅复制
有时我们需要的是深复制(即副本不共享内部对象的引用)。copy 模块提供的 deepcopy 和 copy 函数能为任意对象做深复制和浅复制。
为了演示 copy() 和 deepcopy() 的用法,定义了一个简单的类 Bus。这个类表示运载乘客的校车,在途中乘客会上车或下车。
import copyclass 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)bus1 = Bus(['Alice', 'Bill', 'David'])bus2 = copy.copy(bus1)bus3 = copy.deepcopy(bus1)id(bus1), id(bus2), id(bus3) # ➊
(140059318933488, 140059318933600, 140059318933264)
id(bus1.passengers), id(bus2.passengers), id(bus3.passengers) # ➋
(140059319442504, 140059319442504, 140059319440968)
bus1.drop('Bill')bus2.passengers # ❸
['Alice', 'David']
bus3.passengers # ❹
['Alice', 'Bill', 'David']
➊ 使用 copy 和 deepcopy,创建 3 个不同的 Bus 实例。
➋ 审查 passengers 属性后发现,bus1 和 bus2 共享同一个列表对象,因为 bus2 是bus1 的浅复制副本。
❸ bus1 中的 'Bill' 下车后,bus2 中也没有他了。
❹ bus3 是 bus1 的深复制副本,因此它的 passengers 属性指代另一个列表。
深复制有时可能太深了。例如,对象可能会引用不该复制的外部资源或单例值。我们可以实现特殊方法 __copy__() 和 __deepcopy__(),控制 copy 和 deepcopy 的行为。
函数的参数传递
Python 唯一支持的参数传递模式是共享传参(call by sharing),共享传参指函数的各个形式参数获得实参中各个引用的副本。也就是说,函数内部的形参是实参的别名。函数可能会修改接收到的任何可变对象。
不要用可变类型作为参数的默认值
默认参数最好指向不可变对象,否则会引起难以调试的问题。
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)
❶ 如果没传入 passengers 参数,使用默认绑定的列表对象,一开始是空列表。
❷ 这个赋值语句把 self.passengers 变成 passengers 的别名,而没有传入passengers 参数时,后者又是默认列表的别名。
❸ 在 self.passengers 上调用 .remove() 和 .append() 方法时,修改的其实是默认列表,它是函数对象的一个属性。
bus1 = HauntedBus()bus1.pick('Carrie')bus2 = HauntedBus()print(bus2.passengers) # ['Carrie']bus2.passengers is bus1.passengers
['Carrie']True
可以看出 bus2 的列表竟然不为空。这种问题很难发现。实例化 HauntedBus 时,如果传入乘客,会按预期运作。但是不为 HauntedBus 指定乘客的话,奇怪的事就发生了,这是因为 self.passengers 变成了 passengers 参数默认值的别名。出现这个问题的根源是,默认值在定义函数时计算(通常在加载模块时),因此默认值变成了函数对象的属性。因此,如果默认值是可变对象,而且修改了它的值,那么后续的函数调用都会受到影响。
防御可变参数
如果定义的函数接收可变参数,应该谨慎考虑调用方是否期望修改传入的参数。
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)basketball_team = ['Sue', 'Tina', 'Maya', 'Diana', 'Pat'] # ➊bus = TwilightBus(basketball_team) # ➋bus.drop('Tina') # ➌bus.drop('Pat')basketball_team # ➍
['Sue', 'Maya', 'Diana']
basketball_team 中有 5 个学生的名字,使用这队学生实例化 TwilightBus。两个学生下车了,下车的学生从篮球队中消失了!
当 passengers 不为 None 时,self.passengers 变成 passengers 的别名,而后者是传给 __init__ 方法的实参的别名。在 self.passengers 上调用 .remove() 和 .append() 方法其实会修改传给构造方法的那个列表。
这里的问题是,校车为传给构造方法的列表创建了别名。正确的做法是,校车自己维护乘客列表。修正的方法很简单:在 init 中,传入 passengers 参数时,应该把参数值的副本赋值给 self.passengers。
正确的做法应该如下:
def __init__(self, passengers=None): if passengers is None: self.passengers = [] else: self.passengers = list(passengers)
总结
变量保存的是引用,这一点对 Python 编程有很多实际的影响。
- 简单的赋值不创建副本。
- 对 += 或 *= 所做的增量赋值来说,如果左边的变量绑定的是不可变对象,会创建新对象;如果是可变对象,会就地修改。
- 为现有的变量赋予新值,不会修改之前绑定的变量。这叫重新绑定:现在变量绑定了其他对象。如果变量是之前那个对象的最后一个引用,对象会被当作垃圾回收。
- 函数的参数以别名的形式传递,这意味着,函数可能会修改通过参数传入的可变对象。这一行为无法避免,除非在本地创建副本,或者使用不可变对象(例如,传入元组,而不传入列表)。
- 使用可变类型作为函数参数的默认值有危险,因为如果就地修改了参数,默认值也就变了,这会影响以后使用默认值的调用。