news 2026/10/18 8:15:42

直接赋值、浅拷贝与深拷贝:内存共享、隔离与工程选型

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
直接赋值、浅拷贝与深拷贝:内存共享、隔离与工程选型

写这篇文章的起因有点丢人——某次项目评审会上,我被当场问了一句"你这段代码为什么不用深拷贝",我愣了一下,然后发现自己在用一堆零散经验解释一个本来应该很基础的概念。回头把"直接赋值、浅拷贝、深拷贝"这一组操作彻底捋了一遍之后,才发现它们看似简单,背后牵扯的引用语义、内存共享、可变性设计,几乎影响了我后来写过的每一个复杂业务模块。这篇内容不废话,直接讲清楚三者是怎么一回事、底层发生了什么、不同语言里怎么实现、真实项目里该怎么选,以及我实际踩过的那些坑。

1. 三个操作的真正分界线:共享到什么程度

很多人背过定义:直接赋值传引用,浅拷贝只拷一层,深拷贝全拷。能背下来,但换一段代码马上就分不清。我后来发现,问题出在没用"共享程度"这个标尺去理解它们。

1.1 直接赋值:连容器本身都是同一个

先说最容易理解、也最容易被低估的直接赋值。在 Python 里写b = a,在 JavaScript 里写let b = a,表面上是"把 a 的值给了 b",但实际上,如果 a 是对象或数组这种引用类型,b 拿到的只是指向同一块内存地址的指针。

这里推荐用快递柜类比:对象本身是快递柜里的包裹,变量名只是柜门上的取件码。你复制了一个取件码给朋友,不代表你把包裹复制了一份,你们俩用各自手上的取件码打开的是同一个柜门。改包裹里的东西,另一个取件码同样看到改动。

这个特性在业务代码里最典型的表现就是:函数传参后,你无意间改了参数对象的某个字段,调用方发现原对象也被改了。这是我接手某个老项目时经常遇到的线上事故来源——大家都以为传进去的是一个副本。

1.2 浅拷贝:复制了一层"外壳"

浅拷贝做的工作是:重新创建一个容器对象,然后把原对象的第一层属性值挨个复制进去。如果某个属性值是基本类型,那复制的是真实值;如果某个属性值本身是对象、数组、字典等引用类型,那复制的是它的引用地址,而不是它的内部结构。

继续用快递柜类比:浅拷贝是你拿了一个新柜子,把原来柜子里所有的包裹标签都抄了一份贴到新柜门上,但包裹本尊还是从原柜子取出来的那只。你可以独立地把新柜子里的某个包裹换成另一个,原对象不受影响;但如果你打开新柜子里的某个包裹,往里面塞了东西,原柜子对应的包裹也会多出东西来。

一个经典的 Python 示例:

original = {"data": [1, 2, 3], "name": "张三"} shallow = dict(original) # 浅拷贝 shallow["data"].append(4) print(original["data"]) # 输出 [1, 2, 3, 4],原对象被连累

很多人以为dict(original)已经是一个独立副本了,等到嵌套结构被修改才恍然大悟。浅拷贝只是解决了"容器本身能不能独立重新装填"的问题,并没有解决"容器内部深层的共享"问题。

1.3 深拷贝:从里到外全部重建

深拷贝的目标是生成一棵和原对象在内存中完全独立的树。原对象里不管嵌套了多少层列表、字典、自定义类实例,深拷贝都会递归地创建对应结构,每层都换新内存,直到叶子节点是基本类型为止。

这么做的好处很直观:你拿到手的东西和原对象毫无血缘关系,怎么改都互不影响。但代价同样直观:对象内部嵌套越深、节点越多,拷贝成本就越高,时间开销和内存开销都不可忽视。如果对象里还有循环引用、文件句柄、数据库连接、线程锁这些没法"值复制"的资源,深拷贝直接炸给你看。

2. 内存视角下的行为差异:从"别名"问题说起

要判断一个操作到底属于三类中的哪一类,在纸面上用箭头图把引用关系画一遍是最快的。这节我直接用可复现的代码配上内存关系说明,把这层窗户纸捅破。

