Python 的赋值为什么不会复制对象?拆开引用、浅拷贝与深拷贝

简介: list 能装下任何类型,是因为它存的是 PyObject* 指针数组:类型信息挂在对象自己头上。代价是每元素 8 字节指针加完整对象头——实测 100 万个整数,list 总内存约 34.3MB,是 array 的 9 倍。本文用 sys.getsizeof 一格格量给你看,并给出什么时候该换 array/numpy 的判断标准

先看一段让新手崩溃的代码:

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;代码可直接粘贴复现)

相关文章
|
8月前
|
人工智能 Linux API
OpenClaw全自动小红书运营实战:从0到1全流程部署、技能配置与内容自动化发布指南
在内容自动化运营场景中,OpenClaw(Clawdbot)凭借高度可扩展的Skill体系与多任务执行能力,可实现从热点追踪、文案创作、封面生成到笔记发布、互动管理的全流程自动化。本文基于2026年最新环境,完整讲解如何通过阿里云轻量服务器或本地Windows11/macOS/Linux部署OpenClaw,安装并配置小红书运营Skill,完成Cookie登录、内容生成、笔记发布、数据监控,并接入阿里云百炼Coding Plan免费大模型与QMD记忆优化系统,实现低成本、7×24小时无人值守小红书运营。全文无营销词汇,所有命令可直接复制,零基础用户也能快速跑通全流程。
3481 9
|
4天前
|
Python
Python 的 dict 为什么查询是 O(1)?拆开哈希表、扰动函数与扩容阈值
很多人背过“字典查询是 O(1)”,但为什么是常数?CPython 3.13 实测 dict.get 在 100→10 万规模恒为 0.1 微秒,而 list.index 线性涨到 609 微秒(慢 4743 倍)。本文拆开开放寻址哈希表、扰动函数与 2/3 扩容阈值把 O(1) 钉死,也讲清“创建是 O(n)”这个常被混的点。
|
17天前
|
缓存 Python Windows
Python 的 __new__ 到底管什么?和 __init__ 的分工一次讲透
__new__ 管造对象、__init__ 管初始化:11 组实验画出分工边界——两类事非 __new__ 不可(改造不可变类型构造、控制实例创建),日常 99% 初始化仍该写在 __init__。
|
1天前
|
Java C++ Python
Python 的 list 为什么能装下任何类型?拆开 PyObject* 数组,以及它让你多付了多少内存
list 能装下任何类型,是因为它存的是 PyObject* 指针数组:类型信息挂在对象自己头上。代价是每元素 8 字节指针加完整对象头——实测 100 万个整数,list 总内存约 34.3MB,是 array 的 9 倍。本文用 sys.getsizeof 一格格量给你看,并给出什么时候该换 array/numpy 的判断标准
|
2天前
|
缓存 安全 Python
Python 的 str 为什么设计成不可变?拆开字符串驻留、字符串池,以及 += 拼接到底慢在哪
str 不可变换来三件事:hash 一次算完可缓存(dict 查找的地基)、子串切片能安全共享底层缓冲、跨模块传递不用防御性拷贝。实测 += 在 CPython 里已被原地扩容优化,10 万次拼接仍是线性,真正的差距是 join 只分配一次、比 += 快约 19 倍。本文拆开字符串驻留、字符串池与拼接性能把设计逻辑钉死。
|
3天前
|
缓存 Java Python
Python 的 int 为什么不会溢出?拆开 PyLongObject 和它背后那串变长数组
很多人背过“字典查询是 O(1)”,但为什么是常数?CPython 3.13 实测 dict.get 在 100→10 万规模恒为 0.1 微秒,而 list.index 线性涨到 609 微秒(慢 4743 倍)。本文拆开开放寻址哈希表、扰动函数与 2/3 扩容阈值把 O(1) 钉死,也讲清“创建是 O(n)”这个常被混的点。
|
5天前
|
缓存 C++ 索引
Python 的列表和元组到底差在哪?拆开 PyListObject 和 PyTupleObject,为什么一个能改一个不能
list 和 tuple 的差别不只是能不能改:CPython 3.13 实测 tuple 字面量创建快 6.7 倍、迭代快约 16%、索引快约 18%、空容器省 16 字节。根在结构——PyTupleObject 定长不带 allocated 字段,PyListObject 多带扩容冗余。本文用实机数据拆开两者底层,告诉你什么时候该用哪个。
|
6天前
|
缓存 编译器 Sentinel
Python 里 is 和 == 到底差在哪?拆开小整数池、字符串驻留与 0ns 身份比较
is 比 == 快不是 Python 偷懒,而是它只比对象身份(指针),0ns 级别;== 要走 __eq__ 还可能触发哈希。但小整数池和字符串驻留会让 is 在「本不该相等」时也返回 True,这是 80% 初学者踩的坑。
|
7天前
|
Python
Python 拼字符串为什么别在循环里用 +=?拆开 join 的两遍账:191.8 微秒与 6.8 微秒
循环 += 拼接 1000 段要 191.8 微秒,已有列表直接 join 只要 6.8 微秒:join 先扫一遍算总长再一次性分配,+= 每轮都可能复制整个已拼好的串。CPython 对 += 的原地扩容依赖引用计数,加一个别名后涨到 459.8 微秒。3 片段场景 f-string 约 251.8ns,join 反而 306.4ns。
|
8天前
|
C++ Python
Python 的函数调用到底贵在哪?拆开一次 call:51 纳秒栈帧与 *args 的 40 纳秒罚单
Python 函数调用一次约 100ns:空调用 97.8ns,其中栈帧创建机制约 51ns;*args 打包 tuple 贵 27%,**kw 建 dict 贵 15%;绑定方法比类函数调用快 40%。fib(30) 的 200ms 几乎全是调用开销,内联优化上限约 48ns。7 组实测数据。

热门文章

最新文章