目录
2.1.1 变量与对象 (Variables and Objects)
2.1.2 不可变对象 (Immutable Objects)
2.1.4 直接赋值 (Direct Assignment)
2.2 copy.copy() —— 浅拷贝 (Shallow Copy)
2.3 copy.deepcopy() —— 深拷贝 (Deep Copy)
一、绪论
copy 模块 定义了对象拷贝相关方法。有别于使用 等号 “=” 赋值 的操作,copy 模块能够实现对数据对象的深、浅拷贝:
名称 | 功能 |
copy() | 返回数据对象的 浅拷贝 |
deepcopy() | 返回数据对象的 深拷贝 |
以下将结合 Python 等号赋值对比说明 深、浅拷贝 的作用和意义。但此前,须先理清 可变对象 & 不可变对象 的含义与联系。
二、说明
2.1 赋值 (Assignment)
2.1.1 变量与对象 (Variables and Objects)
对象 指的是内存中存储数据的实体,具有明确的类型,在 Python 中一切都是对象,包括函数。
变量 作为对象的 引用/别名,实质保存着所指对象的 内存地址。
知识点:
Python 是一门 动态 (dynamic) 强类型 (strong) 语言。动态 类型语言即 在运行期间才确定数据类型。例如,VBScript 和 Python 是动态类型的,因为它们是 在赋值时确定变量的类型。相反,静态 类型语言 在编译期间就确定数据类型,这类语言大都通过要求 在使用任一变量前声明其数据类型 来确保类型固定,例如 Java 和 C。
>>> x = 666 # 666 是一个对象, 而 x 是指向对象 666 的一个变量, 类型相应为 int 型
>>> x
666
## 变量 x 可以指向任意对象, 而没有类型的前提限制, 因为动态语言变量类型可随着赋值而动态改变
>>> x = '666' # 变量 x 指向新的对象 '666', 类型随之变为 string 型
>>> x
'666'
总之,在 Python 中,类型属于对象,变量本无类型,仅仅是一个对对象的引用。而 变量指向对象的数据类型若发生变化,则变量的类型亦随之改变。而赋值语句改变的是变量所执的对对象的引用,故一个变量可指向各种数据类型的对象。
此外,在 Python 中,从数据类型的角度看,对象可分为 “可变对象” 和 “不可变对象”,常见的内建类型有:
2.1.2 不可变对象 (Immutable Objects)
不可变对象:对象相应内存中的值 不可改变,常见的有 int、float、string、tuple 等类型的对象。因为 Python 中的变量存放的是 对象引用,所以对不可变对象而言,尽管对象本身不可改变,但 变量对对象的引用或指向关系仍是可变的。具体而言,指向原不可变对象的变量被改变为指向新对象时,Python 会开辟一块新的内存区域,并令变量指向这个新内存 (存放新对象引用),因此 变量对对象的引用或指向关系是灵活的、可变的。例如:
i = 73 # 变量 i 指向原不可变对象 73 (变量 i 存放原对象 73 的引用)
i += 2 # 变量 i 指向新对象 75 (变量 i 存放原对象 75 的引用)
综上可知,不可变对象自身并未改变,而是创建了新不可变对象,改变了变量的对象引用。具体而言,原不可变对象 73 内存中的值并未改变,Python 创建了新不可变对象 75,并令变量 i 重新指向新不可变对象 75 / 保存对新对象 75 的引用,并通过 “垃圾回收机制” 回收原对象 73 的内存。
知识点:
- 垃圾回收 (garbage collection) 机制指:对处理完毕后不再需要的堆内存空间的数据对象 (“垃圾”) 进行清理,释放它们所使用的内存空间的过程。例如,C 使用 free() 函数;C++ 使用 delete 运算符;而在 C++ 基础上开发的 C# 和 Java 等,其程序运行环境会自动进行垃圾回收,以避免用户疏忽而忘记释放内存,造成 内存泄露 (memory leaky) 问题。
- Python 通过 引用计数 (Reference Counting) 和一个 能够检测和打破循环引用的循环垃圾回收器 来执行垃圾回收。可用 gc 模块 控制垃圾回收器。具体而言,对每个对象维护一个 ob_refcnt 字段 (对象引用计数器),用于记录该对象当前被引用的次数。每当有新引用指向该对象时,该对象的引用计数 ob_refcnt +1;每当该对象的引用失效时,该对象的引用计数 ob_refcnt -1;一旦对象的引用计数 ob_refcnt = 0,该对象立即被回收,对象占用的内存空间将被自动放入 自由内存空间池,以待后用。
- 这种引用计数垃圾回收机制的 优点 在于,能够自动清理不用的内存空间,甚至能够随意新建对象引用 (不建议) 而无需考虑手动释放内存空间的问题,故相比于 C 或 C++ 这类静态语言更“省心”。
- 这种引用计数垃圾回收机制的 次要缺点 是需要额外空间资源维护引用计数,
主要缺点则是无法解决对象的“循环引用”问题。因此,也有很多语言如 Java 并未采用该机制。
注意,对于不可变对象,所有指向该对象的变量在内存中 共用同一个地址。这种多个变量引用同一个对象的现象叫做 共享引用。但不管有多少个引用指向它,都只有一个地址值,只有一个引用计数会记录指向该地址的引用数目。
>>> x = 0
>>> y = 0
>>> print(id(x) == id(y))
True
>>> print(x is y)
True
>>> print(id(0), id(x), id(y)) # 结果不唯一, 但一定是相同的
2424416677616 2424416677616 2424416677616
事实上,Python 对不可变对象有着许多性能/效率优化机制,若学有余力或饶有兴趣,不妨了解一下以加深对内存优化机制的理解,详见文章《【Python】详解 小整数池 & intern 机制 (不可变对象的内存优化原理) 》。
2.1.3 可变对象 (Mutable Objects)
可变对象:变量所指向对象的内存地址处的值 可改变,常见的有 list、set、dict 等类型的对象。因此指向可变对象的变量若发生改变,则该可变对象亦随之改变,即发生 原地 (in-place) 修改。另一方面, 当可变对象相应内存中的值变化时,变量的对可变对象引用仍保持不变,即变量仍指向原可变对象。例如:
>>> m = [5, 9] # 变量 m 指向可变对象 (list)
>>> id(m)
1841032547080
>>> m += [6] # 可变对象 (list) 将随变量 m 的改变而发生原地 (in-place) 修改, 但 m 仍是其引用 (保存的内存地址 id 不变)
>>> id(m)
1841032547080
综上可知,可变对象随着变量的改变而改变,但变量对可变对象的引用关系仍保持不变,即变量仍指向原可变对象。例如,变量 m 先指向可变对象 [5, 9] ,然后随着变量增加元素 6,可变对象 [5, 9] 也随之在内存中增加 6,而变化前、后变量 m 始终指向同一个可变对象 / 保存对同一可变对象的引用。
但注意,我们也由此知道,对于 “看起来相同” 的可变对象,其内存地址是完全不同的,例如:
>>> n = [1, 2, 3]
>>> id(n)
1683653539464
>>> n = [1, 2, 3]
>>> id(n)
1683653609928
可见,对于两个可变对象 [1, 2, 3],二者是先后分别创建的新可变对象,虽然值相同,但内存地址完全不同。而这点有别于不可变对象,因为 所有指向不可变对象的变量在内存中共用同一个地址 (比如 2.1.2 中 666 的例子)。
2.1.4 直接赋值 (Direct Assignment)
Python 中的变量存在 深拷贝 和 浅拷贝 的 区别:
- 对于不可变对象,无论深、浅拷贝,内存地址 (id) 都是一成不变的;
- 对于可变对象,则存在 3 种不同情况。
以下以 list 为例简要说明 可变对象的 3 种情况:
情况一 - 直接赋值:仅拷贝了对可变对象的引用,故前后变量均未隔离,任一变量 / 对象改变,则所有引用了同一可变对象的变量都作相同改变。例如:
>>> x = [555, 666, [555, 666]]
>>> y = x # 直接赋值, 变量前后并未隔离
>>> y
[555, 666, [555, 666]]
# 修改变量 x, 变量 y 也随之改变
>>> x.append(777)
>>> x
[555, 666, [555, 666], 777]
>>> y
[555, 666, [555, 666], 777]
# 修改变量 y, 变量 x 也随之改变
>>> y.pop()
777
>>> y
[555, 666, [555, 666]]
>>> x
[555, 666, [555, 666]]
在某些情况下,这是致命的,因此还需要深、浅拷贝来正确实现真正所需的拷贝目的。
2.2 copy.copy() —— 浅拷贝 (Shallow Copy)
情况二 - 浅拷贝:使用 copy(x) 函数,拷贝可变对象如 list 的最外层对象并实现隔离,但 list 内部的嵌套对象仍是未被隔离的引用关系。例如:
>>> import copy
>>> x = [555, 666, [555, 666]]
>>> z = copy.copy(x) # 浅拷贝
>>> zz = x[:] # 也是浅拷贝, 等同于使用 copy() 函数的 z
>>> z
[555, 666, [555, 666]]
>>> zz
[555, 666, [555, 666]]
# 改变变量 x 的外围元素, 不会改变浅拷贝变量
>>> x.append(777)
>>> x
[555, 666, [555, 666], 777] # 只有自身改变, 增加了外围元素 777
>>> z
[555, 666, [555, 666]] # 未改变
>>> zz
[555, 666, [555, 666]] # 未改变
# 改变变量 x 的内层元素, 则会改变浅拷贝变量
>>> x[2].append(888)
>>> x
[555, 666, [555, 666, 888], 777] # 同时发生改变, 增加了内层元素 888
>>> z
[555, 666, [555, 666, 888]] # 同时发生改变, 增加了内层元素 888
>>> zz
[555, 666, [555, 666, 888]] # 同时发生改变, 增加了内层元素 888
# 浅拷贝变量的外围元素改变不会相互影响
>>> z.pop(0)
555
>>> x
[555, 666, [555, 666, 888], 777] # 未改变
>>> z
[666, [555, 666, 888]] # 只有自身改变, 弹出了外围元素 555
>>> zz
[555, 666, [555, 666, 888]] # 未改变
# 浅拷贝变量的内层元素改变会相互影响
>>> z[1].pop()
888
>>> x
[555, 666, [555, 666], 777] # 同时发生改变, 弹出了内层元素 888
>>> z
[666, [555, 666]] # 同时发生改变, 弹出了内层元素 888
>>> zz
[555, 666, [555, 666]] # 同时发生改变, 弹出了内层元素 888
注意,所谓改变应包含 “增、删、改” 三种,以上仅展示了前两种情况,第三种不言自明。
此外,若有人问元组 (tuple) 一定是不可变的吗?答案是不一定,因为浅拷贝时仅隔离最外层对象,而内层嵌套对象则仍为引用关系,例如:
>>> t = (1, 2, [3, 4]) # tuple
>>> import copy
>>> ct = copy.copy(t) # 浅拷贝 tuple # 注意, 令 ct = t 时此例结果仍然相同
>>> ct
(1, 2, [3, 4])
>>> ct[2][-1] = 5 # 修改 ct
>>> ct
(1, 2, [3, 5])
>>> t # t 也随之改变, 证明内层嵌套对象仍为引用关系
(1, 2, [3, 5])
2.3 copy.deepcopy() —— 深拷贝 (Deep Copy)
情况三 - 深拷贝:使用 deepcopy(x[,memo]) 函数,拷贝可变对象如 list 的“外围+内层”而非引用,实现对前后变量不论深浅层的完全隔离。例如:
>>> import copy
>>> x = [555, 666, [555, 666]]
>>> k = copy.deepcopy(x) # 深拷贝
>>> k
[555, 666, [555, 666]]
# 改变变量 x 的外围元素, 不会改变深拷贝变量
>>> x.append(777)
>>> x
[555, 666, [555, 666], 777]
>>> k
[555, 666, [555, 666]] # 未改变
# 改变变量 x 的内层元素, 同样不会改变深拷贝变量
>>> x[2].append(888)
>>> x
[555, 666, [555, 666, 888], 777]
>>> k
[555, 666, [555, 666]] # 未改变
# 深拷贝变量的外围元素改变不会相互影响
>>> k.pop(0)
555
>>> x
[555, 666, [555, 666, 888], 777] # 未改变
>>> k
[666, [555, 666]]
# 深拷贝变量的内层元素改变同样不会相互影响
>>> k[1].pop()
666
>>> x
[555, 666, [555, 666, 888], 777] # 未改变
>>> k
[666, [555]]
再次试验元组 (tuple) 的例子以展示浅拷贝和深拷贝的区别于联系:
>>> t = (1, 2, [3, 4]) # tuple
>>> import copy
>>> ct = copy.deepcopy(t) # 深拷贝 tuple
>>> ct
(1, 2, [3, 4])
>>> ct[2][-1] = 5 # ct 改变
>>> ct
(1, 2, [3, 5])
>>> t # t 不论外层还是内层嵌套变量, 均不变 (完全隔离)
(1, 2, [3, 4])
2.4 其他 (Others)
上述内容即为基本用法,对于普通使用足够了。若想进一步深入,可选读如下内容:
浅拷贝和深拷贝之间的区别仅在于 复合对象 (即包含其他对象的对象,如 list 或类的实例) 相关:
一个 浅拷贝 会构造一个新的复合对象,然后 (在可能的范围内) 将原对象中找到的 引用 插入其中。
一个 深拷贝 会构造一个新的复合对象,然后递归地将原始对象中所找到的对象的 副本 插入。
深拷贝操作通常存在两个问题,而浅拷贝操作并不存在这些问题:
递归对象 (直接或间接包含对自身引用的复合对象) 可能会导致 递归循环。
由于深拷贝会复制所有内容 (外围内层),故可能 过多复制 (例如本应在副本间共享的数据) 。
深拷贝函数 deepcopy() 通过以下方式避免上述问题:
保留在当前复制过程中已复制的对象的 “备忘录” (memo) 字典;
允许用户定义的类重载复制操作或复制的组件集合。
此外,copy 模块不拷贝模块、方法、栈追踪(stack trace)、栈帧(stack frame)、文件、套接字、窗口、数组及任何类似的类型。它通过不改变地返回原始对象来(浅层或深层地)“复制” 函数和类;类似于 pickle 模块处理这类问题的方式。
参考资料:
8.10. copy — 浅层 (shallow) 和深层 (deep) 复制操作 — Python 3.6.15 文档