2.1 变量之间究竟存的是值还是地址

不同语言对"值"的定义不一样,这是理解所有困惑的根本前提。在 C 这种语言里,你可以显式区分int b = a(值拷贝)和int *p = &a(地址拷贝)。到了 Java、Python、JavaScript 这类以对象为主的语言里,普通变量的赋值行为往往混合了这两种语义——基本类型按值拷,对象和数组按引用拷。

判断方法很简单:拷完之后,修改b的某个属性,再去看a有没有变化。有变化就是共享了引用,没变化就是值语义。可以自己做一个基准测试:

import copy class Payload: def __init__(self, items): self.items = items a = Payload([1, 2, 3]) b = a # 直接赋值:同一对象 c = copy.copy(a) # 浅拷贝:新对象,但 items 还是同一列表 d = copy.deepcopy(a) # 深拷贝:新对象 + 新列表 print(b is a, c is a, d is a) # True False False print(b.items is a.items) # True,赋值共享内部列表 print(c.items is a.items) # True,浅拷贝共享内部列表 print(d.items is a.items) # False,深拷贝彻底分离

is比较的是对象身份,也就是内存地址。这四个输出的差异,就是三类操作最本质的内存关系差异。

2.2 为什么可变对象会放大隐患

基本类型不是没有共享问题,而是因为基本类型一旦重新赋值,变量名直接换到了另一块内存上,原来的值并没有被修改,所以影响不到别人。但列表、字典、对象属性这些东西不一样:它们是"持久的容器",你可以不改变量指向的容器,而是直接改容器里面的内容。

为了描述这两种修改的区别,我习惯把前者叫"换盒子",后者叫"改盒子里的东西"。直接赋值共享的是同一个盒子,浅拷贝共享的是盒子内部的子盒子,深拷贝则是把所有能看到的盒子都复制了一遍。你真正需要担心的,永远是"改盒子里的东西"这半边。

举个例子,React 社区里著名的不可变数据理念,核心动机就是解决"改盒子里的东西"带来的不可预测性。它要求每次更新状态时都生成一个新的盒子,让旧盒子保持不变,这样对比新旧状态时只需要比较引用地址,性能和安全都兼顾了。而在 Python 默认的可变字典里,你要想做到同样效果,就得手动复制或使用深拷贝。

2.3 引用计数与垃圾回收对拷贝行为的影响

Python 和 JavaScript 这类带自动内存管理的语言中,对象何时被回收,取决于还有没有引用指向它。直接赋值会新增一个引用,导致同一个对象被更多人持有;深拷贝会创建一个新对象,并把原对象和副本变成完全无关的孤岛,两者各自被回收。浅拷贝则处于中间状态——外层新对象互不干扰,但内层共享对象引用计数会额外增加一份。

这带来一个隐藏的问题:浅拷贝的共享内层对象生命周期会被意外延长。比如你用一个浅拷贝保存历史快照,结果快照里持久引用着一个超大的数据列表,哪怕原始对象早就该释放了,这个列表也因为快照还活着而始终占用内存。写缓存、写快照系统的时候尤其要小心这种不经意间的内存泄漏。我曾经在某个模拟项目里见过一个很典型的案例:一个服务每隔几秒就把运行时状态浅拷贝进历史列表,结果历史列表里全引用着同一个大数组,内存曲线一路上涨,重启才能缓解。

3. 主流语言中的实现路线与封装方案

这个概念在不同语言里有不同的最佳实践,我挑实际工程中常用的几个语言来讲。每个语言都附上我验证过的代码和注意事项。

3.1 Python:copy 模块、切片与自定义deepcopy

Python 标准库copy模块提供了两个入口:

import copy shallow = copy.copy(original) deep = copy.deepcopy(original)

此外,一堆看似和拷贝无关的写法也能达到浅拷贝效果:list(original)、original[:]、original.copy()、字典的dict(original)。但要注意,它们全都是浅拷贝,不是深拷贝。这是 Python 初学者最常误解的地方。

