Python 里 copy.copy() 和 deepcopy() 到底差在哪:拆开 __copy__、__deepcopy__ 和那个防止无限递归的 memo 字典

0 阅读6分钟

几乎每个 Python 教程都会写「浅拷贝只复制一层,深拷贝递归复制」。但真到排查 bug 时,光背这句不够:为什么 deepcopy 一个不可变对象有时返回原对象?为什么自定义类里要有 __deepcopy__(self, memo) 这个多出来的参数?为什么循环引用不会把深拷贝撑爆?

一句话结论:copy.copy() 只新建最外层容器,内部对象全部沿用引用;copy.deepcopy() 递归重建所有可变子对象,并靠一个贯穿全程的 memo 字典记住「原对象 id → 新对象」,既保证共享引用不被复制成两份,也挡住循环引用的无限递归。它不是「递归 copy」,是一台带记忆的对象图遍历机。

下面数据全部来自本机 CPython 3.13.14 实测(d55-probe.py)。

一、先复现:改了内层,浅拷贝跟着变

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])   # 谁被牵连

实测输出:

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 也未必新建

for v in [123456, 'abc', (1, [2, 3]), frozenset([1, 2])]:
    print(type(v).__name__, copy.deepcopy(v) is v)

实测输出:

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:钩子怎么被调用

想接管拷贝行为,就在类里定义这两个方法:

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)

实测输出:

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)

三个细节值得抠:

  1. __copy__ 没有 memo 参数,__deepcopy__ 有——浅拷贝不遍历对象图,不需要记忆。
  2. 两次 __deepcopy__ 打印的 memo_id 完全相同(都是 2862891293056)。这就是贯穿全程的那一个字典,父子节点共用。
  3. memo[id(self)] = n 必须写在递归之前。若先 copy.deepcopy(self.children, memo) 再登记,而 children 里又反向指回 self,就会无限递归。这是自定义 __deepcopy__ 最常见的写法错误。

四、memo 字典:循环引用为什么不炸

cyc = []
cyc.append(cyc)
cc = copy.deepcopy(cyc)
print(cc is cc[0], cc is cyc)

实测输出:

True False

cc is cc[0] 为 True,说明深拷贝连「自引用」这个结构都原样复刻了:新 list 的第一个元素仍是新 list 自己,而不是旧 list,也不是另一份副本。

再看传入自定义 memo:

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 直接返回原对象

f = lambda t: t
print(copy.deepcopy(f) is f)        # True
print(copy.deepcopy(Node) is Node)  # True

实测都是 True。函数、类、模块、文件句柄这类对象被 _deepcopy_atomic 直接放行。这也解释了为什么「深拷贝一个带方法的对象」通常不会把类也复制一份——方法所在的类仍是原来那个。

六、slots 里的私有名也会先被改写

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 倍

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.copy0.040 s1×
copy.deepcopy1.052 s26.5×

deepcopy 要建字典、递归、查表、 dispatch 到各类型的 _deepcopy_*,慢一个数量级很正常。结论不是「别用 deepcopy」,而是别在热路径里对大对象无脑深拷贝;能靠不可变数据结构或 copy.copy 解决的,就别上递归。

八、一张对照表

维度copy.copycopy.deepcopy
外层容器新建新建
嵌套可变子对象共享引用递归新建
int / str 等原子不可变对象沿用沿用
含可变元素的 tuple / frozenset沿用外层也可能新建外层
自定义钩子__copy__(self)__deepcopy__(self, memo)
循环引用不处理(本来就不递归)靠 memo 正确复刻
共享引用保留(因为根本没复制)靠 memo 保留
20 万次耗时(本例)0.040 s1.052 s

九、六条排错清单

  1. 改了拷贝体的嵌套列表,原对象也变了 → 用了 copy.copy,换 deepcopy。
  2. deepcopy 一个 tuple 竟然不是原对象 → 里面装了 list 等可变元素,属预期行为。
  3. 自定义 __deepcopy__ 无限递归 → memo[id(self)] = 新对象 没写在递归之前。
  4. 两个字段本该指向同一对象,深拷贝后变成两份 → 自己实现了拷贝逻辑却没用 memo,改用 copy.deepcopy(x, memo) 传递。
  5. 深拷贝后方法/类「丢了」或行为异常 → 函数与类本来就不复制,检查是不是误把状态挂在了类属性上。
  6. 接口响应变慢、CPU 花在 copy 上 → 用 cProfile 看 _deepcopy_* 占比,热路径换浅拷贝或不可变结构。

小结

copy.copy 是「换一个壳」,copy.deepcopy 是「照着对象图重画一张,并且记牢谁指向谁」。真正决定行为的不是「深不深」,而是三件事:对象图里有没有可变子对象、有没有共享/循环引用、以及你有没有把 memo 一路传下去。把这三点想清楚,上面那些「奇怪」的输出就都不奇怪了。


实测环境:本机 CPython 3.13.14,脚本 G:/work/netease-hao/d55-probe.py,全部输出为该脚本原样打印。