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

简介: copy.copy 换一个壳,copy.deepcopy 照着对象图重画一张并记牢谁指向谁。本文用本机 CPython 3.13.14 实测:浅拷贝嵌套层共享、deepcopy 对 int/str 返回原对象但含 list 的 tuple 会新建、__copy__/__deepcopy__ 钩子与 memo 传递、循环引用正确复刻、函数类不复制,以及 20 万次耗时 0.040s vs 1.052s(26.5 倍),附对照表与六条排错清单

几乎每个 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.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

九、六条排错清单

  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,全部输出为该脚本原样打印。

相关文章
|
10天前
|
Linux 开发工具 iOS开发
Codex保姆级教程(下载+安装+登录+使用,含所有版本,2026最新)
Codex是OpenAI推出的开源AI编程智能体,本地运行,可理解整个代码库,支持自然语言指令改代码、执行命令、跑测试、修Bug,兼容Win/macOS/Linux。(239字)
3913 18
|
11天前
|
JavaScript API iOS开发
DeepSeek Harness下载、安装、配置一篇搞定(2026整理,送常用插件)
DeepSeek Harness(DSH)是DeepSeek于2026年开源的MIT协议AI Agent框架,支持插件化组装模型、工具、沙箱与UI,兼容主流大模型。本文提供免Node.js的桌面端安装包(Win/macOS),含详细图文教程及热门插件推荐。
4096 49
|
11天前
|
iOS开发 Docker MacOS
【2026实测】Docker Desktop下载、安装、汉化、配置教程(附官网安装包)
Docker Desktop 是 Docker 官方推出的桌面容器管理工具,集成引擎、CLI、图形界面与 Kubernetes,支持 Win/macOS/Linux。2026 最新版 4.88.1 强化 ARM 原生运行、镜像加速及资源可视化,附中文汉化与国内镜像配置指南。
1932 7
|
5天前
|
云安全 人工智能 安全
|
6天前
|
存储 弹性计算 人工智能
2026 年阿里云双十一活动入口攻略,新客服务器优惠通道、三大会场入口梳理
综合来看,2026年阿里云双十一活动入口清晰: 阿里云轻量应用服务器、阿里云ECS云服务器、阿里云GPU云服务器、阿里云百炼大模型服务平台 活动会场、综合权益中心、99计划专场三大入口覆盖服务器、AI权益与续费特价;新用户通过控制台首页banner、权益中心、产品页三处路径进入专属专场,老用户通过控制台弹窗和费用中心权益中心进入老客专区,企业用户做企业实名后解锁专属满减与阶梯折扣。
817 0
|
5天前
|
Linux 开发工具 iOS开发
2026最新codex下载安装保姆级使用教程(下载+安装+登录+使用,含所有历史版本)
本文详解OpenAI Codex国内快速上手指南:提供Win/macOS/Linux全平台免安装包、npm/Homebrew等多方式安装、账号/API Key双登录、简体中文切换,覆盖下载、安装、汉化、使用及高频避坑(如微软商店失败、npm超时等),10分钟即可跑通。
1029 2
|
11天前
|
安全 算法 定位技术
《高性价比人生指南》完整原版:普通人用最少的资源,过好这一生的底层逻辑~How To Live Better
📖《高性价比人生指南》是一份基于实证、拒绝焦虑的生存手册,倡导以“人生时薪”为视角,重新计算时间、金钱与精力的成本与收益。它不承诺逆袭,只教人算清账:省下的钱若耗掉时间便亏;买体验比买物品更值;睡眠是最高杠杆的健康投资;关系贵在情绪价值而非表面热络;学习重在改变行为而非自我安慰。核心理念:用低成本、高回报的微小行动,换取长期的选择权——不是成为谁,而是掌控自己人生的主动权。
2141 6
|
云安全 安全 网络安全
|
24天前
|
人工智能 API 开发工具
DeepSeek Harness 神插件:白嫖 13 家大厂模型
`dsh-our-free-model` 是 DeepSeek Harness 神级插件:零配置、零API Key、零账号,聚合13家大厂免费模型(含DeepSeek V4.1 Flash、Kimi K3等),支持本地网关、用量看板与自动降级,真正实现“白嫖即用”。
4286 58