从一个小秘密说起

你是一个寂寞的人。

但是,你有一笔宝贵的财富,它一直是你的小秘密。

你拥有一个绝密的联系册,里面记录了好多小姐姐的联系方式。它是一个列表,以这样的形式存在。

1
girls_contacts = ["Jasmine","Vanilla","Moonlight"]

它一直是你心底的秘密,直到…

糟糕,秘密被发现了

你身边的渣男突击你的宿舍,发现了你的小秘密。

“哈哈!这么多女孩子啊,搞快点,把它们的联系方式给我!“

你是一个正义的人。你知道,当你把这份名单给他的时候,那些小姐姐一定会被这个渣男伤害。为了保护她们,你想尽可能不让他拷贝你的数据(名单)。

最浅的拷贝——引用

但是一开始,你使用了一种最最浅的拷贝,浅到以至于不能被称作是拷贝。

1
contacts_for_zha_nan = girls_contacts

这种复制是什么意思呢?相当于,每次渣男来找你的时候,他都是去询问你,“喂,给我康康那些小姐姐的联系方式!”。也就是说,实际上不存在两份通讯录,他的小姐姐的联系方式直接链接到你的联系方式上的。如果你学过C语言的话,你可以把这个渣男的联系方式当作一个指针。换句话说,在程序里,变量contacts_for_zha_nan 是一个快捷方式,直接链接到你的小姐姐的联系册。

这样子的后果是什么呢?最显而易见的好处是省空间,尤其是你的联系册特别大的情况下。坏处是什么呢?因为你们俩共享的其实是一份联系册,所以如果你的联系册修改了,渣男也一并会看到,你就无法保护好你的小姐姐了。

举例来说,如果在现在的情况下,你修改你的联系册,增加一个小姐姐。

1
girls_contacts.append("Sugar")

而这时候,如果你查看渣男的联系册和你的联系册——

1
2
3
4
>>> girls_contacts
["Jasmine","Vanilla","Moonlight","Sugar"]
>>> contacts_for_zha_nan
["Jasmine","Vanilla","Moonlight","Sugar"]

没错,它们是同样的。

稍微深一点——浅拷贝

这样怎么保护好新的小姐姐!不可以!我们需要另外的拷贝方式!

正好,Python的列表有一个函数,叫.copy(),它执行的是真正的拷贝,换言之,新列表和旧列表是两份列表。对于我们的例子来说,就是渣男把你的联系册手抄了一份。可以这么写

1
contacts_for_zha_nan = girls_contacts.copy()

哎嘿,这样子的后果是什么呢?最显而易见的好处是隔离开来了,你和他是两份联系册,互相修改互不影响。我们可以这样测试。给你的联系册上加一个人,看看他的联系册上还有没有?

1
girls_contacts.append("Sugar")

再看看两个人的联系册

1
2
3
4
>>> girls_contacts
["Jasmine","Vanilla","Moonlight","Sugar"]
>>> contacts_for_zha_nan
["Jasmine","Vanilla","Moonlight"]

好!不会互相影响啦!

深入一点说,在不用copy的情况下,你和渣男的女生联系册都指向的是同一片内存区域,而现在,你和渣男的女生联系册指向的是两片内存区域。

补充一下 Copy-on-Write 的知识,这个术语叫写时复制,广泛用于操作系统当中,来提高执行效率。你可以理解为它是一个智能的工具,来判断你和渣难的联系册要不要分开。具体来说,在我们复制或者进程复制(fork)的时候,它们都被指向了同一片区域(即共享联系册)。如果这个新进程仅仅只是“读”,不需要“写”。那么这样的共享是没问题的,而且还节省了很大的空间和时间开销(overhead)。因为很显然,都是读内容的话,复制不复制是没有什么区别的。又不需要写,复制不复制有什么本质的关系吗?没有。但是,当这个进程开始尝试对数据进行“写”操作的时候,这时候就要复制原来进程的内存空间,并在新的内存空间上写,以达到两个进程的数据隔离。更微妙的是,它们复制的还不一定是原进程的所有进程,操作系统会采用一种分页机制来优化(这点不深入了)。

最深的拷贝——深拷贝

你以为这样就完了?太小瞧渣男了!真正的难题现在才开始。

