写点什么

Python 中的浅拷贝和深拷贝

用户头像
王坤祥
关注
发布于: 2020 年 06 月 30 日
Python中的浅拷贝和深拷贝

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

==和 is

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

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


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


举个例子:

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

a is bFalse
复制代码


首先 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 == l2True
l1 is l2False
---------------
s1 = set([1, 2, 3])s2 = set(s1)
s2{1, 2, 3}
s1 == s2True
s1 is s2False
复制代码


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

import copyl1 = [1, 2, 3]l2 = copy.copy(l1)
复制代码


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

t1 = (1, 2, 3)t2 = tuple(t1)
t1 == t2True
t1 is t2True
复制代码


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

# ----- 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 copyl1 = [[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 copyx = [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 copyx = [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对象的比较、拷贝


发布于: 2020 年 06 月 30 日阅读数: 88
用户头像

王坤祥

关注

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

不懂热能的低温工程师不是好程序猿

评论

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