如果对象里有自定义类,深拷贝默认会递归复制所有属性。但某些资源型属性需要特殊处理,可以覆写__deepcopy__方法,在被拷贝时返回一个全新的资源代理,或者干脆跳过拷贝:

import copy class Connection: def __init__(self, url): self.url = url self.connected = True def __deepcopy__(self, memo): # 网络连接不应该被复制,新建一个未连接实例即可 new = Connection(self.url) new.connected = False return new

memo参数用来记录已经拷贝过的对象,内部依赖它处理重复引用和循环引用。你自己写递归深拷贝时一定要记得维护一份"已拷贝映射",不然遇到自引用结构会直接爆递归深度。

另外还要专门提醒:copy.deepcopy虽然会处理循环引用,但性能远低于手写浅拷贝。大量嵌套数据对象做反复深拷贝时,建议做一次性能测试,不要想当然直接用。实测下来,几千个节点的字典深拷贝耗时大约是浅拷贝的几十倍,这个差距在热路径上足以造成可感知的卡顿。

3.2 JavaScript:展开运算符、JSON.parse 与结构化克隆

JavaScript 中浅拷贝最常见的写法是展开运算符:

const copied = { ...original }; const arrCopy = [...originalArr];

它等价于Object.assign({}, original),只复制一层。嵌套对象仍然共享。

很多人网上流传的"深拷贝终极写法"其实是指这样:

const deepClone = JSON.parse(JSON.stringify(original));

这种写法的优点是简单,缺点是会把undefined、函数、Date、Map、Set、正则表达式、循环引用全部搞坏或者丢掉。比如Date会被转成字符串,Map会被转成空对象。在业务对象里混着配置字段时,这类隐藏问题尤其阴险——数据表面没变,类型已经不对了。

浏览器环境和 Node 环境现在都有更靠谱的选择:structuredClone。它是 HTML 标准提供的结构化克隆算法,能完整处理Date、Map、Set、ArrayBuffer,甚至能处理循环引用,只要求对象可序列化(函数和 DOM 节点等不可序列化类型依然会被拒绝)。

const deepClone = structuredClone(original);

如果你需要兼容旧浏览器,或者需要拷贝函数和原型链,那只能手写一个基于 WeakMap 记忆化的递归克隆函数。核心逻辑类似:

function deepClone(obj, memo = new WeakMap()) { if (obj === null || typeof obj !== "object") return obj; if (memo.has(obj)) return memo.get(obj); const temp = Array.isArray(obj) ? [] : {}; memo.set(obj, temp); for (const key of Object.keys(obj)) { temp[key] = deepClone(obj[key], memo); } return temp; }

这段代码只处理普通对象和数组,但能正确应对循环引用。工程上想支持更多类型,就得像structuredClone一样按 tag 分派处理。

3.3 其他语言与框架中的常见选择

在 Java 的世界里,clone()方法历来是坑王。默认的Object.clone()是浅拷贝,而且要求类实现Cloneable接口,否则抛异常。很多企业级代码里的"深拷贝"实现是手动逐字段new一个新对象再逐个赋值,或者用序列化方案(如把对象写进字节流再读回来),后者性能开销大,但胜在通用。

在 Go 这类没有内建深拷贝函数的语言里,最朴素的深拷贝方案是手写结构体字段赋值,或者用encoding/json来回序列化。如果结构体字段少、嵌套浅,序列化方案完全够用;但大量使用指针、接口、time.Time、[]byte时,序列化方案会引入很多刁钻的坑。

框架层面也有不少现成方案。比如 Vue 的reactive内部会深度代理对象,React 的不可变数据结构思想,都建立在"数据不能被随意二次修改"这一共识之上。而像 Immer 这类库,本质上是把深拷贝和变更操作封装在了一个 draft 机制里:你写改 draft 的代码,它帮你产出深拷贝后的新状态。这也侧面说明了一件事实:深拷贝不是最终目的,隔离变更影响才是。

4. 实际项目中的选型逻辑:什么时候必须深拷贝

知其然之后,更关键的其实是"什么时候用哪个"。我以自己参与过的真实类型场景为例展开说,读者可以对照自己的项目找相似处。

