Python 中的浅拷贝和深拷贝

发布于: 23 小时前
Python中的浅拷贝和深拷贝

在讨论浅拷贝和深拷贝之前,首先要了解python中常见的两个对象的比较场景。

==和is

==操作符进行的是对象的值判断,比较两个对象的值是否相等。

is操作符进行的是对象的身份标识的判断,比较两个对象的内存地址是否相等。

None在Python中比较特殊,在Python里是个单例对象,一个变量如果是None,它一定和None指向同一个内存地址。None是python中的一个特殊的常量,表示一个空的对象,空值是python中的一个特殊值。数据为空并不代表是空对象,例如[],''等都不是None。None和任何对象比较返回值都是False,除了自己。

举个例子:

a = 1
b = 1
a == b
True
id(a) == id(b)
True
a is b
True
----------
a = 369
b = 369
a == b
True
id(a) == id(b)
False
a is b
False

首先 Python 会为 1这个值开辟一块内存,然后变量 a 和 b 同时指向这块内存地址,即 a 和 b 都是指向 1 这个变量,因此 a 和 b 的值相等,id 也相等,a == b和a is b都返回 True。

不过,需要注意,对于整型数字来说,以上a is b为 True 的结论,只适用于 [-5 , 257 )范围内的数字。

出于对性能优化的考虑,Python 内部会对 -5 到 256 的整型维持一个数组,起到一个缓存的作用。每次创建一个 -5 到 256 范围内的整型数字时,Python 都会从这个数组中返回相对应的值的引用,而不是重新频繁地开辟一块新的内存空间。

上述例子中的 369,Python 则会为两个369开辟两块内存区域,因此 a 和 b 的 ID 不一样,a is b就会返回 False 了。

浅拷贝shallow copy

浅拷贝通常的实现方法是使用数据类型本身的构造器,此处所说通常情况,是因为后面存在着特殊情况(元组的构造器),比如:

l1 = [1, 2, 3]
l2 = list(l1) # l2 = l1[:]
l2
[1, 2, 3]
l1 == l2
True
l1 is l2
False
---------------
s1 = set([1, 2, 3])
s2 = set(s1)
s2
{1, 2, 3}
s1 == s2
True
s1 is s2
False

当然,Python 中也提供了相对应的函数 copy.copy(),适用于任何数据类型,用来进行浅拷贝:

import copy
l1 = [1, 2, 3]
l2 = copy.copy(l1)

前面提到了元组的特殊情况,使用 tuple() 或者切片操作符':'不会创建一份浅拷贝,相反,它会返回一个指向相同元组的引用。实际上,使用copy.copy()以及copy.deepcopy()得到的,也是一个指向相同元组的引用。

t1 = (1, 2, 3)
t2 = tuple(t1)
t1 == t2
True
t1 is t2
True

浅拷贝,是指重新分配一块内存,创建一个新的对象,里面的元素是原对象中子对象的引用。既然拷贝得到的新的对象的元素是对原对象中子对象的引用,那么有几个点需要注意一下。看一下下面的例子。

# ----- 1 -----
l1 = [[1, 2], (30, 40)]
l2 = list(l1)
# ----- 1 -----
# ----- 2 -----
l1.append(100)
l1
[[1, 2, 3], (30, 40), 100]
l2
[[1, 2, 3], (30, 40)]
# ----- 2 -----
# ----- 3&4 -----
l1[0].append(3)
l1[1] += (50, 60)
l1
[[1, 2, 3], (30, 40, 50, 60), 100]
l2
[[1, 2, 3], (30, 40)]

  1. 这个例子中,我们首先初始化了一个列表 l1,里面的元素是一个列表和一个元组;然后对 l1 执行浅拷贝,赋予 l2。因为浅拷贝里的元素是对原对象元素的引用,因此 l2 中的元素和 l1 指向同一个列表和元组对象。

  2. 紧接着,l1.append(100),表示对 l1 的列表新增元素 100。这个操作不会对 l2 产生任何影响,因为 l2 和 l1 作为整体是两个不同的对象,并不共享内存地址。操作过后 l2 不变,l1 会发生改变。

  3. 再来看,l1[0].append(3),这里表示对 l1 中的第一个列表新增元素 3。因为 l2 是 l1 的浅拷贝,l2 中的第一个元素和 l1 中的第一个元素,共同指向同一个列表,因此 l2 中的第一个列表也会相对应的新增元素 3。操作后 l1 和 l2 都会改变。

  4. 最后是l1[1] += (50, 60),因为元组是不可变的,这里表示对 l1 中的第二个元组拼接,然后重新创建了一个新元组作为 l1 中的第二个元素,而 l2 中没有引用新元组,因此 l2 并不受影响。操作后 l2 不变,l1 发生改变。

