Python和Numpy中的赋值、浅拷贝与深拷贝

前言

本篇旨在理清Python中赋值、浅拷贝与深拷贝之间的区别与联系。


1. 对象及对象的引用

在正式讨论Python中的赋值、浅拷贝与深拷贝之前,我们先来了解一下Python中的对象及其引用。

1.1 对象的定义

Python,一切皆对象 [1]

Python中,对象是一块内存空间,拥有特定的值,支持特定类型的相关操作对象具有三要素:标识(Identity)、类型(Type)、值(Value) [1]

  • 标识:对象的唯一标识,通常对应对象在计算机内存中的地址。使用内置函数id(obj)返回对象唯一标识
  • 类型:标识对象类型,表示对象存储的数据的类型。类型可以限制对象的取值范围和可执行的操作。使用内置函数type(obj)返回对象所属类型
  • :对象存储的数据的信息。使用内置函数print(obj)可以直接打印值
    在这里插入图片描述
图1(引用自 [1])

 

Python中,对象存储在堆(heap)内存中。对象通过调用__new__创建。

1.2 对象的引用

1.2.1 变量

Python中,变量用来指向任意的对象,是对对象的引用Python变量更像是指针,而不是数据存储区域 [1]

变量,可以看作是对象的一个临时「标签」,是引用,而不是装有对象的容器。例如,房间门外挂着“张先生的房间”的门牌,矿泉水瓶身上贴的一张写着“刘女士的矿泉水”的标签。变量一旦使用完毕,可被清理,就像撕掉标签。

1.2.2 引用方式

Python中,赋值(=)操作时从变量到对象自动建立的连接关系,称为引用。引用是一种关系,以内存中的指针的形式实现。赋值操作就是对象标识(identity)的传递,片面的理解是内存地址的传递

Python中,对象的引用又可细分为如下两种引用方式:

(1) 简单引用

例如:

a = 3

概念上,a = 3 这种简单引用方式执行下列三个步骤:

  • 创建一个对象来代表值 3
  • 创建一个变量a,如果它还没有创建的话
  • 将变量a指向对象3

这里,需再次强调, 变量a不是对象3本身,变量a只是对象3的引用、“标签”。

我们来看下面一个例子:

# 将变量a指向对象1,相当于将标签a贴在对象1上
>>> a = 1
>>> id(a)
140718917625632

# 将变量b指向对象2,相当于将标签b贴在对象2上
>>> b = 2
>>> id(b)
140718917625664

# 将变量c指向对象1,相当于将标签c贴在对象1上
>>> c = 1
>>> id(c)
140718917625632

# 将变量a指向对象2,相当于将标签a从对象1上撕下来,再贴在对象2上
>>> a = 2
>>> id(a)
140718917625664

上述例子就有如下面贴标签的过程:
在这里插入图片描述

图2(引用自 [1])

 


注释:小整数small_ints和短字符串缓存池

对于整型对象1和2,上述的表达和图示没有什么问题。这里面牵扯到小整数small_ints和短字符串缓存池的概念。

CPython出于性能优化的考虑,会把频繁使用的整数对象用一个small_ints的对象池缓存起来。small_ints缓存的整数值被设定为[-5, 256]这个区间。如果使用CPython解释器,在任何引用这些整数的地方,都不需要重新创建int对象,而是直接引用缓存池中的对象。但是,如果整数不在该范围内,那么即便两个整数的值相同,它们也是不同的对象 [1]

我们来看下面一个例子:

# 1存在于小整数small_ints缓存池
>>> a = 1
>>> id(a)
140718917625632

>>> b = 1
>>> id(b)
140718917625632

# 257不存在于小整数small_ints缓存池
>>> c = 257
>>> id(c)
3035567082896

>>> d = 257
>>> id(d)
3035567083120

类似小整数缓存池,Python中也存在短字符串缓存池,只是规则更加复杂,详情请参考参考文献 [2] [3]。我们直接看下面一个例子:

>>> str1 = 'python'
>>> id(str1)
3035565921072

>>> str2 = 'python'
>>> id(str2)
3035565921072

>>> str3 = 'I love python'
>>> id(str3)
3035567113776

>>> str4 = 'I love python'
>>> id(str4)
3035567113264

综上所述,对于整型对象1和2,上述的表达和图示没有什么问题。但是更一般地,简单引用会创建新的对象,即使这些对象的值相同


(2) 共享引用
例如:

a = 3
b = a