4.1 表单编辑与"保存前草稿"场景

做后台管理系统的编辑页时,最常见的困境是:用户点开一个已经存在的记录,修改了几个字段,点了取消,却发现原数据已经被改掉了——如果编辑时直接操作原对象,就会出这种事。

我当时的做法是:打开编辑页时,立刻对原始数据做一次深拷贝,存入编辑 store,表单绑定深拷贝数据。用户改得再疯,原始数据都稳如泰山。只有点保存时,才用编辑数据去覆盖原始数据。

为什么不用浅拷贝?因为表单提交的数据经常是嵌套结构,比如包含地址对象、标签数组。浅拷贝只复制了第一层,用户修改嵌套地址里的街道字段时,原对象的地址街道一样变。这个坑我同事踩过,线上反馈"为什么我只是改了城市,另一个页面的同一条记录也跟着变了",定位到根因时大家都不太想承认是拷贝写错了。

但也不是所有表单都要深拷贝。如果只是编辑一个只有基础字段的对象,浅拷贝足够,因为第一层属性全是基本类型,浅拷贝已经完成了完全隔离。选型的关键是看对象结构里有没有可变引用类型的嵌套。大多数时候,为了省心,可以直接深拷贝,代价是多一点性能消耗。

4.2 状态管理库里的不可变数据约束

在 React 这类依赖虚拟 DOM diff 的框架里,状态的每次更新都应该产生新引用,否则框架无法感知变化。Redux 的 reducer 要求你不能直接修改 state,每一次返回的必须是一个新对象。这里深度拷贝扮演的角色是:生成一个全新的状态树,再对需要变化的分支做替换。

实际工作中更常见的是手动展开的办法:

return { ...state, user: { ...state.user, name: "新名字" } };

这种手动分层展开的效果和"按需拷贝"是一样的:改动路径上的每一层都换新,没有改动的分支仍然共享旧引用。这样既保证了引用变化,又不必对整个状态树做一次全量深拷贝,性能上远优于无脑structuredClone。

如果你的团队代码里到处都在用JSON.parse(JSON.stringify(state))做更新,我会建议认真优化一下了。即便不追求极致性能,也可以借助 Immer 这样的库写出更清晰的更新逻辑,而不是每次都暴力拷全树。数据量一旦上去,暴力拷贝的频率和内存抖动都会让页面明显变慢。

4.3 缓存、并发与暂存快照场景

做多线程或异步并发处理时,共享可变对象是一个高频事故点。比如两个线程同时读一个配置对象,一个线程在里面临时改了某个字段准备重算,另一个线程读到的数据就不一致了。Google 的很多服务都对不可变对象做严格约束,就是为了让并发程序更容易推理。

缓存场景也一样:从缓存里取出来的对象,如果不做拷贝就直接交给业务方修改,修改结果可能会被写回缓存,也可能污染给下一个取用方。我维护的一个某跨平台系统中就出过类似事故:一个接口把内存缓存里的列表直接返回给前端,前端顺手改了列表里某一项,后来别的接口再读缓存,就发现数据被人为改动过,排查了大半天才发现"元凶"是前端的顺手修改。

正确的做法是:缓存层负责返回拷贝,除非你明确希望调用方操作同一个对象。返回浅拷贝还是深拷贝取决于对象嵌套程度,最简单的统一约定是——对外返回一律深拷贝,在内部热点路径上再按需优化成浅拷贝或手写按字段复制。规则越简单,越不会有人用错。

5. 极易踩坑的几类典型场景:排查链路复盘

这一节把我在真实项目里遇到过的意外汇总在一起。每个场景都按"现象、排查过程、根因、正确姿势"的顺序讲,让读者踩过坑后能直接举一反三。

5.1 浅拷贝修改嵌套字段,"连累"原对象

某个模块的代码大致是这样:

base_config = {"name": "service", "params": {"timeout": 3, "retries": 5}} def adjust(config): config_copy = dict(config) config_copy["params"]["timeout"] = 30 return config_copy adjust(base_config) print(base_config["params"]["timeout"]) # 输出 30,原数据被改