上面的例子,对于可变对象的引用,尤其是列表独享,容易带来一些副作用,要避免这种情况,可以使用深拷贝。

深拷贝deep copy

Python 中以 copy.deepcopy() 来实现对象的深度拷贝。

import copy
l1 = [[1, 2], (30, 40)]
l2 = copy.deepcopy(l1)
l1.append(100)
l1[0].append(3)
l1
[[1, 2, 3], (30, 40), 100]
l2
[[1, 2], (30, 40)]

可以看到,无论 l1 如何变化,l2 都不变。因为此时的 l1 和 l2 完全独立,没有任何联系。

深度拷贝也不是完美的,往往也会带来一系列问题。如果被拷贝对象中存在指向自身的引用,那么程序很容易陷入无限循环:

import copy
x = [1]
x.append(x)
x
[1, [...]]
y = copy.deepcopy(x)
y
[1, [...]]

列表 x 中有指向自身的引用,因此 x 是一个无限嵌套的列表。但是我们发现深度拷贝 x 到 y 后,程序并没有出现 stack overflow 的现象。这是为什么呢?

这是因为深度拷贝函数 deepcopy 中会维护一个字典,记录已经拷贝的对象与其 ID。拷贝过程中,如果字典里已经存储了将要拷贝的对象,则会从字典直接返回,我们来看相对应的源码就能明白:

def deepcopy(x, memo=None, _nil=[]):
"""Deep copy operation on arbitrary Python objects.
See the module's __doc__ string for more info.
"""
if memo is None:
memo = {}
d = id(x) # 查询被拷贝对象x的id
y = memo.get(d, _nil) # 查询字典里是否已经存储了该对象
if y is not _nil:
return y # 如果字典里已经存储了将要拷贝的对象,则直接返回
...

如果执行如下操作:

import copy
x = [1]
x.append(x)
y = copy.deepcopy(x)
# 重点来了
x == y
---------------------------------------------------------------------------
RecursionError Traceback (most recent call last)
<ipython-input-276-9cfbd892cdaa> in <module>
----> 1 x == y
RecursionError: maximum recursion depth exceeded in comparison

出现上面错误的原因很明显,在执行 == 操作时,因为x中存储了自身的引用,会无限的递归与y比较,从而造成RecursionError异常,因为最大递归深度有一定的限制。

总结

  • 比较操作符'=='表示比较对象间的值是否相等,而'is'表示比较对象的标识是否相等,即它们是否指向同一个内存地址。

  • 浅拷贝中的元素,是原对象中子对象的引用,因此,如果原对象中的元素是可变的,改变其也会影响拷贝后的对象,存在一定的副作用。

  • 深度拷贝则会递归地拷贝原对象中的每一个子对象,因此拷贝后的对象和原对象互不相关。另外,深度拷贝中会维护一个字典,记录已经拷贝的对象及其 ID,来提高效率并防止无限递归的发生。

最后供上一张图方便理解:


参考资料

Python对象的比较、拷贝

发布于: 23 小时前 阅读数: 11
用户头像

王坤祥

关注

日拱一卒,功不唐捐。 2017.10.17 加入

还未添加个人简介

评论

发布
暂无评论
Python中的浅拷贝和深拷贝