跳转至主要内容

Python编程

深入理解Python中的深拷贝和浅拷贝

Sprite
发表于 2024年10月21日

在介绍浅拷贝和浅拷贝之前,我们介绍一下不可变对象和可变对象,在 Python 中,对象可以分为不可变对象可变对象,它们的区别主要在于对象创建后是否能够修改。

不可变对象(Immutable Objects)

不可变对象一旦创建,其内容就不能改变。每当尝试修改时,实际上是创建了一个新的对象,而不是在原对象上进行修改。常见的不可变对象包括:

  • 整数(int): 比如 510,一旦创建,值就不能改变。
  • 浮点数(float): 像 3.14 这样的浮点数也是不可变的。
  • 字符串(str): 字符串的内容无法修改,如果你尝试更改字符串,Python 会创建一个新的字符串对象。
  • 元组(tuple): 元组是一种不可变的序列,创建后其中的元素不能被更改。
  • 布尔值(bool): TrueFalse 也是不可变的。

比如下面这个例子

x = 10
x = x + 5  # 实际上是创建了一个新的整数对象,并赋值给 x

这里,x 重新指向了一个新值 15,但原来的值 10 没有被修改。

可变对象(Mutable Objects)

可变对象的内容是可以在原对象上直接修改的。修改这些对象时,不需要创建新的对象。常见的可变对象包括:

  • 列表(list): 列表的元素可以被添加、删除或修改。
  • 字典(dict): 字典的键值对可以动态修改,添加或删除。
  • 集合(set): 集合中的元素可以动态添加或删除。

我们看一下这个例子

my_list = [123]
my_list[0] = 10  # 列表的第一个元素被修改为 10,原列表发生了变化

这里,my_list 本身被修改了,没有创建新的对象。

总结

  • 不可变对象:值不能改变,修改时会创建新对象(比如字符串、整数)。
  • 可变对象:可以直接修改原对象的内容(比如列表、字典)。

理解这些概念有助于正确使用 Python 的深拷贝和浅拷贝,并避免意外的数据修改。

  1. 不可变对象(比如字符串、元组等),因为它们不能被修改,所以深拷贝和浅拷贝的效果是一样的,复制它们时不需要重新分配内存。
  2. 可变对象(比如列表、字典等),深拷贝和浅拷贝的区别主要体现在它们的可变性上。深拷贝会彻底复制所有内容,而浅拷贝只复制最外层,内部的可变对象会共用同一个引用。

深拷贝和浅拷贝的概念理解

  1. 浅拷贝:创建一个新的对象,并为它分配一块新的内存,但新对象里的元素其实还是原对象中各个子对象的引用。所以修改子对象时,原对象和新对象都会受到影响。
  2. 深拷贝:创建一个全新的对象,并且递归地把原对象中的每一个子对象都复制一份,分配给新对象。这意味着新对象和原对象完全独立,互不影响。

对于不可变对象,深拷贝和浅拷贝的效果是一样的,而深拷贝和浅拷贝的讨论只有在可变数据类型的情况下才有意义,我们看下面这个例子

>>> a = [1, 2, 3]
>>> b = a
>>> print(b)
[1, 2, 3]
>>> a[0] = 0
>>> print(b)
[0, 2, 3]

你会发现,当我们更新元素 a ,b 也会被同步更新,这就是因为浅拷贝导致的。如果你想改变a 的同时不改变b,我们可以将代码改成下面这样:

>>> a = [1, 2, 3]
>>> b = list(a)
>>> a[0] = 0
>>> print(b)
[1, 2, 3]

现在你可以看到你有两个独立的对象。你还可以通过运行 id(a) 和 id(b) 来验证,并检查它们是否有效地不同。

我们再看下面一个例子:

>>> a = [[1, 2, 3], [4, 5, 6]]
>>> b = list(a)

如果你检查 id(a) 和 id(b) ,你会发现它们是不同的。我们可以再进一步改变 a

