为什么修改对象副本,会意外地改变原始对象

当我们在代码中,修改一个看似独立的“对象副本”时,之所以会意外地,同步改变了“原始对象”,其根本原因在于我们所复制的,并非对象本身,而仅仅是它的“内存地址”或“引用”。这种现象,源于编程语言对不同数据类型的底层处理机制,其核心逻辑涵盖:源于编程语言中“值类型”与“引用类型”的区分、变量存储的并非对象本身而是其“内存地址”、所谓的“副本”很可能只是地址的“浅拷贝”、多个变量最终指向了“同一个”内存中的对象实例、以及任何一方的修改都会通过这个共享的地址,反映到所有持有该地址的变量上。具体来说,当我们将一个对象变量A赋值给另一个变量B时(B = A),程序并没有在内存中,创建一个全新的、一模一样的对象。

为什么修改对象副本,会意外地改变原始对象为什么修改对象副本,会意外地改变原始对象

它所做的,仅仅是复制了A变量中所存储的、那个指向原始对象的“内存地址”,并将其,存入了B变量。此时,AB,就如同两把指向“同一间房子”的钥匙,无论你用哪一把钥匙开门,进去修改了房子的装修,另一个人,用另一把钥匙开门进来时,看到的,都将是这个已被修改过的房子。

一、问题的“幻象”:看似“两个”,实则“一个”

在编程的旅途中,尤其是对于初学者而言,这是一个极其常见,也极具迷惑性的“灵异事件”。让我们通过一段简单的JavaScript代码,来重现这个问题的“幻象”。

代码场景:JavaScript// 1. 我们先定义一个“原始”的用户对象 let originalUser = { name: "张三", details: { age: 30, city: "北京" } }; // 2. 我们创建了一个看似独立的“副本” let copiedUser = originalUser; // 3. 我们只修改“副本” copiedUser.details.age = 35; // 4. 我们来查看“原始”对象,期望它保持不变 console.log(originalUser.details.age);

预期的结果30

实际的结果35

我们明明只修改了copiedUser,为何连originalUser也一同被“诡异”地改变了? 这个“幻象”的背后,并没有任何超自然的力量,而是隐藏着计算机内存管理的一条最基本的、也最重要的核心原则:变量的“值”与“引用”的根本区别

1. 一个关于“房子”与“钥匙”的说明 为了彻底地理解这个机制,我们可以使用一个简单的生活化说明:

内存中的“对象”:可以被看作是一间真实存在的“房子”。这间房子,包含了所有的家具和信息(即对象的属性)。

程序中的“变量”:则不是房子本身,而仅仅是一把能够打开这间房子的“钥匙”。钥匙上,记录着房子的“地址”。

当我们执行 let originalUser = { ... }; 时,计算机,实际上做了两件事:

在内存的某个地方(通常是“堆”内存),建造了一间包含了namedetails等家具的“房子”(即对象实例)。

然后,它锻造了一把名为originalUser的“钥匙”,并将这间房子的“地址”,刻在了这把钥匙上。

而当我们,执行那句看似“复制”的 let copiedUser = originalUser; 时,计算机,并没有去另外建造一间一模一样的“新房子”。它所做的,仅仅是,去配钥匙的店里,完美地,复制了一把与originalUser一模一样的“钥匙”,并将其,命名为copiedUser

至此,真相大白。我们手中,虽然有了两把“钥匙”(originalUsercopiedUser),但这两把钥匙,所能打开的,是同一间、唯一的“房子”。因此,无论你用哪把钥匙开门,进去把墙刷成蓝色,另一个人,用另一把钥匙进来时,看到的,必然是这面蓝色的墙。

二、核心机制一:“值类型”与“引用类型”

要从更专业的角度,理解上述的“钥匙”与“房子”的区别,我们就必须引入“值类型”和“引用类型”这两个核心的计算机科学概念。

1. 值类型(原始类型)

定义值类型的变量,其存储空间中,直接、完整地,保存着变量的“值”本身

包含的种类:通常包括编程语言中最基础、最原始的数据类型,例如**数字(Number)、布尔值(Boolean)、字符串(String,在某些语言中表现特殊)、nullundefined**等。

