【Python】详解 可变/不可变对象 与 深/浅拷贝

目录

一、绪论

二、说明

2.1 赋值 (Assignment)

2.1.1 变量与对象 (Variables and Objects)

2.1.2 不可变对象 (Immutable Objects)

2.1.3 可变对象 (Mutable Objects)

2.1.4 直接赋值 (Direct Assignment)

2.2 copy.copy() —— 浅拷贝 (Shallow Copy)

2.3 copy.deepcopy() —— 深拷贝 (Deep Copy)

2.4 其他 (Others)


一、绪论

        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 模块处理这类问题的方式。


参考资料

Python可变对象和不可变对象

8.10. copy — 浅层 (shallow) 和深层 (deep) 复制操作 — Python 3.6.15 文档

Python中的垃圾回收机制(转) - 奋斗终生 - 博客园

  • 14
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值