变量a指向对象3,b = a 赋值引用传递对象3的内存地址,于是变量b也指向对象3。变量a和变量b指向同一个对象3。

我们还是以图2为例,严格意义上图2所图示的过程更像下面代码执行的操作

# 创建变量a和对象1,并将变量a指向对象1,相当于将标签a贴在对象1上
>>> a = 1
>>> id(a)
140718917625632

# 创建变量b和对象2,并将变量b指向对象2,相当于将标签b贴在对象2上
>>> b = 2
>>> id(b)
140718917625664

# 通过赋值操作,将变量c指向变量a指向的对象1,相当于将标签c贴在对象1上
>>> c = a
>>> c
1
>>> id(c)
140718917625632

# 通过赋值操作,将变量a指向变量b指向的对象2,相当于将标签a从对象1上撕下来,重新贴在对象2上
>>> a = b
>>> a
2
>>> id(a)
140718917625664

注释:简单引用和共享引用的区别和联系

我们直接来看下面的例子:

# 创建对象257及变量a,将变量a指向对象257
>>> a = 257
>>> id(a)
3035567081648

# 创建新的对象257及变量b,将变量b指向新的对象257
>>> b = 257
>>> id(b)
3035567082288

# 创建新的对象c,并将变量c指向变量a所指向的对象
>>> c = a
>>> c
257
>>> id(c)
3035567081648

可以发现,简单引用会创建新的对象(除诸如小整数等特殊情况之外),而共享引用则不会


2. 不可变数据类型和可变数据类型

在正式讨论Python中的赋值、浅拷贝与深拷贝之前,我们再来了解一下Python中的不可变数据类型和可变数据类型。

2.1 不可变数据类型和可变数据类型的定义

不可变数据类型:当该数据类型对应变量的值发生了改变,那么它对应的内存地址也会发生改变,对于这种数据类型,就称不可变数据类型 [4]Python中的不可变数据类型包括数字(Number)、字符串(String)、元组(Tuple)三类

可变数据类型:当该数据类型对应变量的值发生了改变,那么它对应的内存地址不发生改变,对于这种数据类型,就称可变数据类型 [4]Python中的可变数据类型包括列表(List)、字典(Dictionary)、集合(Set)三类

“不可变数据类型…改变”,“可变数据类型…不改变”,还是比较绕的哈。更直观的解释如下 [5]

不可变数据类型:不可变数据类型/对象不允许对自身内容进行原地(in-place)修改
可变数据类型:可变数据类型/对象则可以对自身内容进行原地修改

我们来看下面一个例子:

# 不可变数据类型
>>> a = 1 # 将变量a指向1所在的内存地址
>>> id(a)
140711971333920
## 改变变量a的值
>>> a += 1 # 尝试将变量a指向的对象值变为2
>>> a
2
>>> id(a)
140711971333952

# 可变数据类型
>>> list1 = [0,1,2]
>>> id(list1)
1521003752256
# 改变变量list1的值
>>> list1.append(3)
>>> list1
[0, 1, 2, 3]
>>> id(list1) # 变量list1指向的内存地址不变,只是该内存地址所存放的列表增加了一位元素
1521003752256

我们结合上面的例子,更通俗地解释一下不可变数据类型和可变数据类型。

变量a一开始指向了整型对象1。此时,如果我们尝试想将变量a指向的对象值变为2(a += 1),我们打印前后两次变量a指向的对象的标识可以发现,变量a前后两次指向的对象发生了改变。这是因为整型1是不可变数据类型,我们无法将该内存地址存放的1直接修改为2,所以程序只能创建新的整型对象2,并将变量a重新指向整型对象2。因此我们可以发现,变量a前后指向的内存地址发生了变化。这就是所谓的不可变数据类型。

而变量list1一开始指向了列表对象[0,1,2]。此时,如果我们想将变量list1指向的对象值变为[0,1,2,3],因为列表[0,1,2]是可变数据类型,所以我们可以直接对其进行修改,而不用创建新的列表对象[0,1,2,3]。因此我们可以发现,变量list1前后指向的内存地址没有发生改变。这就是所谓的可变数据类型。

2.2 不可变数据类型和可变数据类型的共享引用

不可变数据类型的共享引用当多个变量共同引用同一对象时,若其中某个变量改变引用,则会创建新对象,建立新引用,而其他变量引用的对象不变。

