今天我们来讨论一个看似简单的问题。这篇内容产生于我对python
的copy
库中的deepcopy()
方法的使用过程,在微调模型和进行多轮对话时,我们经常需要对模型的历史参数或是历史对话记录进行保存,按常理来说,直接用=
赋值给另一个变量就好了,但实际项目中往往使用deepcopy()
方法对一些“复杂变量”进行保存,今天我们就一起来探索一下python
中的拷贝与赋值。
python 中的对象类型
在 Python 中,对象分为可变对象和不可变对象。了解这些对象的区别对于编写高效且正确的代码非常重要。
不可变对象
不可变对象一旦创建,无法在原地修改。如果需要修改它们的值,只能通过创建一个新的对象并重新赋值。
常见的不可变对象包括:
整数 (int
):
- 例如:
a = 10
。不能在原地修改10
的值。
浮点数 (float
):
- 例如:
b = 3.14
。类似于整数,浮点数也是不可变的。
字符串 (str
):
- 例如:
s = "hello"
。字符串是不可变的,修改字符串中的字符会创建一个新的字符串对象。
元组 (tuple
):
- 例如:
t = (1, 2, 3)
。元组中的元素不能被修改或重新赋值。
布尔值 (bool
):
- 例如:
flag = True
。布尔值也是不可变的。
frozenset:
- 例如:
fs = frozenset([1, 2, 3])
。这是一个不可变的集合。
可变对象
可变对象在创建后可以修改其内容,而不需要创建新的对象。这使得它们在某些操作中更为高效,但也可能带来意外的副作用。
常见的可变对象包括:
列表 (list
):
- 例如:
lst = [1, 2, 3]
。可以通过lst[0] = 10
来修改列表中的元素。
字典 (dict
):
- 例如:
d = {'a': 1, 'b': 2}
。可以通过d['a'] = 10
来修改字典中的键值对。
集合 (set
):
- 例如:
s = {1, 2, 3}
。可以通过s.add(4)
来修改集合的内容。
字节数组 (bytearray
):
- 例如:
ba = bytearray([1, 2, 3])
。可以通过ba[0] = 0
来修改字节数组中的值。
可变与不可变的对比
- 内存使用:不可变对象通常在内存中是唯一的,多个引用可以共享同一个对象(尤其是小的整数和短字符串)。可变对象因为可以修改,通常不会被共享。
- 副作用:可变对象的修改会影响所有引用它的变量,而不可变对象则不会有这种副作用。
举例说明
# 不可变对象示例
a = "hello"
b = a
b = "world"
print(a) # 输出: "hello"(a 未受 b 的影响)
# 可变对象示例
x = [1, 2, 3]
y = x
y[0] = 10
print(x) # 输出: [10, 2, 3](x 受到了 y 的修改影响)
在第一个示例中,a
和 b
最初指向同一个字符串 "hello"
,但当 b
被重新赋值为 "world"
时,它不再指向 a
的对象,因此 a
保持不变。
在第二个示例中,x
和 y
最初指向同一个列表。当 y
修改列表时,由于列表是可变的,因此 x
也受到了影响。
到这里我们明确了一个关于赋值的问题,如果你将一个变量a
赋值为“不可变对象”(字符串、元组、数字),那么你可以毫无顾忌的使用另一个变量b
来保留a
变量的内容,不需要担心这种直接的赋值b = a
会影响a
变量的内容;但是如果将变量a
赋值为“不可变对象”(列表、字典、集合),你的变量传递就是无效的,虽然你写b = a
的本意是用新的变量b
来保护变量a
中的内容,这样你就可以肆无忌惮的改动变量a
或b
了,但事实并非如此。
Python 中的深拷贝和浅拷贝
如果只介绍到上面就结束了,你可能会说:这谁不知道啊!
实际上,与变量赋值相关联的还有拷贝这个概念,如果你学过C++的话,你就会了解深拷贝和浅拷贝,下面我简单回顾一下:
第一种拷贝赋值的方法叫做深拷贝,第二种拷贝赋值的方法叫做浅拷贝。
- 深拷贝:先创建一份新的空间来容纳数据蓝本,然后再把实际内容拷贝过去。数据内容相同,但放在两块不同的内存空间。
- 浅拷贝:定义一个新的变量名,然后将其指向另一个变量所对应的内存块,本质上就是给同一块数据起了个“外号”。
在 Python 中也是一样,对存有“可变对象”的变量a
执行b = a
,其实就是一种浅拷贝,两个变量名指向同一块数据(数组、列表、集合),“一荣俱荣,一损俱损”;如果变量a
内是“不可变对象”的话(字符串、元组、数字),那没办法,虽然变量b
也想图省事直接把a
的数据拿过来就用,但是“不可变对象”不能复用,他只能另起炉灶(内存空间)了。
再理解了赋值、深拷贝、浅拷贝之后,回到我们的主题,为什么python
重要专门写一个copy
库,包含浅拷贝copy()
和深拷贝deepcopy()
两种拷贝方法呢?
这是因为在某些场景(模型微调需要保存历史参数、模型推理需要保存历史对话记录)中,我们往往需要保存”可变类型对象“,如果只用b = a
来实现的话,就会导致”虚假“的拷贝,我们以为保存好了原来的模型参数,开开心心微调完,发现模型调傻了,又没用deepcopy()
深拷贝,想回到过去也回不去了。。。
Python 中浅拷贝与赋值的区别
看到这里,你可能回想,那你这么说,浅拷贝b = copy.copy(a)
和赋值b = a
不是一样了吗?实则不然。浅拷贝和直接赋值在某些情况下效果相同,但在处理复杂数据结构(如嵌套的列表或字典)时,它们的行为会有所不同。
直接赋值
直接赋值时,两个变量指向同一个对象。如果你对其中一个变量进行修改,另一个变量也会受到影响,因为它们共享相同的内存引用。
示例
a = [[1, 2, [5,6]], [3, 4]]
b = a # 直接赋值
b[0][0] = 88
print(a) # 输出: [[88, 2, [5, 6]], [3, 4]]
print(b) # 输出: [[88, 2, [5, 6]], [3, 4]]
a = [[1, 2, [5,6]], [3, 4]]
b = a # 直接赋值
b[0] = 99
print(a) # 输出: [99, [3, 4]]
print(b) # 输出: [99, [3, 4]]
在这个例子中,a
和 b
指向同一个列表对象。对 b
的修改直接反映在 a
上。
浅拷贝
浅拷贝会创建一个新对象,但它只拷贝对象的第一层元素,嵌套的子元素仍然是引用同一对象。因此,浅拷贝对象的顶层结构是独立的,但内部的嵌套元素仍然共享。
示例
import copy
a = [[1, 2, [5,6]], [3, 4]]
b = copy.copy(a) # 浅拷贝
b[0][0] = 88
print(a) # 输出: [[88, 2, [5, 6]], [3, 4]]
print(b) # 输出: [[88, 2, [5, 6]], [3, 4]]
到这里看起来直接赋值和浅拷贝的结果一样吧,不过别急
import copy
a = [[1, 2, [5,6]], [3, 4]]
b = copy.copy(a) # 浅拷贝
b[0] = 99
print(a) # 输出: [[1, 2, [5, 6]], [3, 4]]
print(b) # 输出: [99, [3, 4]]
这里怎么就突然不一样了呢?
在这个例子中,a
和 b
是两个不同的列表对象(列表的第一维),但它们的子元素(第二维、第三维。。。)仍然引用同一个子列表。因此,修改 b[0][0]
也会影响 a[0][0]
。
直接赋值 vs 浅拷贝
- 直接赋值:两个变量指向同一个对象,任何一个变量的修改都会影响另一个变量。
- 浅拷贝:创建了一个新的对象,但内部的嵌套元素仍然是共享的,修改这些嵌套元素会影响原始对象。
何时有区别?
- 简单对象(如数字、字符串):浅拷贝和直接赋值的效果一样,因为这些对象是不可变的。
- 复杂对象(如列表、字典,特别是嵌套的结构):浅拷贝和直接赋值效果不同,浅拷贝创建了新的顶层对象,但嵌套元素仍然共享。
总结
- 直接赋值:完全共享相同的对象,任何改变都会反映在所有引用中。
- 浅拷贝:创建一个新的对象,但嵌套的子对象仍然共享。因此,顶层修改不会影响原始对象,但修改嵌套对象会。
如果你需要一个完全独立的副本,包括所有嵌套的对象,你需要使用深拷贝(deepcopy
)。
特殊情况:Python 中的数组切片
除此之外,还有一种特殊情况也被我碰到了(在赋值时使用切片操作):
original_str = [1, 2, 3]
copied_str = original_str[:]
copied_str[0] = 3
print(copied_str) # 输出: [3, 2, 3]
print(original_str) # 输出: [1, 2, 3]
original_str = [1, 2, 3]
copied_str = original_str
copied_str[0] = 3
print(copied_str) # 输出: [3, 2, 3]
print(original_str) # 输出: [3, 2, 3]
上面用到original_str[:]
是 Python 切片(slice)操作,它返回原对象的一个浅拷贝。
示例
列表示例
original_list = [1, 2, 3, 4]
copied_list = original_list[:]
# 修改 copied_list 不会影响 original_list
copied_list[0] = 99
print(original_list) # 输出: [1, 2, 3, 4]
print(copied_list) # 输出: [99, 2, 3, 4]
在这个例子中,copied_list
是 original_list
的一个浅拷贝。对 copied_list
的修改不会影响 original_list
,因为它们是两个独立的对象。
字符串示例
original_str = "hello"
copied_str = original_str[:]
print(copied_str) # 输出: "hello"
print(copied_str is original_str) # 输出: True
在这个字符串示例中,由于字符串是不可变对象,copied_str
和 original_str
实际上是同一个对象的引用,因此 copied_str is original_str
为 True
。尽管如此,copied_str = original_str[:]
仍然遵循了创建浅拷贝的语法,只是在这种情况下,它们指向了同一个对象。
注意
- 对于可变对象(如列表),浅拷贝意味着新对象和原对象共享内部的子对象。例如,如果列表中包含其他列表,使用
message[:]
创建的新列表和原列表中的子列表仍然是共享的。
original_list = [[1, 2], [3, 4]]
copied_list = original_list[:]
copied_list[0][0] = 99
print(original_list) # 输出: [[99, 2], [3, 4]]
print(copied_list) # 输出: [[99, 2], [3, 4]]
在这个例子中,original_list
和 copied_list
都引用了相同的内部列表,所以修改其中一个的子列表内容会影响另一个。
总结
- 数组切分创建了原对象的浅拷贝。它复制了对象的元素,但不会影响原对象。
- 对于可变对象,这种方式非常有用,因为它可以在不改变原对象的情况下操作副本。
- 对于不可变对象,这种操作没有实际区别,但仍然可以用于创建一个新的引用。