排查的第一步永远不是看代码逻辑,而是确认改的是"哪个盒子"。在adjust函数里加一行print(id(config["params"]), id(config_copy["params"])),立刻能看到两个 key 指向同一个params字典。第二步回头检查测试用例,发现测试只断言了返回值的timeout,没有断言原对象的timeout,因此这个问题在测试阶段就漏掉了。

正确的姿势说起来简单:确实需要保留原对象时,对config使用copy.deepcopy。但有些时候你并不想复制整个大配置,只是想改其中一个嵌套字段,那也可以手动复制需要修改的那一层:

new_config = {**base_config, "params": {**base_config["params"], "timeout": 30}}

这样做的好处和 React 手动展开状态树一致,只换需要换的节点,其他节点继续共享,性能和隔离能力都照顾到了。这是我在实际工程里最推荐的一种"按需浅拷贝"模式。

5.2 深拷贝无法处理自定义对象的隐藏问题

用copy.deepcopy拷贝普通字典很顺利,一旦对象里混入文件句柄、线程锁、socket 连接、数据库连接池这类资源,就会触发一类很难排查的异常。文件句柄虽然能被拷贝出"另一个对象",但底层 fd 指向的还是同一份系统资源,关闭一个会影响另一个。线程锁被复制后更是形同虚设,两个"锁对象"不再指向同一把锁,互斥就失去了意义。

我在某图像处理 Demo 里遇到过类似问题:某个缓存对象持有图像数据的 numpy 数组,deepcopy会把数组完整复制一份出来,内存直接翻倍,原本流畅的处理流程瞬间卡顿。当时做了内存分析才发现,热点路径上每次请求都在复制一张完整的高分辨率图像,完全没必要——图像数据本身是只读的,多个请求共享它反而更合理。最后的方案是把不可变的大数组单独存放在只读区域,对象拷贝时只复制引用,同时约定任何人都不能原地修改数组内容。

这个经历给了我一个很重要的判断标准:深拷贝之前,先想清楚对象图里的每个节点到底是"数据"还是"资源"。数据需要隔离,资源应该共享。资源型节点要主动跳过或特殊处理,而不能一股脑全拷。

5.3 循环引用与性能陷阱

循环引用不仅会让手写递归函数爆栈,也会让某些不成熟的深拷贝实现行为异常。copy.deepcopy和structuredClone都内建了循环检测机制,分别通过memo参数和内部WeakMap记录已完成拷贝的对象。但不要因此放松警惕:一旦嵌套深度超过递归限制,仍然会报RecursionError或栈溢出。

性能陷阱的经典案例是:在渲染循环里对同一个大对象反复深拷贝。我见过一段代码,每帧都对场景配置做一次copy.deepcopy,原本 60 帧的流畅动画被拖累到十几帧。优化时先看拷贝频率,再决定是缓存拷贝结果还是改用按需浅拷贝。高频路径上,深拷贝的频率应该无限趋近于零。

教训总结成一句话:能共享就别复制,必须复制就用浅拷贝,浅拷贝不满足隔离需求才考虑深拷贝,而且深拷贝的对象图应当经过设计,而不是让它默默接管一切。

6. 面试与团队代码评审中的正确表达方式

这个话题既然常常被称为"面试题",最后就聊几句表达层面的事。很多候选人能写出深浅拷贝的代码,却讲不清三者区别的内在逻辑,归根结底是缺少一个简洁的心智模型。

6.1 面试官爱问这道题背后的真实关切

面试官不是想考 API 记忆,而是想通过这道题判断三件事:第一,候选人是否理解引用语义和值语义的差异,这决定了他在多线程、状态管理、缓存场景里犯错的可能性;第二,是否清楚语言底层的内存模型,这决定了遇到诡异数据问题时,直觉排查方向对不对;第三,代码评审时能不能解释清楚"为什么这里必须深拷贝"。对团队来说,代码里的每一处拷贝都是一条隐藏契约,写错了影响的是整个模块的稳定性。