可变数据类型的共享引用当多个变量共同引用同一对象时,若其中某个变量原地修改了对象值,则其他变量引用的对象值也发生改变(因为多个变量仍然引用同一个对象)。

不难发现,问题的核心是某个变量改变引用后,多个变量是否还是指向同一个对象

我们来看下面一个例子:

# 不可变数据类型的共享引用
>>> a = 1 # 变量a引用对象1
>>> id(a) # 变量a指向的地址
140723269805856
>>> b = a # 变量a和变量b引用同一对象1
>>> id(b)
140723269805856
>>> b = 2 # 变量b改变引用
>>> id(b)
140723269805888 # 创建了新对象2,并且变量b指向了新创建的对象2
>>> id(a) # 变量a仍然指向原来的地址,因此引用的对象不变
140723269805856

# 可变数据类型的共享引用
>>> list1 = [0,1,2] # 变量list1引用对象[0,1,2]
>>> id(list1) # 变量list1指向的地址
1460375944320
>>> list2 = list1 # 变量list2和变量list1引用同一对象[0,1,2]
>>> id(list2)
1460375944320
>>> list2.append(3) # 原地修改变量list2引用对象的值
>>> list1 # 变量list1引用对象的值也发生了改变
[0, 1, 2, 3]
>>> id(list1) # 变量list1指向的地址不变
1460375944320
>>> list2 # 变量list2引用对象的值发生了改变
[0, 1, 2, 3]
>>> id(list2) # 变量list2指向的地址也不变
1460375944320

注释:原地修改不等于重新赋值

  • 原地修改:可变对象支持原地修改,因此原变量指向的内存地址不变
  • 重新赋值:都会新建不可变对象或可变对象,因此原变量指向的内存地址改变

我们来看下面一个例子:

>>> list1 = [0,1,2]
>>> id(list1)
1460371109248
>>> list2 = list1
>>> list2
[0, 1, 2]
>>> id(list2)
1460371109248
>>> list2 = [0,1,2,3]
>>> id(list2)
1460375949504
>>> id(list1)
1460371109248

3. 赋值、浅拷贝与深拷贝

接下来,我们用上面的知识来深入理解Python中的赋值、浅拷贝与深拷贝,以及它们之间的区别与联系 [6] [7]

赋值:对象的引用,父对象、子对象均为引用。

浅拷贝(Shallow Copy):拷贝父对象,不会拷贝对象内部的子对象,即创建新的父对象(父对象内存地址不同,因此是不同对象),而只引用子对象(子对象内存地址相同,因此是同一对象)。copy.copy()。

深拷贝(Deep Copy): 完全拷贝了父对象及其子对象。copy.deepcopy()。

明白了Python对象及其引用,以及不可变数据类型和可变数据类型的相关内容后,也就明白了赋值、浅拷贝与深拷贝的区别和联系。我们还是来看具体的例子:

>>> list1 = [0,[1,2],257]
>>> list2 = list1 # 赋值
>>> list3 = list1.copy() # 浅拷贝
>>> list4 = copy.deepcopy(list1) # 深拷贝

>>> print(f'id(list1)={id(list1)} \n',f'id(list1[0])={id(list1[0])} \n',f'id(list1[1])={id(list1[1])} \n',f'id(list1[2])={id(list1[2])}')
 id(list1)=2375346448576 # 父对象内存地址
 id(list1[0])=140723230549760 
 id(list1[1])=2375346446848 # 子对象内存地址
 id(list1[2])=2375346512080

>>> print(f'id(list2)={id(list2)} \n',f'id(list2[0])={id(list2[0])} \n',f'id(list2[1])={id(list2[1])} \n',f'id(list2[2])={id(list2[2])}')
 id(list2)=2375346448576 # 父对象内存地址相同,因此是同一对象
 id(list2[0])=140723230549760 
 id(list2[1])=2375346446848 # 子对象内存地址相同,因此是同一对象
 id(list2[2])=2375346512080
 
>>> print(f'id(list3)={id(list3)} \n',f'id(list3[0])={id(list3[0])} \n',f'id(list3[1])={id(list3[1])} \n',f'id(list3[2])={id(list3[2])}')
 id(list3)=2375339936704 # 父对象内存地址不同,因此是不同对象
 id(list3[0])=140723230549760 
 id(list3[1])=2375346446848 # 子对象内存地址相同,因此是同一对象
 id(list3[2])=2375346512080

