深拷贝和浅拷贝的区别(辨析:内存结构与引用传递)

同样一句赋值,改一个对象另一个跟着变、另一个却纹丝不动,差别就在拷贝方式。浅拷贝只新建外层容器,内层元素沿用原来的引用;深拷贝把每一层引用类型都重新分配内存,副本与原对象在堆上彻底分开。分辨两者的方法很简单:改副本内部的嵌套对象,看原对象会不会跟着变。

线上真出过这类事故。一个配置对象从缓存里取出来,用展开运算符复制了一份给某个租户单独改参数,改的是副本的嵌套字段,结果所有租户下个周期读到的阈值全变了。排查花了大半天,根因就一句:外层复制了,内层还在共享同一块堆内存。从那以后,凡是”复制一份再改”的地方,都会先问一句这个结构有几层。

三者对比:赋值、浅拷贝、深拷贝各自复制了什么

赋值不复制对象,浅拷贝复制第一层,深拷贝递归复制所有层级,这是三种操作的本质区别。把三者放在一起看,行为差异一目了然。

维度 直接赋值 浅拷贝 深拷贝 结论
是否创建新对象 否 是,仅外层 是,含所有子对象 只有后两者算拷贝
内层引用类型处理 共享同一引用 共享同一引用 重新分配内存 决定会不会互相污染
改外层基本类型 双方都变 只有副本变 只有副本变 浅拷贝已能隔离外层
改内层嵌套对象 双方都变 双方都变 只有副本变 深拷贝才能隔离内层
内存开销 无 小 与嵌套规模同增 隔离性用内存换
典型写法 b = a {...a}、copy.copy structuredClone、deepcopy 按深度需求选

表里有一行容易被忽略:浅拷贝在”改外层基本类型”这一行已经能隔离。也就是说,对象只有一层、字段全是字符串和数字时,浅拷贝和深拷贝的效果没有区别,此时用深拷贝只是白花了开销。

一个常见误解是认为浅拷贝”复制得少所以不安全”。准确的说法是:浅拷贝的语义就是只复制一层,共享内层是设计如此,不是缺陷。真正的问题是使用者以为复制了两层。

内存结构对比:栈里的地址和堆里的对象

基本类型的值直接存在栈上,引用类型在栈上只存一个地址、实际数据放在堆里,这就是深浅差异的物理来源。理解了这两层存储,所有”改了副本原对象也变”的现象都能自己推出来。

内存位置 直接赋值 浅拷贝 深拷贝 说明
栈上的变量 两个变量存同一个地址 两个变量存不同地址 两个变量存不同地址 外层已经分开
堆上的外层对象 只有一份 两份 两份 浅拷贝在此分叉
堆上的嵌套对象 只有一份 还是一份 两份 污染的来源
内层对象的引用计数 不变 增加 各自一份 决定内存回收时机

基本类型(字符串、数字、布尔、null、undefined 等)赋值时传的是值本身,改一个不会影响另一个。引用类型(对象、数组、函数、日期、正则)赋值时传的是引用地址,两个变量指向同一块堆内存,改内容等于改同一份数据。所谓”引用传递”,讲的正是后者。

这也解释了为什么浅拷贝后的修改结果会分两种。改外层字段属于”替换引用”,副本的栈地址本来就和原对象不同,互不影响;改内层嵌套对象属于”原地修改”,两边指向同一块堆内存,于是同时变化。列表的 push、append,字典嵌套赋值,都是原地修改这一类。

实现方式对比:六种写法的能力边界

六种常见写法里,能力差别集中在特殊类型与循环引用上,没有一种能覆盖全部场景。选之前先看数据结构里有哪些类型,再看是否需要处理自引用。

实现方式 复制深度 日期与 Map/Set 循环引用 函数与原型链 适用判断
展开运算符 / Object.assign 仅一层 不适用 不涉及 只复制可枚举自有属性 结构扁平时的默认选择
slice / concat / Array.from 仅一层 不适用 不涉及 同上 数组场景
JSON.parse(JSON.stringify()) 全部层级 日期变字符串,Map/Set 退化 直接抛错 全部丢失 纯 JSON 数据才用
structuredClone 全部层级 支持日期、Map、Set、RegExp、Error 支持 函数、DOM 节点不可克隆 现代环境下的通用选择
递归 + WeakMap 自研 全部层级 需自行分支处理 靠 WeakMap 缓存解决 可自行控制 需要定制克隆规则时
成熟库的 cloneDeep 全部层级 覆盖较全 支持 支持函数 遗留环境或复杂类型

JSON 往返的失真清单

JSON 往返那条要特别小心,它的失真清单比多数人以为的长。undefined、函数、Symbol 会被直接丢掉,Date 会变成字符串,NaN 与 Infinity 会变成 null,正则变成空对象,遇到循环引用会抛异常。只要数据里出现过这些类型之一,就不能用这条路径。

