先看一段让新手崩溃的代码:
a = [1, 2, 3]
b = a # 只是赋值,没有复制
b.append(4)
print(a) # [1, 2, 3, 4] —— a 居然也变了
b = a 一行都没"改错",a 却被牵连了。要解释这件事,得回到上一期讲过的那个事实:Python 的变量不是盒子,是标签。
一、赋值的真相:复制的是指针,不是对象
CPython 里每个变量名都只是指向某个 PyObject* 的引用。b = a 做的事只有一件:把 a 指向的那个指针抄给 b,再调用一次 Py_INCREF 把引用计数加一。对象本身一个字节都没动。
在解释器里验证:
>>> id(a) == id(b)
True
>>> sys.getrefcount(a)
3 # a、b,加上传参临时持有的一次
id() 相同,说明 a 和 b 是同一个对象的两个名字。所谓"赋值",本质上就是把 8 字节的指针抄了一遍——这也解释了为什么 Python 里赋值快得不像话。
所以开头那段代码不是 bug:b.append(4) 操作的是那个唯一的列表对象,a 和 b 都指着它,当然一起变。
二、浅拷贝:只复制最外层
想拿到独立副本,最直接的是浅拷贝:
c = a.copy() # 等价于 a[:] 和 list(a)
浅拷贝会新建一个列表对象,把外层的指针数组逐个抄一遍——注意抄的还是指针。于是:
inner = [9]
outer = [inner]
sh = outer.copy()
sh[0].append(8)
print(outer[0]) # [9, 8] —— 内层被共享了
print(outer is sh) # False,外层确实是新对象
print(outer[0] is sh[0]) # True,内层还是同一个
实测一组数据(CPython 3.13):2 万行嵌套列表占 9.3MB,整体浅拷贝之后内存只增加 0.16MB——增加的正是 2 万个指针槽(20,000 × 8 字节 ≈ 156KB),所有内层对象原封不动地共享。
浅拷贝的真正含义:外层新,内层旧。
三、深拷贝:递归到底的代价
要连内层一起复制,就得用 copy.deepcopy,它会沿着引用一路递归下去,把整棵对象树复制一遍。代价是惊人的:
lst = [{
'k': [i]*10} for i in range(2000)]
%timeit lst.copy() # 约 0.010ms
%timeit copy.deepcopy(lst) # 约 17.2ms
同样 2000 个元素,深拷贝比浅拷贝慢了大约 1700 倍。原因很直白:浅拷贝是 memcpy 级别的指针搬运,深拷贝要对每个嵌套对象做递归遍历、逐个新建、维护"已复制"备忘录(memo 字典,用来正确处理循环引用和共享子对象)。
顺带说一个高频面试点:deepcopy 之所以能处理 x.append(x) 这种自引用结构,靠的就是 memo——复制前先查"这个对象是不是已经复制过了"。
四、函数传参也一样:传的是引用
def fill(lst):
lst.append(1)
data = []
fill(data)
print(data) # [1] —— 出了函数还在
Python 的参数传递常被叫做"传对象引用":形参和实参是同一个对象的两个名字。想避免函数污染外部数据,入口处先 data = data.copy()。
五、一个判断口诀
最后给一个实用口诀,按需选拷贝深度:
- 元素是不可变类型(数字、字符串、元组):浅拷贝就够,反正它们改不了;
- 一层嵌套、只改外层:浅拷贝;
- 嵌套可变对象、各层都要独立:深拷贝,但先掂量那 1700 倍的性能差;
- 只是想传个名字:直接赋值,零成本。
记住核心机制就够了:Python 里 "=" 永远只复制指针。 复制对象的任何承诺,都来自 copy 模块,而不是赋值运算符本身。
(实测环境:CPython 3.13,Windows;代码可直接粘贴复现)