几乎每个 Python 教程都会写「浅拷贝只复制一层,深拷贝递归复制」。但真到排查 bug 时,光背这句不够:为什么 deepcopy 一个不可变对象有时返回原对象?为什么自定义类里要有 __deepcopy__(self, memo) 这个多出来的参数?为什么循环引用不会把深拷贝撑爆?
一句话结论:copy.copy() 只新建最外层容器,内部对象全部沿用引用;copy.deepcopy() 递归重建所有可变子对象,并靠一个贯穿全程的 memo 字典记住「原对象 id → 新对象」,既保证共享引用不被复制成两份,也挡住循环引用的无限递归。它不是「递归 copy」,是一台带记忆的对象图遍历机。
下面数据全部来自本机 CPython 3.13.14 实测(d55-probe.py)。
一、先复现:改了内层,浅拷贝跟着变
python
import copy
inner = [1, [2, 3]]
a = [inner, 4]
sh = copy.copy(a)
dp = copy.deepcopy(a)
print(sh[0] is a[0]) # 顶层元素:浅拷贝沿用
print(dp[0] is a[0]) # 顶层元素:深拷贝新建
print(sh[0][1] is a[0][1]) # 嵌套层:浅拷贝共享
print(dp[0][1] is a[0][1]) # 嵌套层:深拷贝也新建
inner[1].append(99)
print(sh[0][1], dp[0][1]) # 谁被牵连
实测输出:
python
True
False
True
False
[2, 3, 99] [2, 3]
改的是 inner[1],浅拷贝 sh 跟着变成了 [2, 3, 99],深拷贝 dp 纹丝不动还在 [2, 3]。这就是「一层 vs 递归」最直白的代价。
注意一个常见误读:sh[0] is a[0] 为 True 不代表 sh is a------copy.copy(a) 确实新建了外层 list,只是里面装的两个元素仍是原来的引用。
二、反直觉:不可变对象 deepcopy 也未必新建
python
for v in [123456, 'abc', (1, [2, 3]), frozenset([1, 2])]:
print(type(v).__name__, copy.deepcopy(v) is v)
实测输出:
python
int True
str True
tuple False
frozenset False
int 和 str 是不可变对象,deepcopy 直接返回原对象------复制一份没有意义,谁也改不了它。
但 (1, [2, 3]) 是 tuple、frozenset([1, 2]) 是 frozenset,照样返回 False。原因是判定标准不是「容器可不可变」,而是「整个对象图里有没有可变子对象」 :tuple 不可变,但它装了一个 list;frozenset 里若装了含可变元素的对象同理。CPython 的 _deepcopy_tuple / _deepcopy_atomic 走的是「先递归重建元素,若全都没变才返回原对象」,只要有一个元素被复制了,外层就得新建。
一个能直接记住的结论:(1, 2, 3) 的 deepcopy 通常还是它自己,(1, [2, 3]) 一定不是。
三、copy / deepcopy:钩子怎么被调用
想接管拷贝行为,就在类里定义这两个方法:
python
class Node:
def __init__(self, name):
self.name = name
self.children = []
def __copy__(self):
print(' __copy__ CALLED')
n = Node(self.name + '(shallow)')
n.children = self.children # 故意共享
return n
def __deepcopy__(self, memo):
print(' __deepcopy__ CALLED memo_id=', id(memo))
n = Node(self.name + '(deep)')
memo[id(self)] = n # 🔴 必须先登记自己
n.children = copy.deepcopy(self.children, memo)
return n
root = Node('root')
root.children.append(Node('kid'))
sc = copy.copy(root)
dc = copy.deepcopy(root)
实测输出:
ini
A5 custom shallow:
__copy__ CALLED
name= root(shallow) | children shared? True
A6 custom deep:
__deepcopy__ CALLED memo_id= 2862891293056
__deepcopy__ CALLED memo_id= 2862891293056
name= root(deep) | children shared? False | kid name= kid(deep)
三个细节值得抠:
__copy__没有memo参数,__deepcopy__有------浅拷贝不遍历对象图,不需要记忆。- 两次
__deepcopy__打印的memo_id完全相同 (都是2862891293056)。这就是贯穿全程的那一个字典,父子节点共用。 memo[id(self)] = n必须写在递归之前 。若先copy.deepcopy(self.children, memo)再登记,而 children 里又反向指回 self,就会无限递归。这是自定义__deepcopy__最常见的写法错误。
四、memo 字典:循环引用为什么不炸
python
cyc = []
cyc.append(cyc)
cc = copy.deepcopy(cyc)
print(cc is cc[0], cc is cyc)
实测输出:
python
True False
cc is cc[0] 为 True,说明深拷贝连「自引用」这个结构都原样复刻了:新 list 的第一个元素仍是新 list 自己,而不是旧 list,也不是另一份副本。
再看传入自定义 memo:
python
memo = {}
x = [[1, 2], [3, 4]]
y = copy.deepcopy(x, memo)
print(y[0] is not x[0], len(memo))
# True 4
memo 里攒了 4 条记录(外层 list、两个内层 list,以及递归过程中的中间项)。它的两个作用合起来才是完整答案:
| 作用 | 不做会怎样 |
|---|---|
记住 id(原) → 新 |
同一个对象被引用两次时,会被复制成两份,原结构里的共享关系丢失 |
| 递归前先查表 | 循环引用(a→b→a)会无限递归直到 RecursionError |
所以 memo 不是优化,是正确性前提。
五、函数、类、模块:deepcopy 直接返回原对象
python
f = lambda t: t
print(copy.deepcopy(f) is f) # True
print(copy.deepcopy(Node) is Node) # True
实测都是 True。函数、类、模块、文件句柄这类对象被 _deepcopy_atomic 直接放行。这也解释了为什么「深拷贝一个带方法的对象」通常不会把类也复制一份------方法所在的类仍是原来那个。
六、slots 里的私有名也会先被改写
python
class Slotted:
__slots__ = ('__v',)
def __init__(self, v): self.__v = v
def get(self): return self.__v
print(Slotted().get()) # 7
实测能正常取到 7。但 __slots__ 里写的 '__v' 在类创建时已被改名成 '_Slotted__v',所以外部 s.__v 取不到,s._Slotted__v 才能取到。这一条和深浅拷贝无关,却是「拷贝一个用了 __slots__ 的对象」时最容易踩的坑------copy 走的是 __reduce_ex__ 重建,槽名写错就丢属性。(name mangling 的完整机制另文详述。)
七、性能:26.5 倍
python
timeit.timeit('copy.copy(a)', setup='import copy; a=[[1,2],[3,4]]', number=200000)
timeit.timeit('copy.deepcopy(a)', setup='import copy; a=[[1,2],[3,4]]', number=200000)
实测(20 万次,同一台机):
| 操作 | 耗时 | 相对 |
|---|---|---|
copy.copy |
0.040 s | 1× |
copy.deepcopy |
1.052 s | 26.5× |
deepcopy 要建字典、递归、查表、 dispatch 到各类型的 _deepcopy_*,慢一个数量级很正常。结论不是「别用 deepcopy」,而是别在热路径里对大对象无脑深拷贝 ;能靠不可变数据结构或 copy.copy 解决的,就别上递归。
八、一张对照表
| 维度 | copy.copy |
copy.deepcopy |
|---|---|---|
| 外层容器 | 新建 | 新建 |
| 嵌套可变子对象 | 共享引用 | 递归新建 |
int / str 等原子不可变对象 |
沿用 | 沿用 |
| 含可变元素的 tuple / frozenset | 沿用外层 | 也可能新建外层 |
| 自定义钩子 | __copy__(self) |
__deepcopy__(self, memo) |
| 循环引用 | 不处理(本来就不递归) | 靠 memo 正确复刻 |
| 共享引用 | 保留(因为根本没复制) | 靠 memo 保留 |
| 20 万次耗时(本例) | 0.040 s | 1.052 s |
九、六条排错清单
- 改了拷贝体的嵌套列表,原对象也变了 → 用了
copy.copy,换deepcopy。 deepcopy一个 tuple 竟然不是原对象 → 里面装了 list 等可变元素,属预期行为。- 自定义
__deepcopy__无限递归 →memo[id(self)] = 新对象没写在递归之前。 - 两个字段本该指向同一对象,深拷贝后变成两份 → 自己实现了拷贝逻辑却没用 memo,改用
copy.deepcopy(x, memo)传递。 - 深拷贝后方法/类「丢了」或行为异常 → 函数与类本来就不复制,检查是不是误把状态挂在了类属性上。
- 接口响应变慢、CPU 花在 copy 上 → 用
cProfile看_deepcopy_*占比,热路径换浅拷贝或不可变结构。
小结
copy.copy 是「换一个壳」,copy.deepcopy 是「照着对象图重画一张,并且记牢谁指向谁」。真正决定行为的不是「深不深」,而是三件事:对象图里有没有可变子对象、有没有共享/循环引用、以及你有没有把 memo 一路传下去。把这三点想清楚,上面那些「奇怪」的输出就都不奇怪了。
实测环境:本机 CPython 3.13.14,脚本 G:/work/netease-hao/d55-probe.py,全部输出为该脚本原样打印。