>>> print(f'id(list4)={id(list4)} \n',f'id(list4[0])={id(list4[0])} \n',f'id(list4[1])={id(list4[1])} \n',f'id(list4[2])={id(list4[2])}')
 id(list4)=2375339935680 # 父对象内存地址不同,因此是不同对象
 id(list4[0])=140723230549760 
 id(list4[1])=2375339934336 # 子对象内存地址不同,因此是不同对象
 id(list4[2])=2375346512080

我们循着上面的例子继续往下:

>>> list1
[0, [1, 2], 257]
>>> list2
[0, [1, 2], 257]
>>> list3
[0, [1, 2], 257]
>>> list4
[0, [1, 2], 257]

>>> list1[0] = 257
>>> list1[1][0] = -1
>>> list1[2] = 0

>>> list1
[257, [-1, 2], 0]
>>> print(f'id(list1)={id(list1)} \n',f'id(list1[0])={id(list1[0])} \n',f'id(list1[1])={id(list1[1])} \n',f'id(list1[2])={id(list1[2])}')
 id(list1)=2375346448576 # 父对象内存地址不变,即变量list1仍指向原来的对象,因为父对象是可变对象
 id(list1[0])=2375346512368 # 子对象内存地址改变,即变量list1[0]指向了新创建的对象257,因为原子对象0是不可变对象
 id(list1[1])=2375346446848 # 子对象内存地址不变,即变量list1[1]仍指向原来的对象,因为子对象是可变对象
 id(list1[2])=140723230549760 # 子对象内存地址改变,即变量list1[2]指向了新的对象0,因为原子对象257是不可变对象。但是需要注意的是,因为0是小整数,所以这里并没有创建新的对象0,而是将变量list1[2]指向了小整数缓存池里已存在的对象0

>>> list2 # 因为变量list2是变量list1的直接赋值引用,所以变量list2和变量list1指向的对象完全一样,任何一方的改变都会引起另外一方的改变
[257, [-1, 2], 0]
>>> print(f'id(list2)={id(list2)} \n',f'id(list2[0])={id(list2[0])} \n',f'id(list2[1])={id(list2[1])} \n',f'id(list2[2])={id(list2[2])}')
 id(list2)=2375346448576 
 id(list2[0])=2375346512368 
 id(list2[1])=2375346446848 
 id(list2[2])=140723230549760

>>> list3
[0, [-1, 2], 257]
>>> print(f'id(list3)={id(list3)} \n',f'id(list3[0])={id(list3[0])} \n',f'id(list3[1])={id(list3[1])} \n',f'id(list3[2])={id(list3[2])}')
id(list3)=2375339936704 # 父对象内存地址不变,即变量list3仍指向原来的对象,因为父对象是可变对象。而且因为变量list1和变量list3指向的是不同的对象,所以变量list1对对象造成的改变不会影响到变量list3指向的对象
 id(list3[0])=140723230549760 
 id(list3[1])=2375346446848 # 因为变量list1[1]和变量list3[1]指向的是同一对象,因此变量list1[1]对对象造成的改变会影响到变量list3[1]
 id(list3[2])=2375346512080

>>> list4 # 变量list4指向的父对象和子对象与变量list1指向的父对象和子对象完全不同,因此变量list1对对象的任何改变都不会影响到变量list4
[0, [1, 2], 257]
>>> print(f'id(list4)={id(list4)} \n',f'id(list4[0])={id(list4[0])} \n',f'id(list4[1])={id(list4[1])} \n',f'id(list4[2])={id(list4[2])}')
 id(list4)=2375339935680 
 id(list4[0])=140723230549760 
 id(list4[1])=2375339934336 
 id(list4[2])=2375346512080

4. Numpy ndarray的赋值、浅拷贝与深拷贝

最后,我们再来看一下Numpy中ndarray数组对象的赋值、浅拷贝与深拷贝。为什么单独拎出来说?一是因为Numpy非常重要,二是因为Numpy中ndarray数组对象的赋值、浅拷贝与深拷贝与上面所说的有点不一样。

赋值:同上。只是对象的引用,完全不拷贝。

浅拷贝 [8] [9] [10] [11]

  • Numpy中,浅拷贝也叫视图(View),其对应的ndarray对象方法为ndarray.view()。
  • ndarray.view() 会创建一个新的ndarray对象作为原始数据的视图,但不会拷贝原始数据,数据物理地址相同。
  • 视图是数据的一个别称或引用,通过该别称或引用亦可访问、修改原始数据。如果我们对视图进行修改,它会影响到原始数据。
  • 对视图改变维数不会改变原始数据的维数,因此也不会改变同一原始数据其他视图的维数。这得益于ndarray数据存储与其解释方式的分离 [11]