复制行为:当对一个值类型的变量,进行复制时,计算机会为其,开辟一块全新的内存空间,并将原始变量的“值”,原封不动地,复制到这个新空间中。此后,两个变量,就变成了两个完全独立、互不相干的实体。JavaScriptlet a = 100; let b = a; // 计算机为b开辟了新空间,并将100这个值复制进去 b = 200; // 修改b的值 console.log(a); // 输出 100。a的值,完全不受b的影响。

2. 引用类型(复杂类型)

定义引用类型的变量,其存储空间中,所保存的,并非是“对象”本身,而仅仅是一个指向该对象在内存中真实位置的“引用”或“指针”(即我们前面所说的“地址”或“钥匙”)。对象实体本身,则被统一地,存放在一个被称为“堆内存”的、更大的共享内存区域中。

包含的种类:通常包括所有由多个值组合而成的、更复杂的“对象”,例如**对象(Object)、数组(Array)、函数(Function)、集合(Map, Set)**等。

复制行为:当对一个引用类型的变量,进行复制时,计算机,只会复制那个“引用”(即内存地址),而不会去复制那个庞大的、位于“堆内存”中的对象实体。

三、核心机制二:“浅拷贝”与“深拷贝”

理解了“引用”的概念后,我们就进入了解决这个问题的核心环节。既然简单的=赋值,只是“复制钥匙”,那么,我们如何,才能真正地,去“克隆”一间“新房子”呢?这就引出了“拷贝”的两个不同层次:“浅拷贝”与“深拷贝”。

1. 浅拷贝:只复制“第一层”的房子

定义浅拷贝,会创建一个新的、顶层的对象。但是,对于这个对象内部,那些属性值,本身,也是“引用类型”(例如,一个嵌套的对象或数组)的属性,它则只会,复制其“引用”,而不会,递归地,去复制那些被引用的对象

