【Python探寻之旅】可变对象,不可变对象,深拷贝,浅拷贝

rh-li / 2024-11-06 / 原文

目录
  • 1. 可变对象和不可变对象
    • 1.1 “对象的值可以改变”是什么意思?
    • 1.2 “对象的值不能改变”是什么意思?
    • 1.3 总结
  • 2. 用=赋值有什么问题
  • 3. copy模块登场
  • 4. 重新认识列表对象
  • 5. 浅拷贝,深拷贝
    • 5.1 浅拷贝(copy.copy())
      • 浅拷贝的原理
      • 浅拷贝举例
    • 5.2 浅拷贝后,修改z中不可变对象元素的值
    • 5.3 深拷贝(copy.deepcopy())
  • 6.总结:用=赋值,浅拷贝,深拷贝的区别

看这篇文章之前,你需要了解:

  • 什么是引用

  • 在 Python 中,所有的变量名都是引用。

    例如,对于整数变量来说,变量名是指向整数对象的引用,而不是整数对象本身的名字;

    对于列表变量来说,变量名是指向列表对象的引用,而不是列表对象本身的名字。

1. 可变对象和不可变对象

想要知道什么是深拷贝和浅拷贝,必须要先知道什么是可变对象和不可变对象。、

在 Python 中,数据类型可以分为两大类:可变对象不可变对象

可变对象是指:对象的值可以改变;不可变对象是指:对象的值不能改变。

常见的可变对象有:

  • 列表(list)

  • 字典(dict)

  • 集合(set)

常见的不可变对象有:

  • 整数(int)

  • 浮点数(float)

  • 字符串(str)

  • 元组(tuple)

  • 布尔值(bool)

看到这里,你一定很懵。别急,我准备了一些例子,一定可以帮你搞定它们。

1.1 “对象的值可以改变”是什么意思?

例如:

我们创建一个列表aa的地址记作id_old。接下来,我们在a的末尾添加一个元素,添加元素后,a新的地址记作id_new

>>> a = [0,1]
>>> id_old = id(a)	# 修改前a的地址
>>> a.append(1)
>>> id_new = id(a)	# 修改后a的地址
>>> print(id_old == id_new)
True

比较id_oldid_new,发现id_oldid_new相等。这表示,添加元素前后,a的地址没有发生变化。也就是说,Python直接在原地址上修改了a的值。因此,列表a的值是可以改变的。

这就是“对象的值可以改变”的意思。

1.2 “对象的值不能改变”是什么意思?

例如:

我们创建一个整数bb的地址记作id_old。接下来,我们修改b的值,修改后,b新的地址记作id_new

>>> b = 1
>>> id_old = id(b)	# 修改前b的地址
>>> b = 2
>>> id_new = id(b)	# 修改后b的地址
>>> print(id_old == id_new)
False

比较id_oldid_new,发现id_oldid_new不等。这表示,修改前后,b的地址改变了。也就是说,Python不能在原地址上直接修改b的值。

为什么会这样?

  • 执行b=1的时候:

    Python首先在内存中开辟一块空间,用来存储整数1。然后Python创建一个引用b,指向这块空间。

    is_old就是这片空间的地址。

  • 执行b=2的时候:

    由于整数类型的性质,Python不能直接修改整数的值。因此,Python必须要再开辟一块空间,用来存储整数2。然后,Python让引用b指向新创建的空间。

    is_new就是新创建空间的地址

    image-20241026153431187

    因此,整数b的值是不能改变的。如果想要“修改”b的值,实际上并不是修改原来的整数对象,而是创建一个新的整数对象并将b指向它。

    这就是“对象的值不能改变”的意思。

1.3 总结

对于上面两个例子,我画了一张图,来更直观的说明在修改前后,内存中发生了什么。

  • 列表:Python可以直接对列表进行修改,因此,修改前后,内存中只有一个列表。

  • 整数:Python不能修改整数的值。如果要修改整数的值,必须创建一个新的整数对象。因此,修改后,内存中有两个整数对象:一个是初始的整数1,一个是后来创建的整数2。

image-20241026154601427

2. 用=赋值有什么问题

当我们使用=来赋值时,Python 不会创建一个新的对象,而只会创建一个新的引用。

例如,

>>> c = [1,2]
>>> id_c = id(c)
>>> d = c
>>> id_d = id(d)
>>> print(id_c == id_d)
True

当我们执行d=c时,Python不会为变量d创建一个新的列表,而是让变量cd共享同一个列表,cd只不过是对同一个列表的两个引用。

如下图,cd指向同一个列表对象

image-20241020214922555

因此,当我们改变变量c的值时,会发现,d的值也变了。

>>> c.append(3)
>>> print(d)
[1,2,3]

原因是,cd所共同指向的列表对象的值发生了变化,如下图

image-20241020215054889

因此,d的值也会变。

需要注意的是,对不可变对象来说,执行如下的代码

>>> x = 1
>>> y = x
>>> x = 2
>>> print(y)
1

你会发现,y的值并没有随着x的改变而改变。这是因为,整数是不可变对象。

在执行y=x后,xy和整数1的关系如下图,此时,xy是同一个整数对象的两个引用

执行x=2后,由于整数是不可变对象,因此Python会创建一个新的值为2的整数对象,并将这个整数对象赋给x,如下图

image-20241020215715045

因此,y还是指向原来的整数,并没有受到影响,y的值不会改变。

3. copy模块登场

在上面,我们看到,使用=,不能复制列表,只能创建同一个列表对象的另一个引用。一旦原来的列表变量的值发生变化,那么新创建的列表变量的值也会随之改变。

那么,如何复制一份新的列表,使新列表的值不会受到旧列表的影响呢?

为了解决这个问题,我们需要引入copy模块。