深拷贝 [8] [9] [10] [11]

  • Numpy中,深拷贝直接也叫拷贝(Copy),其对应的ndarray对象方法为ndarray.copy()。
  • ndarray.copy() 会创建一个副本。 对副本数据进行修改,不会影响到原始数据,它们物理内存不在同一位置。

ndarray数据存储与其解释方式分离的设计哲学使其与一般的Python对象拥有不同的浅拷贝和深拷贝操作。在判断“变与不变”时,抓住数据的物理地址内存是否相同这一核心即可。我们来看下面一个例子:

>>> arr1 = np.array([0,1,2,3,4,5])
>>> arr2 = arr1 # 赋值
>>> arr3 = arr1.view() # 浅拷贝或视图
>>> arr4 = arr1.copy() # 深拷贝或拷贝

>>> id(arr1)
2055262999936
>>> id(arr2) # 赋值并没有创建新的ndarray对象
2055262999936
>>> id(arr3) # ndarray.view创建了新的ndarray对象
2055262999856
>>> id(arr4) # ndarray.copy创建了新的ndarray对象
2056212224800

>>> arr1.ctypes.data
2056207123952
>>> arr2.ctypes.data
2056207123952
>>> arr3.ctypes.data # ndarray.view虽然创建了新的ndarray对象,但是数据物理地址相同,即还是共享同一块数据block
2056207123952
>>> arr4.ctypes.data # ndarray.copy创建了新的ndarray对象,而且数据物理地址不同,因此不会共享同一块数据block
2056207123568

>>> arr1.shape = 2,3 # 修改ndarray维度
>>> arr1
array([[0, 1, 2],
       [3, 4, 5]])
>>> arr2
array([[0, 1, 2],
       [3, 4, 5]])
>>> arr3 # 不影响视图ndarray的维度
array([0, 1, 2, 3, 4, 5])
>>> arr4 # 更加不影响视图ndarray的维度
array([0, 1, 2, 3, 4, 5])

>>> arr1[1][2]=25
>>> arr1
array([[ 0,  1,  2],
       [ 3,  4, 25]])
>>> arr2 # arr2和arr1指向对象的物理内存地址相同,共享同一数据block,因此arr2也改变了
array([[ 0,  1,  2],
       [ 3,  4, 25]])
>>> arr3 # 虽然arr3和arr2指向不同的对象,但是它们的物理内存地址相同,共享同一数据block,因此arr2也改变了
array([ 0,  1,  2,  3,  4, 25])
>>> arr4 # arr4指向对象和arr1指向对象不共享数据block,因此arr4不会改变
array([0, 1, 2, 3, 4, 5])

后记

原以为表面很简单的概念,没想到背后蕴含这么多的知识。即使洋洋洒洒写了数千字,有些问题还是无法弄明白。真是“纸上得来终觉浅,绝知此事要躬行”,古人诚不欺我。另外,做人还是要低调,做事更要踏实,做学问更要务实。原本想给本篇取个博人眼球的标题,例如“一文彻底搞懂。。。”,最后还是灰溜溜地去掉了。

Reference

[1]: https://zhuanlan.zhihu.com/p/331732504
[2]: https://blog.csdn.net/shen_chengfeng/article/details/80700368?utm_medium=distribute.pc_relevant.none-task-blog-2%7Edefault%7EBlogCommendFromMachineLearnPai2%7Edefault-1.control&depth_1-utm_source=distribute.pc_relevant.none-task-blog-2%7Edefault%7EBlogCommendFromMachineLearnPai2%7Edefault-1.control
[3]: https://www.cnblogs.com/suxianglun/p/10869761.html
[4]: https://www.cnblogs.com/operationhome/p/9642460.html
[5]: https://www.zhihu.com/question/265400676
[6]: https://www.runoob.com/w3cnote/python-understanding-dict-copy-shallow-or-deep.html
[7]: https://zhuanlan.zhihu.com/p/335266078
[8]: https://numpy.org/doc/stable/user/quickstart.html
[9]: https://www.runoob.com/numpy/numpy-copies-and-views.html
[10]: https://blog.csdn.net/xidianliutingting/article/details/51682867
[11]: https://blog.csdn.net/blogshinelee/article/details/104256244
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值