我们知道,列表有一种类似“套娃”的机制,即列表嵌列表,二层列表乃至三层列表等。用我们的例子来说,可以当作是,你的联系册上的小姐姐,她又认识其他小姐姐,就间接扩大了联系方式。比如,我们的联系册可以是

1
girls_contacts = ["Jasmine","Vanilla",["Moonlight","Sugar"]]

即列表的第2项也是一个拥有两项的列表。即联系册的第二个人又认识两个小姐姐。如果我们的渣男想要联系册,动用我们之前学的.copy()函数。

1
contacts_for_zha_nan = girls_contacts.copy()

这时候,如果你对你的联系册新加一个人,那么渣男的联系册是看不到的,即我们上一章讲的例子。可是,如果我们修改的是那个被套娃的联系人呢?比如说,我们认识的联系人小姐姐又认识了新的小姐姐。

1
girls_contacts[2].append("Ocean")

这时候我们查看我们的联系册和渣男的联系册。

1
2
3
4
>>> girls_contacts
["Jasmine","Vanilla",["Moonlight","Sugar","Ocean"]]
>>> contacts_for_zha_nan
["Jasmine","Vanilla",["Moonlight","Sugar","Ocean"]]

啊?

不是说好了复制了吗?不是说好了两个联系册独立了吗,为什么会这样?

因为在Python里,当你选择.copy()的时候,它是会逐一复制列表的每一项,而列表的第二项其实是一个指针或者说快捷方式,链接到的内存区域是一个列表。用我们的例子来说,也就是说,copy过后,我们有两个联系册,而联系册的第二项都是“去找那个小姐姐问问她的闺蜜”,即指向了这个小姐姐的闺蜜们。所以如果那个小姐姐的闺蜜多了一个人,你和渣男询问这个小姐姐得到的结果都是一样的。换言之,只有第一层是独立的,剩下的内层都是共享的。

如何规避这种情况呢?如果我们想要任意层套娃都能做到完美的复制,完美地隔离,该怎么办呢?

方法是有的,那就是深拷贝。Python里有个函数叫deepcopy(需要导入),又叫深拷贝,干的就是这种活。它会把所有内容都复制一遍,不管多少层。说精确点就是把整个内存空间复制一份,所有的链接或者快捷方式都走到底,而且无关数据类型,什么都可以复制。例如,我们可以这么搞。

1
2
3
4
from copy import deepcopy
girls_contacts = ["Jasmine","Vanilla",["Moonlight","Sugar"]]
contacts_for_zha_nan = deepcopy(girls_contacts)
girls_contacts[2].append("Ocean")

这时候我们再对比两个人的通讯录。

1
2
3
4
>>> girls_contacts
["Jasmine","Vanilla",["Moonlight","Sugar","Ocean"]]
>>> contacts_for_zha_nan
["Jasmine","Vanilla",["Moonlight","Sugar"]]

好!这下你和渣男的通讯录完完全全隔离了!你们就像两个平行宇宙,互相不干扰。

这里再提一下内存池的缓冲机制。你可能会好奇,为什么在浅拷贝的时候,列表会作为指针的形式复制,而数字就不会呢?这就涉及到了内存池的缓冲机制概念。说人话就是,如果是小东西(数字,短的字符串),那么复制的时候就是独立形式存在的,如果是大东西(列表,字典,自定义类的对象),就会复制一个指针。至于多小算大,多大算小(多大的小姐姐联系方式才会被共享)不妨自己查一下?

总结

好了,朋友们,让我们总结一下,三种复制方法。

1
2
3
b = a                    # 是一个引用,a和b是同样的东西
b = a.copy() # 浅拷贝,a的第一层和b的第一层是独立的,深层都是共享的
b = deepcopy(a) # 深拷贝,b的每一项深挖到底都是和a在地址上是不同的,独立的

题外话,现实中写代码还是要避免太套娃的层次,会使代码可读性很差,同时使得要拷贝的话得非常谨慎。但是套娃也是很正常的形式,不需要畏惧和抵触。就像生活中,你舔的女神可能也是别的男生的舔狗,而你不知道。(这都是什么比喻)

本文中的例子全部为虚构,仅仅为了便于理解。

回到现实生活中,让我看看我的通讯录。

1
real_world_girls_contacts = []

生活真美好啊!(苦笑)