ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

PHP对象克隆深挖:浅克隆陷阱与深克隆方案对比

PHP对象克隆深挖:浅克隆陷阱与深克隆方案对比 1. 从一次线上事故说起克隆对象到底复制了什么1.1 当时代码看起来毫无问题先说一个我真实踩过的坑。前些年做电商系统有个配置对象ProductConfig包含了基础售价、库存预警线还有一个Supplier供应商对象属性。某次需求是“复制一个商品配置改改价格再另存为新商品”我觉得这不是轻轻松松的事吗直接clone旧配置改价格保存。代码大概长这样$oldConfig ProductConfig::find($id); $newConfig clone $oldConfig; $newConfig-setPrice(99.9); $newConfig-save();本地测试没问题一上预发布环境麻烦来了原本商品的供应商信息全被改成了同一个人线上商品池直接乱套。当时第一反应是“是不是 SQL 更新条件写错了”排查了两三个小时最后发现问题居然出在这个clone上。原因不复杂但我确实彻彻底底忽略了一点PHP 默认的对象克隆是浅克隆。$oldConfig内部的Supplier对象是引用传递clone只是把外层对象的内存空间复制了一份而内层的Supplier属性仍然指向同一个实例。也就是说新旧两个商品配置文件共享着同一个供应商对象。新配置里改了供应商旧配置也跟着变数据库里就是一堆商品一起“被换供应商”。这个事故给我的教训非常深刻。之后我凡是看到代码里出现clone关键字都会下意识地追问一句这个对象内部有没有对象类型的属性如果有那是不是真的只需要浅克隆1.2 PHP 默认 clone 行为拆解值复制不彻底要彻底理解浅克隆得先搞清楚 PHP 对象在内存中到底长什么样。你看一个普通的赋值操作$a new stdClass(); $a-name demo; $b $a; $b-name changed; echo $a-name; // changed这里$a和$b压根就是同一个对象句柄指向同一个内存地址。而clone的区别在于它会新分配一块内存把外层对象属性的“值”复制过去但所有属性里本身是“对象句柄”的依然和原对象指向同一个目标。我用大白话打个比方浅克隆像是你去复印了一份合同封面封面上印的公司名字会跟着原件的修改一起变因为复印的只是那张纸的影像而不是把公司本身也复制出来。如果你要的是“独立的一份合同”那光复印封面远远不够底层那个人、那个章、那家公司全都得重新弄一套。PHP 官方对clone的定义也很直白clone $obj会创建对象的一个浅拷贝然后如果类里定义了__clone()魔术方法会在拷贝完成后自动调用一次。注意这个顺序先完成所有属性的浅复制然后再调用__clone()。这也是为什么很多人会在__clone()里手动去处理内层对象属性因为那是唯一能改变浅克隆默认行为的时机。属性类型clone 后的行为基本类型int、string、bool、float值完全复制互不影响数组数组本身复制一份但数组内对象元素仍是引用共享对象类型属性共享同一个对象实例修改一处全处变化引用属性引用关系保留和新对象依然指向同一个变量静态属性不属于具体实例clone 不会复制也不应通过实例访问看到表格里“数组内对象元素仍是引用共享”这一条了吗这是最阴的坑之一。外层是数组你总觉得数组复制了应该安全其实里面装的每个对象还是同一个改其中一个元素对象另一份数组里的对应对象照样一起变。1.3 绕不开的 __clone 魔术方法当类中定义了__clone()方法它在浅拷贝完成之后被自动触发。这个方法没有任何参数也没有返回值。通常我们在这里对内层对象做进一步的克隆。class ProductConfig { public Supplier $supplier; public function __clone() { $this-supplier clone $this-supplier; } }这样改造之后clone $oldConfig时外层先复制一份新的ProductConfig然后紧接着进入__clone()把里面的Supplier也克隆一份。此时新旧配置的supplier指向不同的实例互不干扰。但手写__clone()有个天然缺陷每加一个新对象属性你都得记着回来补一行$this-xxx clone $this-xxx;否则就是新的浅克隆漏洞。团队开发时别人往类里塞了一个对象属性没留意__clone()线上事故再次上演。这不是技术难不难的问题而是维护成本非常高。2. 深浅克隆的边界哪些场景必须做深克隆2.1 典型的业务场景配置复制、订单快照、流程实例什么业务场景最容易踩浅克隆的坑我总结下来就三类。第一是配置复制。比如一个营销活动复制成另一个活动内部引用了相同的优惠券模板、相同的门店对象如果不做深克隆改新活动的规则时旧活动的规则也就跟着变了。第二是订单快照。下单时快照一份商品信息后续商品本身怎么改都不应该影响历史订单数据如果你草率地clone $product存进订单商品后续改了可能造成订单里面的商品信息跟着变财务对账就很麻烦。第三是流程实例工作流引擎里一个流程模板复制出一个新流程内部的节点对象如果不深层复制修改新流程节点老流程模板的节点也被污染。这里有一个很容易忽略的共性这些场景要求的是“数据的历史独立性”。我要的是当时那一份副本原对象后续怎么变跟我的副本无关我的副本怎么改也不能回写到原对象。用术语说就是完全的值语义。浅克隆无法提供这种独立性因为它只复制了最外面一层壳壳里面的东西依然共享。反过来也有浅克隆完全够用的场景。例如一个对象内部只有标量属性、字符串属性、数组数组里也没有对象那么默认clone的效果和深克隆没有区别。再比如某些不可变对象immutable object内部属性都是只读的那共享也无所谓。还有 DTO 传输对象只是临时传递数据不会在被复制之后修改内部状态浅克隆通常也行。所以并不是看到对象就无脑深克隆要去判断到底有没有“可变性”传播。2.2 数组里藏了对象的隐蔽坑新手最容易翻车的是数组和对象混在一起的数据结构。你复制了一个数组以为安全其实数组里面的对象元素根本没复制。我见过一个典型的例子导出批次任务要把一批任务列表复制一份去重试每个任务元素是个对象。$tasks [ new Task(download), new Task(parse), new Task(send), ]; $backup $tasks; // 数组复制了 $backup[0]-status running; // 原 $tasks[0] 的状态也被改了$tasks和$backup是两个不同的数组容器但里面装的Task对象还是同一个。所以在写代码之前先自问一句我要复制的是容器还是容器里的每一样内容判断方法也很简单。用debug_zval_refcount或者直接对比两个对象的属性对象 ID 就能确认是否共享。更实际的做法是写一个单元测试把对象克隆之后用assertNotSame检查内部对象是否不同这样至少能在提交前把问题暴露出来。3. 深克隆的四种主流方案横向对比3.1 手写 __clone精准但维护成本高最标准的做法就是前面提到的__clone()方法里手动克隆对象属性。它的优势是性能好而且你可以控制到底哪些属性需要克隆哪些属性保持共享。比如某个类里有一个日志记录器Logger这个对象你其实希望全局共享同一个那就不要在__clone()里克隆它。手写方式的劣势在维护成本。每次属性变更都要想着回来更新__clone()否则对象属性一多很容易漏。而且当对象层级很深时每一层类的__clone()都得写对任何一层漏了整条链路上就埋了一颗雷。这个方案比较适合对象结构稳定、属性数量少、团队成员对克隆语义都熟悉的场景。3.2 serialize/unserialize三行代码的优雅魔法PHP 的序列化机制可以把一个对象完整地转换成字节流再反序列化回来得到的就是一个全新的、所有内层对象都独立的对象。$deepCopied unserialize(serialize($original));就这么简单。原理也不难理解serialize会把对象内部所有属性值、所有嵌套对象的结构完整转成字符串unserialize再从字符串重建整个对象图。过程中每个对象都会被 new 一遍所以不存在任何引用共享问题。但这个方案有两个副作用必须知道。第一是序列化过程中如果对象里有闭包Closure直接抛出Exception: Serialization of Closure is not allowed。第二是反序列化会触发__wakeup()魔术方法如果你的类在__wakeup()里有重新连接数据库、初始化资源之类的操作那性能消耗和不必要的副作用都要算进去。序列化还会把对象里定义的private属性以特殊字符形式写入字符串如果类里有敏感信息序列化后的缓存或日志输出会有信息泄露风险。3.3 json_encode/json_decode方便但坑多有朋友会尝试用 JSON 中转实现深拷贝$deepCopied json_decode(json_encode($original), true);这里的问题非常多。第一json_encode只会处理可见属性private属性不会进入 JSON 输出克隆出来的对象等于丢了一堆属性。第二json_decode得到的是多维数组或stdClass对象原来的类信息彻底丢失后续调方法就直接报错。第三如果属性里有非线性数据或者二进制内容JSON 序列化过程可能直接出错。所以除非你明确知道自己要的只是一个纯数据数组而且这个对象里的属性能被 JSON 完整表达否则这一招就当作“演示用”跑跑看不要上生产。我在实际开发中真正用 JSON 方案克隆的场景是原始对象已经明确是一个 JSON 友好的 API DTO并且克隆之后也只打算继续当数组用其他情况一律不碰。3.4 Reflection API 递归克隆通用工具的最优解既然手写__clone()维护成本高序列化又有副作用那就用反射Reflection在运行时动态读取对象的所有属性再对每个属性逐一递归处理。这个方案可以做成一个通用的工具方法什么类都能丢进去深克隆一遍不需要侵入业务类去改__clone()。function deepClone($object) { $ref new ReflectionObject($object); $clone clone $object; foreach ($ref-getProperties() as $prop) { $prop-setAccessible(true); $value $prop-getValue($clone); if (is_object($value)) { $prop-setValue($clone, deepClone($value)); } elseif (is_array($value)) { $prop-setValue($clone, deepCloneArray($value)); } } return $clone; } function deepCloneArray(array $array) { $result []; foreach ($array as $key $value) { if (is_object($value)) { $result[$key] deepClone($value); } elseif (is_array($value)) { $result[$key] deepCloneArray($value); } else { $result[$key] $value; } } return $result; }Reflection 方案避开了__clone()要逐个类维护的毛病对已有业务类几乎是零侵入。不过反射本身有性能开销在循环里大量深克隆对象时不能说完全没影响但大多数业务场景根本达不到瓶颈。而且反射可以处理private和protected属性这是序列化方案也做不到的序列化可以处理但 JSON 不能通用性很足。方案代码量性能损耗是否处理 private是否处理闭包侵入性手写 __clone中等极小可以可以高serialize/unserialize极低中等可以不能低json_encode/decode极低中等不能不能低Reflection 递归较高中等偏高可以可以极低4. 实操环节写一个通用深克隆工具类4.1 完整实现考虑继承、循环引用和性能我最终在项目里沉淀下来的是一个名为ObjectCloner的工具类。和前面演示的极简版本不同生产级版本必须处理三个复杂点继承属性、循环引用、以及对象标识保持。继承属性这点容易被忽略。ReflectionObject的getProperties()默认只能拿到当前类的属性父类的private属性是拿不到的。解决办法是用getParentClass()逐级往上遍历或者更简单直接合并每一层的属性列表。循环引用不是到处都有但一旦有单纯递归就会死循环。例如一个Node对象它的parent属性指向父节点父节点的children数组里又包含它自己。对这样的对象做深克隆递归会无限套娃。解决办法是用一个SplObjectStorage记录已经克隆过的原始对象和克隆结果的映射遇到已经处理过的对象就直接返回之前克隆的那一份既防止死循环也保证同一个原始对象在克隆结果里依然是同一个对象保持对象图结构的一致性。class ObjectCloner { /** * 深克隆对象保持对象图结构支持循环引用 */ public static function deepClone(object $object): object { $refMap new \SplObjectStorage(); return self::doClone($object, $refMap); } private static function doClone(object $object, \SplObjectStorage $refMap): object { if ($refMap-contains($object)) { return $refMap[$object]; } $clone clone $object; $refMap[$object] $clone; $reflection new \ReflectionObject($object); $properties self::getAllProperties($reflection); foreach ($properties as $prop) { $prop-setAccessible(true); $value $prop-getValue($clone); if (is_object($value)) { $prop-setValue($clone, self::doClone($value, $refMap)); } elseif (is_array($value)) { $prop-setValue($clone, self::cloneArray($value, $refMap)); } } return $clone; } private static function cloneArray(array $array, \SplObjectStorage $refMap): array { $result []; foreach ($array as $key $value) { if (is_object($value)) { $result[$key] self::doClone($value, $refMap); } elseif (is_array($value)) { $result[$key] self::cloneArray($value, $refMap); } else { $result[$key] $value; } } return $result; } /** * 递归收集当前类及所有父类的属性 */ private static function getAllProperties(\ReflectionObject $reflection): array { $props []; $current $reflection; while ($current) { foreach ($current-getProperties() as $prop) { if (!array_key_exists($prop-getName(), $props)) { $props[$prop-getName()] $prop; } } $current $current-getParentClass(); } return array_values($props); } }这段代码的核心思路是先浅克隆一份然后把每个属性取出来递归判断遇到对象就继续深克隆遇到数组就遍历数组元素再深克隆遇到基本类型直接保留。SplObjectStorage在这里起到了“记忆”作用让整个克隆过程变成有向图遍历而不是一棵无脑递归的树。4.2 如何接入现有业务代码这个工具类不要求业务类实现任何接口也不要求改__clone()方法拿来直接用就行。$newConfig ObjectCloner::deepClone($oldConfig); $newConfig-setPrice(99.9); $newConfig-save();原来的clone $oldConfig换成ObjectCloner::deepClone($oldConfig)其他代码完全不用动。因为返回值仍然是ProductConfig类的实例类型不会变IDE 也能正常提示方法。如果有些类里的特定属性希望保持共享比如全局日志对象、连接池对象可以加一个黑名单机制。在工具类里维护一个数组属性名或类名命中黑名单的就不递归直接保留引用。这块可以根据实际业务灵活扩展但不要为了通用而通用把工具类写得太重反而难以维护。4.3 性能实测与使用建议我拿一个包含 5 层嵌套对象、每层有 3 个对象属性的复杂结构做过简单测试Reflection 方案和serialize/unserialize方案性能接近差异在毫秒级手写__clone()最快但那是用工时代码换运行时效率。结论是如果只是在某个业务动作里偶尔克隆一两个对象三个方案性能根本不构成决策因素。真正要考虑的是代码的可维护性和边界异常。生产环境我最终选择 Reflection 方案因为它是唯一一个让调用方“不用想太多”的方案。新同事接手代码时看到ObjectCloner::deepClone()就知道这里要的是完全独立的副本不用去关心目标类内部到底有哪些对象属性。5. 常见问题与排查技巧实录5.1 引用属性没有随对象复制有一类属性是用引用绑定到某个外部变量的比如class ConfigBag { public array $data; }clone之后$clone-data依然和原来的$data指向同一个变量空间。Reflection 递归方案对这种情况也没辙因为getValue拿到的只是当前值拿不到引用关系本身。碰到这种代码你要么把引用属性改成普通属性要么需要复制值之后切断引用最直接的做法是构造函数里不再用引用传递。5.2 单例对象被 clone 导致全局状态错乱单例模式的类如果把__clone()设置成private外部代码没法克隆能直接挡住一半误用。但有些经验不足的开发者没留意这一点或者早期写的单例类没有封__clone()就可能在别的代码里被克隆出一份“假单例”。如果单例内部维护了连接状态、缓存数据克隆出来的实例改了状态原单例也逃不掉因为它们共享的对象属性还是同一批。排查方法是搜代码里有没有出现clone同一个类标识符例如clone SomeSingleton::getInstance()这种写法一旦出现立即检查__clone()是否已封禁。5.3 序列化克隆触发了 __wakeup 和闭包异常用serialize/unserialize方案时反序列化会触发__wakeup()方法。如果类里__wakeup()会重建数据库连接、重新加载配置文件那每次深克隆都会有一笔额外开销。更麻烦的是闭包序列化闭包直接抛异常。我发现用这个方案最安全的前提是对类有完全的了解确认类及其所有对象属性都不包含闭包和不可序列化资源。5.4 快速判断对象是否共享的小技巧调试时想快速判断两个对象内部的某个子对象是不是同一个实例可以用spl_object_id()这个方法。它返回对象的唯一 ID共享的对象这个 ID 一定相同。$old new ProductConfig(); $new clone $old; var_dump(spl_object_id($old-supplier) spl_object_id($new-supplier)); // true 表示共享false 表示已经独立这个技巧在排查克隆问题时非常高效比打日志看属性变化要直观得多。如果再配合 PHPUnit 写一个断言测试public function testCloneSupplierIsIndependent() { $old new ProductConfig(); $new ObjectCloner::deepClone($old); $this-assertNotSame($old-supplier, $new-supplier); }每次重构克隆逻辑后跑一遍这个测试基本能把浅克隆事故挡在单元测试这一层。问题现象可能原因排查方式解决方案克隆后修改对象属性影响了原对象内部对象属性被共享spl_object_id对比深克隆或__clone处理序列化克隆抛异常属性包含闭包或 resource检查属性类型改用 Reflection 方案克隆完 private 属性丢失使用 json 方案检查类定义换 serialize 或 Reflection死循环或内存溢出对象图存在循环引用打印调用栈使用SplObjectStorage记录映射你如果平时写代码时能多留个心眼用clone这个关键字之前先停下来想一想“这个对象的孩子嵌套对象是不是要一起复制”很多线上的诡异数据错乱事故根本不会发生。我在实际项目中现在只要设计实体类就会顺手为它定义克隆语义要么明确浅克隆够用要么直接丢给通用深克隆工具处理。这比事后排查 bug 的效率高了不是一点半点。
返回列表