理解了这层动机,面试时的重点就不再是背诵三种操作的定义,而是主动讲出它们背后的对象图差异、性能代价、适用边界,并给出一个自己踩过的坑。我见过许多候选人对拍字节码细节并不熟,但把"共享引用导致状态污染"的经历讲得很透彻,面试官一样会给高分。

6.2 用一句话快速讲清三者关系

我的总结是:

直接赋值是给同一个对象起别名;浅拷贝是复制容器但不复制容器里的内容,嵌套结构仍然共享;深拷贝是递归复制整棵对象树,得到完全独立的副本。

如果面试官追问"怎么做深拷贝",再展开语言层面的 API。追问"哪些场景必须深拷贝",就讲表单编辑、状态更新、缓存隔离。追问"怎么避免性能问题",就讲按需浅拷贝和结构共享。这个回答结构能覆盖绝大多数追问方向,而且每一层都有足够深的内容支撑。

6.3 一个纯粹的选型经验建议

最后分享一个我一直在用的判断顺序:先问"这个对象会被别人修改吗",不会就什么都不做,直接用原始引用;会,再问"嵌套结构需要一起隔离吗",不需要就浅拷贝,需要就深拷贝;深拷贝时先检查对象图里有没有资源型节点,有就为它们定制拷贝策略。这套流程从需求出发层层递进,能覆盖绝大多数场景,还能在评审时把理由讲得清清楚楚,而不是丢下一句"这里深拷贝一下"就完事。

说到底,拷贝操作本身只是手段,数据隔离和共享的边界才是真正需要设计的东西。把这条主线想明白了,深拷贝、浅拷贝、直接赋值这三兄弟在你眼里就不再是三道需要背答案的题,而是一组可搭配的工程工具。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/18 8:14:56

GitHub 热门项目剖析:当“代码知识图谱”撞上 AI 编程助手,我们真的需要它吗?——用 TaoToken 统一 Key 实测 Cursor Base URL 与 Cline MCP 配置

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/18 8:14:40

常驻Agent四层架构全解析:从会话管理到模型调度

2. 四层构造的全景拆解:从会话外壳到底层内核聊到常驻Agent的内部构造,大多数人第一反应是“不就是一个大模型套个Prompt吗”。但实际拆解下来你会发现,一个能稳定常驻运行、不外挂业务逻辑、能自己驱动工具链的Agent,它的内部结构…

作者头像 李华
网站建设 2026/10/18 8:14:15

手机与AI的双向改造:从端侧大模型到智能体生态

地铁上,旁边一位乘客把手机对着摊开的会议记录拍了张照,两秒后屏幕上弹出一版整理好的文字稿,标题、要点、待办事项分得清清楚楚。他把这段文字转发出去,整套动作行云流水,头都没抬。这一幕放在三年前不太敢想&#xf…

作者头像 李华
网站建设 2026/10/18 8:11:05

从极简命名到完整实现:响应式数据模块rea的设计与落地

1. 从“rea”这个标题说起:一个极简命名背后的完整项目思维第一次看到“rea”这个标题,很多人会愣一下——三个字母,没有上下文,没有说明,甚至连大小写都没区分。但恰恰是这种极简命名,在真实的项目开发中非…

作者头像 李华
网站建设 2026/10/18 8:10:10

C++ STL list容器详解:从接口用法到底层实现手写模拟

如果你刷过C算法题或写过相关业务代码,大概率遇到过这种场景:需要在一个大型对象的集合里频繁做头部插入、中间删除,结果用std::vector一跑,数据量一上去立刻卡顿;换std::list之后,插入删除瞬间变得很轻快。…

作者头像 李华
网站建设 2026/10/18 8:09:37

diagram-design:用文本驱动将图表变为可维护的代码资产

1. 从一张草图到一套系统:diagram-design 到底在解决什么问题第一次看到 diagram-design 这个词,很多人会以为它只是“画图工具”的又一个新名字。但真正在项目里被图表折磨过的人会明白,它指向的是一套更底层的东西:把“图”从一…

作者头像 李华