structuredClone 的能力边界

structuredClone 支持循环引用,遇到对象引用自身的结构不会死循环,这是它相对 JSON 往返的直接优势。它同样有边界:函数、DOM 节点、属性描述符与原型链都不在克隆范围内,跨环境调用时还会受宿主实现限制。

下面这段对照代码可以直接跑,输出结果说明浅拷贝的副本改内层会穿透到原对象。

import copy

origin = {"limit": 100, "rule": {"threshold": 80}}
shallow = copy.copy(origin)      # 只复制外层字典
deep = copy.deepcopy(origin)     # 递归复制每一层

shallow["rule"]["threshold"] = 60
print(origin["rule"]["threshold"])   # 60,被穿透
deep["rule"]["threshold"] = 20
print(origin["rule"]["threshold"])   # 60,深拷贝互不影响

Python 的 deepcopy 内部维护了一份已复制对象的记录,用来处理自引用结构,这也是它比手写递归更稳的原因。自研版本要复刻这个行为,用 WeakMap 或字典缓存已访问对象是通用做法。

自研递归实现的关键顺序

function deepClone(value, seen = new WeakMap()) {
  if (value === null || typeof value !== "object") return value;
  if (seen.has(value)) return seen.get(value);       // 命中缓存,断掉环
  if (value instanceof Date) return new Date(value);
  const out = Array.isArray(value) ? [] : {};
  seen.set(value, out);                              // 先登记再递归
  for (const key of Reflect.ownKeys(value)) {
    out[key] = deepClone(value[key], seen);
  }
  return out;
}

这段代码的关键顺序是”先登记再递归”,把新对象写进缓存之后再进入子层,环就断开了。反之,先递归后登记,遇到自引用会栈溢出。需要注意的是,手写实现很难覆盖全部内置类型,生产代码优先用宿主提供的克隆能力或成熟库。

性能与内存开销对比

浅拷贝的成本基本只在外层容器的分配上,深拷贝的成本随节点数量、嵌套深度与类型复杂度线性增长。数据规模越大,这个差距越明显。

开销项 浅拷贝 深拷贝 影响
分配次数 1 次容器分配 每个节点各分配一次 深拷贝的分配量随规模上升
遍历范围 只遍历第一层 遍历整棵可达对象图 大对象差距放大
内存峰值 与原对象基本持平 可能接近原对象的两倍 内存紧张时需评估
触发时机 高频更新也不明显 每次输入、每次渲染都可能卡顿 别放在高频路径上

在交互频繁的路径上做全量深拷贝,代价会被放大很多倍。表单每敲一个字符就深拷贝整个状态树,或者大列表每次筛选都全量克隆,都会让主线程停顿变得可感知。更省的做法是只复制要改的那条路径,把没动的分支继续共享。

结构共享:隔离性不一定要全量复制

这就是结构共享的思路:改动一层就复制那一层,其余引用原样复用,既有隔离性又不付出全量复制的成本。前端状态管理里的不可变更新写法就是这个套路,数组加一项写成 [...items, item],改嵌套字段则逐层展开,只复制路径上的节点。

选型清单:按数据结构挑拷贝方式

选择顺序按层数、特殊类型、调用频率依次判断,按这三步走基本不会选错。下面这组判断可以直接照做。

  1. 先数结构有几层,只有一层且字段是基本类型时用浅拷贝。
  2. 再查是否含函数、类实例、DOM 节点,含这些就排除 JSON 往返与 structuredClone。
  3. 接着查是否存在自引用结构,存在时选带缓存机制的实现。
  4. 核对调用频率,高频路径只复制改动路径,不做全量深拷贝。
  5. 数据结构是纯 JSON 且只要一份独立快照时,JSON 往返可以接受。

对应的场景取舍很清楚:配置快照、数据回滚点、要传给可能修改入参的第三方函数,这类要求完全隔离的地方用深拷贝;只读共享、扁平结构、只需要新容器承载不同顺序的地方,浅拷贝足够且更快。

还有一类对象不该深拷贝:文件句柄、网络套接字、线程对象、数据库连接。它们代表的是外部资源,复制出来的副本没有意义,部分实现会直接报错。这类对象要么按引用传递,要么单独设计资源管理方式。

常见问题(FAQ)

Q1:深拷贝为什么比浅拷贝慢?

深拷贝要遍历整棵对象图,为每个节点分配新内存;浅拷贝只分配一层容器。

Q2:什么时候必须用深拷贝?

需要完全隔离时,比如配置快照、回滚点,或把数据传给可能改入参的外部函数。

Q3:深拷贝会不会影响性能?

放在每次输入或每次渲染的高频路径上会明显卡顿,应改成只复制改动路径。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
小码农的头像小码农认证作者

相关推荐

返回顶部