>>> a.append([7, 8, 9]) 
>>> print(b) [[1, 2, 3], [4, 5, 6]]

看起来b还是没有发生更新,直到我们做了以下操作:

>>> a[0][0] = 0
>>> print(b)
[[0, 2, 3], [4, 5, 6]]

我们改变了 a , b 也改变了!这就涉及到深拷贝和浅拷贝。当我们通过 list(a) 将 a 复制到 b 中时,我们执行了浅拷贝。这意味着我们创建了一个新元素(这就是为什么 id 不同),但对其他元素的引用仍然相同。我们可以通过检查 a 和 b 的第一个元素的 id 来验证这一点:

>>> id(a[0])
140381216067976
>>> id(b[0])
140381216067976

浅拷贝就像其名字一样,只是一个表面复制。只有第一层被创建为新的对象,而底层的对象不会被复制,对于字典也是如此。

对于列表,还有另一种方式可以进行浅拷贝:

>>> b = a[:]

对于字典,你可以使用:

>>> my_dict = {'a': [1, 2, 3], 'b': [4, 5, 6]}
>>> new_dict = my_dict.copy()
>>> other_option = dict(my_dict)

但是我们想创建一个完全新的对象的深层拷贝,我们使用 copy 模块。

我们使用copy模块进行上面的浅拷贝:

>>> import copy
>>> b = copy.copy(a)
>>> id(a[0])
140381216067976
>>> id(b[0])
140381216067976

我们可以看到使用copy.copy() 进行浅拷贝后,id(a[0])id(b[0]) 都是一样。

我们再使用deepcopy进行深拷贝:

>>> c = copy.deepcopy(a)
>>> id(c[0])
140381217929672

我们可以看到id(c[0]) 是不一样的。

自定义类的拷贝

我们已经看到了标准 Python 数据类型(如列表和字典)的深拷贝和浅拷贝之间的区别。现在重要的是看看当你定义自己的类并引用其他可变对象时会发生什么。让我们快速看看如果复制你的自定义类会发生什么:

class MyClass:
    def __init__(self, x, y):
        self.x = x
        self.y = y

my_class = MyClass([12], [34])
my_new_class = my_class

print(id(my_class))
print(id(my_new_class))

my_class.x[0] = 0
print(my_new_class.x)

输出结果:

140397059541368
140397059541368
[02]

我们可以看到,通过简单地复制具有 = 的类,我们得到了对同一对象的两个引用,因此 id 是相同的。如果对象的可变属性之一发生变化,所有其他对象中的属性也会发生变化。

解决方案是使用 copy 模块:

import copy

class MyClass:
    def __init__(self, x, y):
        self.x = x
        self.y = y

my_class = MyClass([12], [34])
my_new_class = copy.copy(my_class)

print(id(my_class))
print(id(my_new_class))

my_class.x[0] = 0
print(my_new_class.x)

上述代码的输出:

140129009113464
140129008512416
[02]

我们可以看到 my_classmy_new_class 有不同的 id 值,但它们引用的对象仍然相同。如果我们将 copy 改为 deepcopy ,行为会改变,就像对列表或字典进行操作一样。

import copy

class MyClass:
    def __init__(self, x, y):
        self.x = x
        self.y = y

my_class = MyClass([12], [34])
my_new_class = copy.deepcopy(my_class)

print(id(my_class))
print(id(my_new_class))

my_class.x[0] = 0
print(my_new_class.x)

上述代码的输出:

140129009113464
140129008512416
[12]

对象的定制深拷贝和浅拷贝

使用 Python,你可以非常精细地控制每一步的控制级别,包括深拷贝和浅拷贝

为了实现这个控制,我们需要重写方法 __copy__ 和 __deepcopy__ ,接下来我们看看如何实现,再讲讲为什么。

首先,想象一下,我们希望能够复制一个带有其所有引用的类,我们可以这样做:

class MyClass:
    def __init__(self, x, y):
        self.x = x
        self.y = y
        self.other = [123]

    def __copy__(self):
        new_instance = MyClass(self.x, self.y)
        new_instance.__dict__.update(self.__dict__)
        new_instance.other = copy.deepcopy(self.other)
        return new_instance

当使用 copy.copy 时,将执行的方法是 __copy__ ,参数是对象本身。返回值将是复制后的对象。要进行复制,

首先要实例化新类,我们通过再次调用 MyClass 来实现。可以通过使用 type(self) 替换 MyClass 来使其更加general。

下一步是将基本实例的所有属性复制到新实例中。这可以通过更新 __dict__ 属性来快速完成。这两个步骤单独定义了对象的浅拷贝的标准行为。

最后是 other 属性被进行深拷贝。 other 不是 __init__ 的一部分,只是为了展示我们可以添加类的任何属性。

最后,如果我们重复之前的简单测试,我们会得到:

my_class = MyClass([12], [34])
my_new_class = copy.copy(my_class)

print(id(my_class))
print(id(my_new_class))

my_class.x[0] = 0
my_class.y[0] = 0
my_class.other[0] = 0
print(my_new_class.x)
print(my_new_class.y)
print(my_new_class.other)

并且输出将是:

139816535263552
139816535263720
[02]
[04]
[123]

正如你所看到的,属性 other 已进行深拷贝,因此如果你在一个类中对其进行更改,则在另一个类中不会更改。

自定义深拷贝

当我们要定制类的深拷贝。这与 __copy__ 方法非常相似,但它需要一个额外的参数:

class MyClass:
    def __init__(self, x, y):
        self.x = x
        self.y = y
        self.other = [123]

    def __deepcopy__(self, memodict={}):
        new_instance = MyClass(self.x, self.y)
        new_instance.__dict__.update(self.__dict__)
        new_instance.x = copy.deepcopy(self.x, memodict)
        new_instance.y = copy.deepcopy(self.y, memodict)
        return new_instance

看起来与 copy 非常相似,但额外参数 memodict 的要求源于浅拷贝的含义。由于必须重新创建从初始类引用的每个对象,存在无限递归的风险。如果一个对象以某种方式引用自身则可能发生这种情况。即使不是无限递归循环,你可能会多次复制相同的数据。 memodict 正在跟踪已复制的对象。无限递归是我们可以通过重写 __deepcopy__ 方法来防止的。

在上面的示例中,我们所做的是防止深度复制过程生成一个新的 other 列表。因此,我们最终得到了一个混合的深度复制,其中 x 和 y 是真正新的,而 other 是相同的。如果我们运行示例代码,

my_class = MyClass([12], [34])
my_new_class = copy.deepcopy(my_class)

print(id(my_class))
print(id(my_new_class))

my_class.x[0] = 0
my_class.y[0] = 0
my_class.other[0] = 0
print(my_new_class.x)
print(my_new_class.y)
print(my_new_class.other)

我们将获得以下输出:

139952436046312
139952436046200
[12]
[34]
[023]

所以,现在你看到, .x 和 .y 没有改变,而 .other 只在my_new_class上更新。

最后讲一下为什么定义不同的复制行为

上面的简单示例只是展示如何使用深拷贝和浅拷贝实现不同的行为,但有没有想过为什么需要定义不同的复制行为?

比如,当类包含某种类型的缓存时。如果你希望在不同对象之间共享这个缓存,深拷贝的默认行为就需要调整,保留缓存的好处在于可以加速程序运行,避免重复计算,或者减少内存占用,特别是当缓存内容非常大时。

至于浅拷贝的情况,需求各不相同,它通常意味着某些属性不应该在多个对象之间共享。比如,有一个负责和设备通信的对象,你肯定不希望多个对象通过同一个接口同时与设备对话。同时,浅拷贝还可以帮助保护一些敏感的内部状态或私有属性,防止不同对象间无意中互相影响。

写作不易,欢迎关注

分类:
标签:

评论已关闭。