深入理解Python的对象拷贝和内存布局输出的是程序片段吗?a=[1,2,3,4]b=aprint(f"{a=}\t|\t{b=}")a[0]=100print(f"{a=}\t|\t{b=}")a=[1,2,3,4]b=a.copy()print(f"{a=}\t|\t{b=}")a[0]=100print(f"{a=}\t|\t{b=}")a=[[1,2,3],2,3,4]b=a.copy()print(f"{a=}\t|\t{b=}")a[0][0]=100print(f"{a=}\t|\t{b=}")a=[[1,2,3],2,3,4]b=copy.copy(a)print(f"{a=}\t|\t{b=}")a[0][0]=100print(f"{a=}\t|\t{b=}")a=[[1,2,3],2,3,4]b=copy.deepcopy(a)print(f"{a=}\t|\t{b=}")a[0][0]=100print(f"{a=}\t|\t{b=}")在本文中,我们将详细分析上述程序。Python对象的内存布局首先介绍一个比较有用的关于数据在内存中的逻辑分布的网站,https://pythontutor.com/visua...我们在这个网站上运行第一段代码:从上面的输出结果,a和b指向同一内存中的数据对象。所以第一个代码的输出是一样的。我们应该如何确定一个对象的内存地址呢?Python中提供了一个内置函数id()来让我们获取一个对象的内存地址:a=[1,2,3,4]b=aprint(f"{a=}\t|\t{b=}")a[0]=100print(f"{a=}\t|\t{b=}")print(f"{id(a)=}\t|\t{id(b)=}")#输出结果#a=[1,2,3,4]|b=[1,2,3,4]#a=[100,2,3,4]|b=[100,2,3,4]#id(a)=4393578112|id(b)=4393578112其实上面的对象内存布局有点问题,或者说不够准确,但是也能表现出对象之间的关系是的,我们现在深入了解一下。在Cpython中,你可以把每一个变量看成一个指针,指向所代表的数据,这个指针存放的是Python对象的内存地址。在Python中,列表实际上包含指向每个Python对象的指针,而不是实际数据。因此,上面的一小段代码可以用下图来表示对象在内存中的布局:变量a指向内存中的列表[1,2,3,4],列表中有4条数据,这些四个数据是指针,这四个指针指向内存中的1、2、3、4这四个数据。你可能会有疑问,这不是问题吗?都是整型数据,为什么不直接把整型数据存入list,为什么还要加一个指针,然后指向这个数据呢?事实上,在Python中,任何Python对象都可以存储在列表中。例如下面的程序是合法的:data=[1,{1:2,3:4},{'a',1,2,25.0},(1,2,3),"helloworld"]的上面列表中从第一个到最后一个数据的数据类型分别是:整型数据、字典、集合、元组、字符串,现在我们来看这个为了实现Python的特性,指针的特性是否符合要求?每个指针占用的内存都是一样的,所以可以用一个数组来存放指向Python对象的指针,然后将这个指针指向一个真正的Python对象!经过上面的分析,我们来看看下面这段代码,它的内存布局是怎样的:data=[[1,2,3],4,5,6]data_assign=datadata_copy=data.copy()data_assign=data,之前我们讲过这个赋值语句的内存布局,现在也在复习,这个赋值语句的意思是data_assign和data指向的数据是同一个数据,也就是同一个list。data_copy=data.copy(),这个赋值语句的意思是对data指向的数据做一个浅拷贝,然后让data_copy指向拷贝后的数据。这里浅拷贝的意思是复制链表中的每一个指针,而不复制链表中指针指向的数据。从上面对象的内存布局图可以看出data_copy指向了一个新的list,但是list中指针指向的数据和datalist中指针指向的数据是一样的,其中data_copy用绿色箭头表示,data用黑色箭头表示。查看对象的内存地址在上一篇文章中,我们主要分析了对象的内存布局。在本节中,我们使用python为我们提供了一个非常有效的工具来验证这一点。在python中我们可以通过id()查看对象的内存地址,id(a)是查看对象a指向的对象的内存地址。查看以下程序的输出:a=[1,2,3]b=aprint(f"{id(a)=}{id(b)=}")foriinrange(len(a)):print(f"{i=}{id(a[i])=}{id(b[i])=}")根据我们前面的分析,a和b指向同一块内存,即也就是说,两个变量指向同一个Python对象,所以上面多路输出的id结果a和b是一样的,上面输出结果如下:id(a)=4392953984id(b)=4392953984i=0id(a[i])=4312613104id(b[i])=4312613104i=1id(a[i])=4312613136id(b[i])=4312613136i=2id(a[i])=4312613168id(b[i]])=4312613168查看浅拷贝的内存地址:a=[[1,2,3],4,5]b=a.copy()print(f"{id(a)=}{id(b)=}")foriinrange(len(a)):print(f"{i=}{id(a[i])=}{id(b[i])=}")根据我们前面的分析,调用list本身的copy方法就是对list进行浅拷贝,只拷贝list的指针数据,t中指针指向的真实数据list是没有复制的,所以如果我们遍历list中的数据,得到指向对象的地址,此时lista和listb返回的结果是一样的,但是和前面的例子不同的是a和b指向的链表地址不同(因为数据是复制的,可以参考下面浅拷贝的结果来理解)。结合上面的文字可以理解如下输出结果:id(a)=4392953984id(b)=4393050112#两个对象的输出结果不相等i=0id(a[i])=4393045632id(b[i])=4393045632#指向同一个内存对象,所以内存地址相等i=1id(a[i])=4312613200id(b[i])=4312613200i=2id(a[i])=4312613232id(b[i])=4312613232copy模块在python中有一个内置的包copy,主要用于复制对象。在这个模块中,主要有两个方法copy.copy(x)和copy.deepcopy()。copy.copy(x)方法主要用于浅拷贝。这个方法对于列表的意义和列表本身的x.copy()方法是一样的,都是进行浅拷贝。此方法将构造一个新的python对象并复制对象x中的所有数据引用(指针)。copy.deepcopy(x)这个方法主要是对对象x进行深拷贝。这里的深拷贝的意思是构造一个新的对象,递归查看对象x中的每一个对象。如果递归查看的对象是一个Immutable对象将不会被复制。如果查看的对象是可变对象,则重新开辟一块新的内存空间,将对象x中的原始数据复制到新的内存中。(下一节我们会仔细分析可变对象和不可变对象。)根据上面的分析,我们可以知道深拷贝的开销比浅拷贝要大,尤其是当一个对象中有很多子对象时,这将花费大量时间和内存空间。对于python对象,深拷贝和浅拷贝的区别主要在于复合对象(有子对象的对象,比如列表、祖先、类的实例等)。这一点主要和下一节的可变对象和不可变对象有关。可变和不可变对象以及对象复制python中主要有两种对象,可变对象和不可变对象。所谓可变对象就是对象的内容可以改变,不可变对象就是对象的内容不能改变。.可变对象:如列表(list)、字典(dict)、集合(set)、字节数组(bytearray)、类的实例对象。不可变对象:整数(int)、浮点数(float)、复数(complex)、字符串、元组、不可变集合(frozenset)、字节(bytes)。看到这里,你可能会有疑惑,整数和字符串不能修改吗?a=10a=100a="hello"a="world"比如下面的代码是正确的,不会出错,但实际上a指向的对象发生了变化。第一个对象指向一个整数或者字符串时,如果你重新赋值一个新的不同的整数或者字符串对象,python会创建一个新的对象,我们可以用下面的代码来验证:a=10print(f"{id(a)=}")a=100print(f"{id(a)=}")a="你好"print(f"{id(a)=}")a="world"print(f"{id(a)=}")上述程序输出如下:id(a)=4365566480id(a)=4365569360id(a)=4424109232id(a)=4616350128可以看出,变量点to重新赋值后内存对象发生了变化(因为内存地址发生了变化),这是一个不可变对象,虽然变量可以重新赋值,但是得到的新对象并没有在原对象上进行修改!我们现在看一下变量对象列表修改后内存地址是如何变化的:data=[]print(f"{id(data)=}")data.append(1)print(f"{id(data)=}")data.append(1)print(f"{id(data)=}")data.append(1)print(f"{id(data)=}")data.append(1)打印(f"{id(data)=}")上面代码的输出如下:id(data)=4614905664id(data)=4614905664id(data)=4614905664id(data)=4614905664id(data)=4614905664来自上面从输出结果我们可以知道,当我们添加到列表中时新增数据后(列表被修改),列表本身的地址没有改变,是一个可变对象前面讲了深拷贝和浅拷贝,现在分析下面这段代码:data=[1,2,3]data_copy=copy.copy(data)data_deep=copy.deepcopy(data)print(f"{id(data)=}|{id(data_copy)=}|{id(data_deep)=}")print(f"{id(data[0])=}|{id(data_copy[0])=}|{id(data_deep[0])=}")print(f"{id(data[1])=}|{id(data_copy[1])=}|{id(data_deep[1])=}")print(f"{id(data[2])=}|{id(data_copy[2])=}|{id(data_deep[2])=}")上述代码的输出结果如下:id(data)=4620333952|ID(数据副本)=4619860736|id(data_deep)=4621137024id(data[0])=4365566192|id(data_copy[0])=4365566192|)=4365566224|id(data_copy[1])=4365566224|id(data_deep[1])=4365566224id(data[2])=4365566256|id(data_copy[2])=4365566256|id(data_deep[2])=4365566256看到这里你肯定会很疑惑,为什么深拷贝和浅拷贝指向的内存对象一样?上一节我们可以理解,因为浅拷贝复制的是引用,所以指向的对象是一样的,但是为什么深拷贝之后指向的内存对象和浅拷贝是一样的呢?这正是因为列表中的数据是整型数据,是一个不可变对象。如果data或data_copy所指向的对象被修改,它将指向一个新的对象不直接修改原对象,所以不可变对象的重新赋值不需要开辟新的内存空间,因为这块内存中的对象是不会改变的。让我们看一个可复制的对象:data=[[1],[2],[3]]data_copy=copy.copy(data)data_deep=copy.deepcopy(data)print(f"{id(data)=}|{id(data_copy)=}|{id(data_deep)=}")print(f"{id(data[0])=}|{id(data_copy[0])=}|{id(data_deep[0])=}")print(f"{id(data[1])=}|{id(data_copy[1])=}|{id(data_deep[1])=}")print(f"{id(data[2])=}|{id(data_copy[2])=}|{id(data_deep[2])=}")上述代码的输出结果如下:id(data)=4619403712|id(data_copy)=4617239424|id(data_deep)=4620032640id(data[0])=4620112640|id(data_copy[0])=4620112640|id(data_deep[0])=4620333952id(data[1])=4619848128|id(data_copy[1])=4619848128|id(data_deep[1])=4621272448id(data[2])=4620473280|id(data_copy[2])=4620473280|id(data_deep[2])=4621275840从上面程序的输出我们可以看出,当列表中存储了一个可变对象时,如果我们进行深拷贝,将会创建一个全新的对象(对象内存深拷贝的地址与浅拷贝的地址不同)。代码片段分析经过上面的学习,本文开头提出的问题对你来说应该很简单了。现在让我们分析这些代码片段:a=[1,2,3,4]b=aprint(f"{a=}\t|\t{b=}")a[0]=100print(f"{a=}\t|\t{b=}")这个很简单,a和b的不同变量指向同一个列表,如果a中的数据发生变化,b中的数据也会发生变化,输出为如下:a=[1,2,3,4]|b=[1,2,3,4]a=[100,2,3,4]|b=[100,2,3,4]id(a)=4614458816|id(b)=4614458816我们来看第二个代码片段a=[1,2,3,4]b=a.copy()print(f"{a=}\t|\t{b=}")a[0]=100print(f"{a=}\t|\t{b=}")因为b是a的浅拷贝,所以a和b指向不同的列表,但是列表中的数据指向一样,但是因为整型数据是不可变数据,当a[0]改变时,不会修改原来的数据,而是会在内存中创建一个新的整型数据,所以listb的内容不会改变。所以上面代码的输出看起来像这样:a=[1,2,3,4]|b=[1,2,3,4]a=[100,2,3,4]|b=[1,2,3,4]再看第三个片段:a=[[1,2,3],2,3,4]b=a.copy()print(f"{a=}\t|\t{b=}")a[0][0]=100print(f"{a=}\t|\t{b=}")这与第二个片段的分析类似,但是a[0]是一个可变对象,所以当数据被修改时,a[0]的指向并没有改变,所以a修改的内容会影响b。一=[[1,2,3],2,3,4]|b=[[1,2,3],2,3,4]a=[[100,2,3],2,3,4]|b=[[100,2,3],2,3,4]最后一个片段:a=[[1,2,3],2,3,4]b=copy.deepcopy(a)print(f"{a=}\t|\t{b=}")a[0][0]=100print(f"{a=}\t|\t{b=}")深拷贝会在内存中,其中,重新创建一个和a[0]一样的对象,让b[0]指向这个对象,所以修改a[0]不会影响b[0],所以输出如下:a=[[1,2,3],2,3,4]|b=[[1,2,3],2,3,4]a=[[100,2,3],2,3,4]|b=[[1,2,3],2,3,4]揭开Python对象的神秘面我们简单看一下Cpython是如何实现列表数据结构的,列表中定义了什么:typedefstruct{PyObject_VAR_HEAD/*指向列表元素的指针向量。list[0]是ob_item[0],等等*/PyObject**ob_item;/*ob_item包含“已分配”元素的空间。当前使用的数字*是ob_size。*不变量:*0<=ob_size<=allocated*len(list)==ob_size*ob_item==NULL表示ob_size==allocated==0*list.sort()临时设置alloc设置为-1以检测突变。**Items通常不能为NULL,除非在构造期间*列表在构建它的函数之外尚不可见。*/Py_ssize_t已分配;}PyListObject;上面定义的结构体中:allocated表示分配的内存空间大小,即可以存放的指针个数。当所有空间用完后,内存空间ob_item指向内存中实际存放python对象指针的数组。比如我们要获取list中的第一个对象的指针就是list->ob_item[0],如果要获取真正的数据就是*(list->ob_item[0])。PyObject_VAR_HEAD是一个定义结构中子结构的宏。这个子结构的定义如下:typedefstruct{PyObjectob_base;py_ssize_tob_size;/*变量部分的项目数*/}PyVarObject;这里我们先不说对象PyObject。先说ob_size,意思是list里面存了多少条数据。这与分配不同。allocated表示ob_item指向的数组有多少空间,ob_size表示数组中存放了多少数据ob_size<=allocated。了解了链表的结构之后,我们现在应该能够理解之前的内存布局了。所有列表都不存储真实数据,而是存储指向这些数据的指针。综上所述,本文主要介绍了python中对象的拷贝和内存布局,以及对象内存地址的校验,最后介绍了cpython内部实现链表的结构,帮助大家深入理解链表对象的内存布局。.以上就是本文的全部内容,我是LeHung,我们下期再见!!!更多精彩内容合集可以访问项目:https://github.com/Chang-LeHu...关注公众号:一个没用的研究僧,学习更多计算机知识(Java,Python,计算机系统基础,算法和数据结构)知识。