代码示例:在JavaScript中,Object.assign({}, original){...original} 都是常见的、用于实现“浅拷贝”的方法。JavaScriptlet originalUser = { name: "张三", details: { // details属性,其值,是一个“引用类型” age: 30, } }; // 使用展开语法,进行一次“浅拷贝” let shallowCopiedUser = {...originalUser}; // 1. 修改顶层的“值类型”属性 shallowCopiedUser.name = "李四"; console.log(originalUser.name); // 输出 "张三"。顶层属性,是独立的。 // 2. 修改嵌套的“引用类型”属性 shallowCopiedUser.details.age = 35; console.log(originalUser.details.age); // 输出 35。嵌套的引用,依然是共享的!

执行过程分析{...originalUser}这个操作,确实,在内存中,为shallowCopiedUser,创建了一个新的、顶层的“房子”。它也将name这个“值类型”的属性,复制了一份。但是,当它处理details这个属性时,它仅仅是,将originalUser.details这把“钥匙”,复制了一份,并放到了新房子里。因此,最终,shallowCopiedUser.detailsoriginalUser.details,这两把位于不同“顶层房子”里的“钥匙”,打开的,依然是同一个、位于内存别处的、那个包含了agecity的“details”小房子

2. 深拷贝:完整地、递归地,克隆“所有”的房子

定义深拷贝,则会递归地,遍历原始对象的所有层级。每当遇到一个“引用类型”的属性时,它都会,为这个属性所引用的对象,也创建一个全新的、独立的“副本”,直至所有层级,都只剩下“值类型”为止

如何实现

方法一:JSON序列化/反序列化。这是最简单、最快捷的深拷贝方法。JavaScriptlet deepCopiedUser = JSON.parse(JSON.stringify(originalUser)); 原理JSON.stringify会将一个对象,序列化为一个纯粹的“字符串”(所有引用关系,都在此过程中被“切断”)。然后,JSON.parse再将这个字符串,重新解析,构建出一个全新的、内存地址完全独立的对象。 局限性:这种方法,虽然简单,但并非万能。它无法处理一些特殊的数据类型,例如,对象中的函数、undefined、日期对象、正则表达式等,在序列化的过程中,都会被丢失或转换。

方法二:手动递归复制。自己编写一个递归函数,来遍历并复制对象的所有属性。

方法三(最佳实践):使用成熟的第三方库。在任何严肃的、生产级别的项目中,最推荐的、也最安全的做法,是使用像Lodash库中的cloneDeep方法这样,经过了充分测试的、能够处理各种复杂和边界情况的专业工具。

四、在实践中“防范”

除了在需要时,进行正确的“深拷贝”,我们还可以,在团队的“编码规范”和“协作流程”中,建立起更主动的“防范”机制。

建立“不可变性”的思维:在现代的前端(如React)和函数式编程范-式中,强烈推荐,采用“不可变性”的思维模式。即,永远不要,去“直接修改”一个已存在的对象或数组。而是应该,在每次需要变更时,都创建一个新的对象或数组,其中包含了你所需要的修改。这种做法,虽然在性能上,可能会有微小的开销,但它能够从根本上,杜绝所有因“意外的副作用”和“共享引用”所导致的、难以排查的缺陷。

明确“函数”的契约:团队的编码规范,应明确规定:一个函数,原则上,不应,修改其接收到的、作为参数的“引用类型”变量。如果一个函数,其核心职责,就是为了“修改”一个对象,那么,它的函数名,应该被清晰地,命名为mutateObject(...)之类的形式,以向调用者,明确地,警示其“副作用”。

代码审查的“火眼金睛”:在进行代码审查时,审查者,应将“检查是否存在对‘共享引用’的不安全修改”,作为一个重要的检查项。

工具的支撑:在 PingCodeWorktile 这样的协作平台中,团队,可以将其共同制定的《关于对象引用与拷贝的最佳实践》文档,沉淀在知识库中,作为所有成员,随时可以查阅和学习的“标准操作流程”。对于一些关键的、高风险的模块,甚至可以在 PingCode任务描述中,明确地,添加一条“检查清单”,提醒开发者,在进行相关修改时,必须对“深浅拷贝”问题,进行二次确认。

常见问答 (FAQ)

Q1: 什么是“值类型”和“引用类型”?

A1: “值类型”,是指变量,直接存储了数据“本身”(例如,数字100)。而“引用类型”,是指变量,存储的,仅仅是一个指向数据真实存放位置的“内存地址”或“引用”(例如,一个对象)。

Q2: 为什么let b = a;a是数字时是复制值,在a是对象时却是复制引用?

A2: 这是由编程语言,对这两种数据类型的、底层的、不同的处理机制所决定的。对于小而简单的“值类型”,直接复制其值,成本很低。而对于可能很大、很复杂的“对象”,如果每次赋值,都进行一次完整的复制,其性能开销,将是无法接受的,因此,语言设计者,选择了只复制其“引用”。

Q3: “浅拷贝”在什么情况下是足够安全的?

A3: 当一个对象,其所有的属性,都是“值类型”(即,它是一个“扁平”的、不包含任何嵌套对象或数组的对象)时,使用“浅拷贝”,其效果,就等同于“深拷贝”,是完全安全的。

Q4: 为什么 JSON.parse(JSON.stringify(obj)) 这种深拷贝方法并不总是可靠?

A4: 因为JSON这种数据格式,其本身,所能表示的数据类型是有限的。它无法表示像“函数”、“undefined”、“日期对象”、“正则表达式”等JavaScript中的特殊对象类型。在stringify(序列化)的过程中,这些类型的信息,会被丢失错误地转换

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 chuangxiangniao@163.com 举报,一经查实,本站将立刻删除。
发布者:百晓生,转转请注明出处:https://www.chuangxiangniao.com/p/639306.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2025年11月12日 12:52:24
下一篇 2025年11月12日 12:52:41

相关推荐

  • 突然就“推理 Agent 元年”了,再聊 AI Chat 与 AI Agent

    今年 3 月份,我们还在以为 ai agent 的新纪元需要等到“泛 agi”,依靠大模型自身的能力和与之相辅相成的一系列技术的发展,诸如 rag、调用链等,去将大模型的能力更深入地“外置”给 agent 单元体。 然而到了下半年,随着大模型自身推理能力的爆发,以及生态中 MCP、ACP、A2A、上…

    2025年12月6日 行业动态
    000
  • Go语言中枚举的惯用实现方式

    本文深入探讨了Go语言中实现枚举的惯用方法,重点介绍了iota关键字的机制与应用。通过详细的代码示例,文章阐述了iota在常量声明中的重置、递增特性及其在生成系列相关常量时的强大功能,并演示了如何结合自定义类型创建类型安全的枚举,以满足如表示DNA碱基等特定场景的需求。 引言:Go语言中的枚举需求 …

    2025年12月3日 后端开发
    000
  • Go 程序沙盒化:构建安全隔离环境的策略与实践

    本文探讨了 Go 程序沙盒化的核心策略与实践。针对运行不可信 Go 代码的需求,文章阐述了通过限制或伪造标准库包(如 unsafe、net、os 等)、严格控制运行时环境(如 GOMAXPROCS)以及禁用 CGO 和汇编代码等手段来构建安全隔离环境的方法。强调沙盒设计需根据具体安全需求定制,并提醒…

    2025年12月2日 后端开发
    000
  • mysql持续交付如何实现_mysql数据库devops

    将MySQL数据库变更纳入版本控制并使用Flyway等工具管理迁移脚本,实现与应用代码同步;通过CI/CD流水线自动化测试、灰度发布和回滚机制,确保数据库交付高效、安全、可追溯。 在现代软件开发中,MySQL数据库的持续交付(Continuous Delivery)是DevOps实践的重要组成部分。…

    2025年12月2日 数据库
    000
  • Go与C++ DLL互操作:SWIG在Windows平台上的兼容性考量与实践

    本文深入探讨了在Windows环境下使用SWIG将Go语言与C++ DLL集成的挑战,特别是当遇到“adddynlib: unsupported binary format”错误时。核心问题在于SWIG在Windows上对Go语言的DLL绑定,其官方兼容性主要集中在32位系统。文章提供了详细的集成流…

    2025年12月2日 后端开发
    000
  • Go语言编译产物体积探秘:静态链接与运行时机制解析

    Go语言编译的二进制文件体积相对较大,主要源于其默认采用静态链接,将完整的Go运行时、类型信息、反射支持及错误堆栈追踪等核心组件打包到最终可执行文件中。即使是简单的”Hello World”程序也概莫能外,这种设计旨在提供独立、高效且无外部依赖的运行环境。 go语言的设计哲学…

    2025年12月2日 后端开发
    000
  • Go语言日期与时间处理详解:time 包核心机制与实践

    Go语言通过其内置的time包提供了一套强大且精确的日期时间处理机制。它以Time结构体为核心,能够以纳秒级精度表示时间瞬间,且在内部表示中不考虑闰秒。time包依赖IANA时区数据库处理复杂的时区和夏令时规则,确保全球时间信息的准确性。本文将深入探讨Time结构体的设计、时区管理,并提供实际应用示…

    2025年12月2日 后端开发
    000
  • 使用 Go 构建时添加 Git Revision 信息到二进制文件

    在软件开发过程中,尤其是在部署后进行问题排查时,快速确定运行中的二进制文件对应的源代码版本至关重要。本文将介绍一种在 Go 语言构建过程中嵌入 Git Revision 信息的方法,以便在程序运行时方便地获取版本信息。 利用 ldflags 在构建时设置变量 Go 语言的 go build 命令提供…

    2025年12月2日 后端开发
    000
  • 深入理解Go语言gc编译器与C语言调用约定的差异

    Go语言的gc编译器不采用与C语言兼容的调用约定,主要是因为Go独特的协程栈(split stacks)机制使其无法直接与C代码互操作,因此保持调用约定兼容性并无实际益处。然而,gccgo作为Go的另一个编译器实现,在特定条件下可以实现与C语言兼容的调用约定,因为它能支持C语言的栈分割特性,从而提供…

    2025年12月2日 后端开发
    000
  • Go应用中嵌入Git修订版本号的实践指南

    本教程详细阐述了如何在Go语言编译的二进制文件中嵌入当前Git修订版本号。通过利用go build命令的-ldflags -X选项,我们可以在不修改源代码的情况下,将项目的Git提交哈希值注入到可执行文件中,从而实现部署后二进制文件的版本追溯和故障排查,提升软件的可维护性与透明度。 在软件开发和部署…

    2025年12月2日 后端开发
    000
  • 使用 ldflags 在 Go 二进制文件中嵌入 Git Revision 信息

    本文介绍如何在 Go 程序编译时,通过 ldflags 将 Git 提交哈希值嵌入到二进制文件中,以便在程序运行时可以方便地查看版本信息,帮助进行问题排查和版本追溯。 概述 在软件开发过程中,尤其是部署到生产环境后,快速定位问题往往需要知道当前运行的二进制文件是由哪个版本的代码构建的。将 Git r…

    2025年12月2日 后端开发
    000
  • 使用 Go 语言计算 SHA256 文件校验和

    本文介绍如何使用 Go 语言计算文件的 SHA256 校验和。通过使用 crypto/sha256 包和 io.Copy 函数,可以高效地处理任意大小的文件,避免一次性加载整个文件到内存中。本文提供了一个简单易懂的示例代码,展示了如何打开文件、创建 SHA256 哈希对象、使用流式处理计算校验和,并…

    2025年12月2日 后端开发
    000
  • Go语言日期处理:如何获取指定日期前一个月的日期

    本文详细介绍了在Go语言中获取当前日期前一个月份日期的方法。通过time.Date函数结合月份参数的直接调整,以及更灵活的time.Time.AddDate方法,可以精确且优雅地实现日期前推一个月的操作。文章提供了清晰的代码示例,并探讨了相关注意事项,帮助开发者在Go项目中高效处理日期计算。 1. …

    2025年12月2日 后端开发
    000
  • Java里如何实现简易记账软件_记账软件开发项目实例解析

    答案:该记账软件实现收支记录、查询、统计与文件持久化。通过Bill类存储账单信息,BillManager管理账单并处理数据存取,Main类提供用户交互菜单,支持添加、查看、查询和统计功能,数据保存至文本文件,程序重启后仍可读取,适合Java初学者掌握面向对象与IO操作。 开发一个简易记账软件在Jav…

    2025年12月2日 java
    000
  • CI/CD流水线多分支部署策略

    主干开发配合功能分支,通过不同分支映射开发、预发、生产环境,结合Git Flow或简化模型实现自动化测试与可控发布,确保代码质量与快速迭代。 在现代软件开发中,CI/CD 流水线的多分支部署策略是支撑高效、安全发布的关键。不同分支对应不同的开发阶段和环境,合理设计部署策略能确保代码质量、加快迭代速度…

    2025年12月2日 后端开发
    000
  • C++与Java I/O性能差异:深入理解与优化策略

    本文深入探讨了在进行大量“hello world”输出时,c++++程序可能比java程序运行慢的原因。主要分析了c++ i/o流同步、`std::endl`的刷新行为、编译优化以及基准测试方法等关键因素。通过应用特定的优化措施,可以显著提升c++ i/o性能,并确保不同语言间性能比较的公平性。 在…

    2025年12月2日 java
    000
  • Go语言中数字补零操作详解

    本文详细介绍了在Go语言中如何为数字添加前导零以达到指定长度的格式化输出。通过利用fmt包的Printf函数及其%0xd格式化标志,开发者可以轻松实现数字的零填充操作,确保输出的字符串具有统一的长度和美观性。 在软件开发中,经常会遇到需要对数字进行格式化处理的场景,例如生成固定长度的序列号、日期时间…

    2025年12月2日 后端开发
    000
  • C++ I/O性能优化:深入解析cout慢速之谜与提速策略

    本文深入探讨了在特定场景下,c++++的`std::cout`为何可能比java的`system.out.println`表现出更慢的i/o性能。通过分析c++ i/o流与c标准库的同步机制、`std::endl`的自动刷新行为、编译优化以及java程序的运行特性,文章提供了详细的优化策略和代码示例…

    2025年12月2日 java
    000
  • Go语言交互式调试指南:从GDB到IDE集成

    本文旨在为Go语言开发者提供一套全面的交互式调试指南,重点阐述了如何利用GDB进行基础调试,以及通过集成开发环境(如Eclipse、LiteIDE、Zeus)实现图形化断点设置和步进调试,从而显著提升开发效率。文章强调了GDB在Go调试中的核心作用及其与IDE结合所带来的优势,为Go开发者提供了清晰…

    2025年12月2日 后端开发
    000
  • 探索Go语言的交互式调试器与IDE集成

    本文探讨Go语言的交互式调试方案。虽然Go原生支持GDB进行调试,但其命令行操作可能不够直观。文章将介绍如何利用集成开发环境(IDE)如Eclipse、LiteIDE和Zeus,通过图形化界面实现断点设置、单步执行等高级调试功能,从而显著提升Go项目的开发效率和调试体验。 Go语言调试:GDB的基石…

    2025年12月2日 后端开发
    000

发表回复

登录后才能评论
关注微信