python deepcopy函数_Python对象引用与可变性

本文是《流畅的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 的浅复制副本。此时的状态如图。

8d4f15a036bab7bc61a9b0817c1d48ee.png

➋ 把 100 追加到 l1 中,对 l2 没有影响。

30519bcb7d7565404f0b4a3fc6deabbf.png

➌ 把内部列表 l1[1] 中的 55 删除。这对 l2 有影响,因为 l2[1] 绑定的列表与 l1[1]是同一个。

c05aaba9471fad76404ae82086a8b07f.png

➍ 对可变的对象来说,如 l2[1] 引用的列表, += 运算符就地修改列表。这次修改在l1[1] 中也有体现,因为它是 l2[1] 的别名。

fc16d618afc27bb9446497e0ee4bf186.png

➎ 对元组来说, += 运算符创建一个新元组,然后重新绑定给变量 l2[2]。现在, l1 和 l2 中最后位置上的元组不是同一个对象。

f92d48ce336dabd31f4ecc39c2953d9a.png

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

有时我们需要的是深复制(即副本不共享内部对象的引用)。copy 模块提供的 deepcopycopy 函数能为任意对象做深复制和浅复制。

为了演示 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 编程有很多实际的影响。

  1. 简单的赋值不创建副本。
  2. 对 += 或 *= 所做的增量赋值来说,如果左边的变量绑定的是不可变对象,会创建新对象;如果是可变对象,会就地修改。
  3. 为现有的变量赋予新值,不会修改之前绑定的变量。这叫重新绑定:现在变量绑定了其他对象。如果变量是之前那个对象的最后一个引用,对象会被当作垃圾回收。
  4. 函数的参数以别名的形式传递,这意味着,函数可能会修改通过参数传入的可变对象。这一行为无法避免,除非在本地创建副本,或者使用不可变对象(例如,传入元组,而不传入列表)。
  5. 使用可变类型作为函数参数的默认值有危险,因为如果就地修改了参数,默认值也就变了,这会影响以后使用默认值的调用。

Published by

风君子

独自遨游何稽首 揭天掀地慰生平

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注