在copy模块中,只有两个函数,copydeepcopy

看下面的例子

>>> import copy
>>> x = [1,2]
>>> y = copy.copy(x)
>>> z = copy.deepcopy(x)
>>> x.append(3)
>>> print(y)
>>> print(z)
[1,2]
[1,2]

可以看到,在使用了copydeepcopy之后,在对x进行修改时,y和z的值就不会跟着发生变化了。

那么,copydeepcopy有什么区别呢?

为此,我们需要重新认识一下列表对象,以及引入浅拷贝和深拷贝的概念

4. 重新认识列表对象

考虑下面两个列表变量,其中x是一维列表,y是二维列表

>>> x = [1,2,3]
>>> y = [[1,2,3],[4,5,6],[7,8,9]]

其中x所对应的一维列表,在内存中是这样的:

image-20241020222915705

x是列表对象的引用。在列表对象中,存有三个地址,分别是:整数1的地址,整数2的地址以及整数3的地址。


至于y所对应的二维列表,在内存中是这样的:

image-20241021102039344

y是二维列表对象的引用。在二维列表对象中,存着三个一维列表对象的地址。在一维列表对象中,分别存着整数对象的地址。

5. 浅拷贝,深拷贝

知道列表对象在内存中是什么样子的,就可以引出浅拷贝和深拷贝了。

5.1 浅拷贝(copy.copy())

浅拷贝的原理

假设我们令

>>> y = [[1,2,3],[4,5,6],[7,8,9]]
>>> z = copy.copy(y)

那么,在内存中,执行的操作如下:

首先,Python创建一个新的二维列表对象,并令变量z指向新的二维列表对象。

但需要注意的是,执行copy.copy()后,Python不会复制一维列表对象。旧的二维列表对象中的地址,和新的二维列表对象中的地址是相同的。也就是说,新旧两个二维列表对象指向相同的一维列表对象。

如下图

image-20241021103146372

为了方便,这里省略了一维列表对象中的地址和整数对象

对于二维列表来说,浅拷贝只会复制二维列表对象,而不会复制一维列表对象和整数对象。

同样的,如果是三维列表,那么浅拷贝只会复制三维列表对象,而不会复制二维列表对象,一维列表对象和整数对象。

这就是浅拷贝的含义,浅拷贝只复制“最上层”的列表。

浅拷贝举例

y是一个二维列表,zy的浅拷贝,执行下面的代码

>>> y = [[1,2,3],[4,5,6],[7,8,9]]
>>> z = copy.copy(y)
>>> z.append([1,2,3])
>>> print(y)
>>> z[0] = [1,2]
>>> print(y)
[[1,2,3],[4,5,6],[7,8,9]]
[[1,2],[4,5,6],[7,8,9]]

可以看到,向z中添加元素时,y的值不会变化。而修改z的第一个列表的值时,y的值也会发生变化。


当向z中添加元素时, 内存中的操作如下图

image-20241021103231925

可以看到,此时,在z指向的二维列表对象中,新添加了一个地址4,地址4指向的就是新添加的元素。

不过,虽然z中多了一个地址4,但这是对z指向的二维列表对象所做的修改,y指向的二维列表对象不会改变。因此向z中添加元素,不会影响到y的值。


当修改z的第一个列表时,内存中的操作如下

图中标红的部分表示被修改的列表对象。

修改z的第一个列表时,对于y,z分别指向的二维列表对象,它们不会发生变化。改变的是一维列表对象的值。

可以看到,yz中,都有指向红色列表的地址。因此一旦红色列表被修改,那么yz的值都会改变。

5.2 浅拷贝后,修改z中不可变对象元素的值

考虑下面的代码

>>> x = [1,2,3]
>>> z = copy.copy(x)

内存中的操作如下:

Python复制了一个新的一维列表对象,并令z指向新的一维列表对象。

同时,旧一维列表中的地址,和新一维列表中的地址是相同的。也就是说,新旧两个一维列表指向相同的整数对象。

如下图

image-20241021104844717

当我们修改z中整数的值时:

>>> z[0] = 0

由于整数是不可变对象,而不可变对象不允许修改对象的内容,如果需要更改内容,必须创建一个新的对象。

因此我们执行z[0]=0时,实际上创建了一个新的整数对象,并令新的整数对象的值为0,如下图:

image-20241021104935399

这时候,x[0]的值没有改变,因此x不会受到影响。

5.3 深拷贝(copy.deepcopy())

假设我们令

>>> y = [[1,2,3],[4,5,6],[7,8,9]]
>>> z = copy.deepcopy(y)

此时,Python会把y中所有的元素,完完整整的复制一遍,并赋给z。这时候,无论对z进行什么操作,都不会影响y的值了。

6.总结:用=赋值,浅拷贝,深拷贝的区别

我们前面讲过了三种赋值方式,分别是:用=直接赋值,浅拷贝(copy.copy()),以及深拷贝(copy.deepcopy())

现在,我们总结一下这三者的区别:

  • =直接赋值:

    执行y=x时,若:

    • y和x都是不可变对象,那么y和x互不影响
    • y和x都是可变对象,那么修改y的值,x的值也会随之变化;同理,修改x,y也会随之改变
  • 浅拷贝

    执行y = copy.copy(x)时,若

    • y和x都是不可变对象,那么y和x互不影响

    • y和x都是可变对象,那么

      1. 向y中添加元素,x不变;同理,向x中添加元素,y不变

      2. 修改y中可变对象元素的值,x随之改变;同理,修改x中的可变对象元素,y也会随之改变

      3. 修改y中不可变对象元素的值,x不变;同理,修改x中的可变对象元素,y不变

  • 深拷贝

    执行y = copy.deepcopy(x)时,无论y和x是什么类型,y和x的值永远互不影响。