PHP clone 之后,为什么改副本会影响原对象?

简介: PHP `clone` 默认为浅拷贝:标量与数组独立,但嵌套对象仍共享同一实例。修改副本中的对象属性(如 `$copy->addr->city`)会同步影响原对象,因二者指向相同内存地址。需在每层类中实现 `__clone()` 手动深克隆,或改用不可变值对象(如 `readonly` 类)彻底规避。

PHP clone 之后,为什么改副本会影响原对象?

clone 会复制对象,却会共享嵌套对象。在每一层实现 __clone 可以解决这个问题,使用不可变值对象也可以。所有行为均已在 PHP 8.3 中验证。

下面这个错误报告改变了一个团队对 PHP 对象语义的理解。

某电商平台拥有一套订单处理服务。其中采用了一种标准模式:从数据库中获取订单,针对各种操作(计算总额、检查库存、应用折扣)克隆订单,从而使原始订单保持不变。$workingOrder = clone $order; 是整个代码库几十处位置都能见到的熟悉模式。

错误报告显示,某些客户的订单总额偶尔会包含重复折扣。问题并非始终出现,也不局限于特定商品,看起来毫无规律。客服曾多次将其标记为“客户理解有误”。随后,一名大额订单(约 3,000 美元)客户报告了同一问题,并提供了清晰的复现步骤。折扣被应用了两次,导致实际收费明显不足。

调试发现,折扣计算器会克隆订单,对克隆对象中的订单项应用促销折扣,然后丢弃该克隆对象。订单对象的订单项集合包含 LineItem 对象。克隆 Order 会创建一个新的 Order,并复制 items 数组,但数组中的每个 LineItem 都由原始对象与克隆对象共享

当折扣计算器修改 $clonedOrder->items[0]->discount = 0.10 时,实际上修改的是 $originalOrder->items[0] 所引用的同一个 LineItem 对象。结账流程之后处理“原始”订单时,便会看到本应只应用于工作副本的折扣。

问题之所以只是“偶尔”出现,触发因素在于请求时序:如果工作副本在结账流程处理订单之前被垃圾回收,就不会出现问题;如果它在结账期间仍然存在,共享的 LineItem 就会体现出这次修改。这是一种竞态条件,但并非需要线程才会出现的那种,而只是对象身份共享所致。

修复这一问题需要正确理解 PHP 的 clone 语义:clone 默认执行浅拷贝。嵌套对象不会被复制,而是由原始对象和克隆对象共享。要获得独立副本,需要在涉及的每个类中实现 __clone,对其嵌套对象执行深度克隆。另一种方法是使用 serialize/unserialize 实现真正的深拷贝,但要付出性能代价。

紧急修复方案是在 OrderLineItem 中实现 __clone,正确执行深度克隆。长期方案则是将 LineItem 转换为不可变值对象,使共享状态无法被意外修改,并在代码审查清单中加入 clone 语义检查项。

由此得到的长期经验是:PHP 中的 clone 并不表示“为这个对象创建一份独立副本”,而是“创建一个顶层属性值相同的新对象”。嵌套对象是共享引用,通过克隆对象修改它们也会影响原始对象。这是文档明确规定的行为,却一直令那些把“复制”理解为深拷贝的开发者措手不及。

本文将介绍 PHP 的 clone 语义、经过验证的浅拷贝行为与引用陷阱、__clone 如何实现深度克隆、serialize/unserialize 替代方案、性能比较,以及不可变值对象如何彻底绕开这个问题。所有示例均已在 PHP 8.3.6 中验证:修改克隆对象中嵌套的 Address 对象,也会改变原始对象的地址;spl_object_id 证明二者指向同一个对象。正确实现 __clone 后,它们会彼此独立。对象数组也存在同样的问题,需要在 __clone 中使用 array_map(clone $x, $items)

TL;DR 速览

clone 在 PHP 中执行浅拷贝。它会复制顶层属性:标量值和 null 按值复制,数组按值复制(内部采用写时复制,CoW),但对象类型属性在副本中仍指向同一个对象实例。嵌套对象不会被复制,原始对象与克隆对象都指向同一个嵌套对象。

引用陷阱已得到验证:当用户拥有一个 Address 对象属性时,执行 $copiedUser = clone $originalUser,然后将 $copiedUser->address->city 修改为 'Bandung',原始用户的地址也会变成 'Bandung'spl_object_id() 证明两个用户的 address 属性指向同一个 Address 对象(ID 为 3)。

__clone 魔术方法可以解决这一问题。它会在 clone 之后自动调用。标准模式是在 __clone 中手动克隆嵌套对象:

public function __clone(): void {
   
    $this->address = clone $this->address;
}

验证结果表明,实现 __clone 后,对副本地址的修改不会影响原始对象。

对象数组也存在同样的陷阱。数组本身会被复制,但其中的每个对象元素仍然共享。执行 $copiedOrder->items[0]->quantity = 999 也会把 $originalOrder->items[0]->quantity 改为 999。修复方式是在 __clone 中使用:

$this->items = array_map(fn($item) => clone $item, $this->items);

深度克隆要求每一层都协同处理。如果 Order__clone 克隆了 Address,但 Address 没有通过 __clone 克隆其内部的 Country,那么 Country 仍然是共享的。所谓“深度克隆”,实际上就是“一路向下逐层执行浅克隆”。

serialize/unserialize 无需在每一层实现 __clone,即可实现真正的深拷贝。经验证,其性能为每次操作 1.74 µs,而 __clone 链为每次操作 0.44 µs,前者慢约 4 倍,但对复杂嵌套结构更稳妥。需要注意,所有类和值都必须可序列化;资源无法序列化,闭包也需要谨慎处理。

不可变值对象可以彻底避免这个问题。如果嵌套对象无法修改,共享它们就是安全的。使用 readonly 属性(PHP 8.1+),再配合返回新实例的 withX() 方法,既不需要 clone,也不可能出现引用陷阱。

本文将介绍

  • PHP 的 clone 实际复制了什么、共享了什么
  • 经过验证的引用陷阱示例
  • __clone 如何实现正确的深度克隆
  • 不同克隆方式的性能比较
  • 作为替代方案的不可变值对象

PHP 的 clone 语义

clone 关键字会创建一个与原始对象类相同的新对象。它会复制对象的属性,但对于不同的属性类型,“复制”的含义并不相同:

标量类型(intstringfloatbool)和 null:按值复制,克隆后彼此独立。

数组:按值复制(PHP 内部使用写时复制,但其语义是复制),克隆后彼此独立。不过,当数组元素是对象时,它们仍然遵循对象规则:数组本身会被复制,其中的元素却可能共享。

对象:对象类型属性在克隆后仍指向同一个对象实例。原始对象与克隆对象因而共享该嵌套对象,这就是引用陷阱。

资源(文件句柄、数据库连接):共享。属性会被复制,但底层资源保持相同。

经过验证的基础克隆结果如下:

Original: id=1, name=Alice, tags=[admin, active]
Copy:     id=2, name=Bob, tags=[admin, active, newcomer]
→ Primitives and arrays: independent as expected ✓

修改副本的 idnametags 后,原始对象保持不变。标量值的行为符合预期,正因如此,这种模式在遇到嵌套对象之前总会显得很安全。

经过验证的引用陷阱

先建立一个拥有嵌套 Address 对象的 User 类:

<?php
class Address {
   
    public string $city;
    public string $country;
}
class UserWithAddress {
   
    public string $name;
    public Address $address;
}
$addr = new Address('Jakarta', 'Indonesia');
$originalUser = new UserWithAddress('Alice', $addr);
$copiedUser = clone $originalUser;

此时,两个用户看起来各自拥有一个地址:

Original address: Jakarta, Indonesia
Copy address:     Jakarta, Indonesia

现在修改副本的地址:

<?php
$copiedUser->address->city = 'Bandung';
$copiedUser->name = 'Bob';

直觉上的预期行为是只有副本发生变化。

实际验证结果如下:

Original: name=Alice, city=Bandung   ← city changed on original too!
Copy:     name=Bob,   city=Bandung

name 的修改只保留在副本中,因为它是标量值;city 的修改则传播到了原始对象,因为它位于共享对象中。

对象身份可以进一步证明这一点:

Original address hash: 3
Copy address hash:     3
Same object: YES

spl_object_id() 对二者返回相同的值。它们实际上就是同一个 Address 实例,两个用户对象都引用它。通过任意一个引用修改该对象,都会修改所谓的“两个”地址,因为它们其实是同一个对象。

这就是引用陷阱。看似在处理独立副本的深层代码,实际上却在操作共享状态。最终表现为难以追踪的跨对象连带影响:修改一个对象,另一个对象也会随之发生变化。

__clone 魔术方法

PHP 提供了 __clone 魔术方法,用于定制克隆行为。浅拷贝完成后,它会自动调用,使代码有机会修正共享引用:

<?php
class UserWithAddress {
   
    public string $name;
    public Address $address;

    public function __clone(): void {
   
        // Deep clone the nested object
        $this->address = clone $this->address;
    }
}

克隆 UserWithAddress 时,会依次发生以下操作:

  1. PHP 创建一个新实例并执行浅拷贝。
  2. __clone 在新实例上运行。
  3. __clone 使用地址对象的克隆替换共享的 $address 引用。

实现 __clone 后的验证结果如下:

Original: name=Charlie, city=Surabaya
Copy:     name=Charlie, city=Yogyakarta
Same object: no
→ __clone made the nested object independent ✓

修改副本的城市后,原始对象保持不变。现在,每个用户都拥有一个独立的 Address 对象。

这种模式很直接:对于应由每个实例独立拥有的对象类型属性,在 __clone 中对其执行克隆。标量值和数组已经被复制,无需处理;原本就希望共享的对象也应跳过,例如共享日志记录器。

深层嵌套问题

__clone 可以处理一层嵌套,但更深的层级又该如何处理?

<?php
class Country {
   
    public string $name;
    public string $currency;
}
class AddressWithCountry {
   
    public string $city;
    public Country $country;  // nested object inside nested object
}
class UserWithNestedAddress {
   
    public string $name;
    public AddressWithCountry $address;

    public function __clone(): void {
   
        $this->address = clone $this->address;
    }
}

克隆 UserWithNestedAddress 时,顶层 Address 会被克隆,这得益于 User__clone。但 Address 内部的 Country 又如何?除非 AddressWithCountry 也实现了 __clone,否则它仍然会被共享:

<?php
class AddressWithCountry {
   
    // ...
    public function __clone(): void {
   
        $this->country = clone $this->country;
    }
}

在每一层实现 __clone 后,验证结果如下:

Original User == Copy User:       different
Original Addr == Copy Addr:       different
Original Country == Copy Country: different
→ All levels independent ✓

三个层级现在都是彼此独立的对象。

通用规则是:要让深度克隆生效,对象图中的每个类都需要实现 __clone,并在其中克隆自己的对象类型属性。漏掉任何一个类,从该类开始向下的所有内容都会与原始对象共享。

在实践中,这种方式面对复杂对象图时很容易出错。新增一种嵌套对象类型后,很容易忘记添加 __clone;为现有类增加对象属性时,也很容易忘记更新 __clone。随后,这个错误会一直隐藏,直到某处代码通过克隆对象修改共享引用并触发陷阱。

对象数组也有同样的问题

包含数组的对象被克隆时,数组会被复制,但数组中的对象元素仍然是共享引用,同样存在这个陷阱。

<?php
class Order {
   
    public string $orderId;
    public array $items = [];  // Array of Item objects

    // WITHOUT __clone: items array copied, but each Item shared
}
$order1 = new Order('ORD-001');
$order1->addItem(new Item('Widget', 3));
$order1->addItem(new Item('Gadget', 5));
$order2 = clone $order1;
$order2->orderId = 'ORD-002';

// Modify the FIRST item in the copy
$order2->items[0]->quantity = 999;

验证结果如下:

Order 1 items:
  - Widget: 999   ← original also changed!
  - Gadget: 5
Order 2 items:
  - Widget: 999
  - Gadget: 5

两个订单中 Widget 的数量都变成了 999。每个订单的 items 数组确实是独立副本,因此向 order2->items 添加元素不会影响 order1;但数组内部Item 对象仍由二者共享。

修复方式如下:

<?php
class Order {
   
    // ...
    public function __clone(): void {
   
        // Clone each item in the array
        $this->items = array_map(fn($item) => clone $item, $this->items);
    }
}

修复后的验证结果如下:

Order 3 items:
  - Widget: 3     ← original unchanged
  - Gadget: 5
Order 4 items:
  - Widget: 999
  - Gadget: 5

现在二者已经独立。array_map 会迭代数组并克隆每个元素。在 __clone 中应用这种方式,可以确保对象集合得到正确的深拷贝。

serialize/unserialize 替代方案

对于复杂对象图,如果在每一层实现 __clone 并不现实,可以使用 serialize/unserialize 实现真正的深度克隆:

<?php
function deepClone(object $obj): object {
   
    return unserialize(serialize($obj));
}
$deep = deepClone($original);

其工作原理如下:

  • serialize() 会遍历整个对象图,将对象及其嵌套对象编码为字符串表示。
  • unserialize() 会重建对象图,为每个对象创建新的独立实例。

验证结果如下:

Original currency: IDR
Deep clone currency: USD   ← modified only on clone
→ Full independence without needing __clone in every class ✓

对深层嵌套内容的修改不会传播回原始对象。

优点:

  • 无需在每个类中实现 __clone
  • 自动处理任意深度
  • 适用于结构未知的对象图
  • 行为可预测,始终得到完整深拷贝

缺点:

  • 性能明显较慢,经验证比 __clone 链慢 4 倍
  • 并非所有内容都可以序列化
  • 资源(文件句柄、数据库连接)会失败
  • 闭包需要显式处理,或使用 Opis\Closure
  • 会触发对象的 __sleep/__wakeup 行为
  • 要求相关类可以被加载,自动加载必须正常工作
  • 反序列化开始前,完整的序列化结果需要先驻留在内存中,因此会产生额外的内存开销

适用场景是对象图复杂、协作式 __clone 不切实际,而且性能并非关键因素的情况。不应在热点路径中使用。

经过验证的性能比较

对一个三层嵌套对象执行 100,000 次克隆操作:

方式 时间 每次操作耗时 相对性能
浅克隆(默认) 0.042s 0.42 µs/op 1x
__clone 链(深拷贝) 0.044s 0.44 µs/op 1.05x
serialize/unserialize 0.174s 1.74 µs/op 4.0x

分析如下:

浅克隆:几乎没有成本,只需增加嵌套对象的引用计数。

__clone:对于小型对象图,其性能几乎与浅克隆相同。成本会随着对象图深度和广度的增加而上升。

serialize/unserialize:由于完整序列化的开销,始终更慢。它依然很快(纳秒级),但开销是 __clone 的 4 倍。

对大多数应用场景而言,这种性能差异可以忽略。即使每个请求执行 1,000 次深度克隆,总成本也只有约 1.7ms,并不会成为瓶颈。

性能真正重要的场景包括批处理、事件流和高吞吐量服务。应依据测量结果选择,而不是凭假设决定。

用不可变值对象避免问题

只有嵌套对象能够被修改时,引用陷阱才会构成问题。如果对象不可变,也就是无法修改,那么共享它就是安全的。

下面使用 PHP 8.1+ 的 readonly 属性定义不可变值对象:

<?php
final class ImmutableAddress {
   
    public function __construct(
        public readonly string $city,
        public readonly string $country,
    ) {
   }

    // 'with' methods return NEW instances instead of mutating
    public function withCity(string $city): self {
   
        return new self($city, $this->country);
    }

    public function withCountry(string $country): self {
   
        return new self($this->city, $country);
    }
}

验证结果如下:

addr1: Jakarta, Indonesia
addr2: Bandung, Indonesia
Same object: no — new instance from withCity
→ No clone needed; no shared state; no reference trap

即使两个对象引用同一个 ImmutableAddress,也没有任何一方能够修改它。任何“变更”都会创建一个新地址。对象共享从结构上就是安全的。

优点:

  • 不可能出现引用陷阱
  • 不需要 __clone
  • 线程安全,这一点与 Swoole 和并行处理有关
  • 更容易推理,不可变值的行为类似标量值

权衡:

  • 对象分配更多,每次“变更”都会创建新实例
  • 不适用于具有身份的实体,例如带有可变状态的领域模型
  • 需要设计纪律,绝不就地修改,始终使用 with 方法

最适合的场景包括 DTO、值对象(金额、日期、坐标、地址)、配置对象和不可变事件。现代 PHP 框架鼓励采用这种模式。

特殊情况

私有属性和受保护属性:无论可见性如何,clone 都会复制它们。__clone 在克隆对象的上下文中运行,因此可以访问私有属性。

静态属性:不属于任何实例。克隆不会影响它们,它们会像往常一样由所有实例共享。

资源:属性会被复制,但底层资源(文件句柄、数据库连接)仍然共享。通常需要在 __clone 中重新建立资源:

<?php
public function __clone(): void {
   
    // Re-open file handle for the clone
    $this->fileHandle = fopen($this->filepath, 'r');
}

闭包:克隆带有闭包属性的对象时,会复制指向该闭包的引用。闭包捕获的变量(use 列表)不会被深度克隆。如果闭包捕获了 $this,它仍会绑定到原始对象的 $this

SplObjectStorage 和类型化集合:克隆包含它们的对象时,存储结构会被复制,但其中存储的对象仍然共享。处理方式与对象数组相同,需要在 __clone 中逐个克隆元素。

带有 __wakeup 的对象:如果使用 serialize/unserialize 实现深度克隆,__wakeup 会在反序列化后的对象上运行。这可能是期望行为,也可能不是。

应避免的陷阱

假设 clone 会产生完整的独立副本。 默认行为是浅拷贝,嵌套对象会被共享。这是克隆相关错误最主要的来源。

只在顶层类中实现 __clone 深度克隆要求每一层都协同处理。如果 Order 实现了 __clone,但 LineItem 没有通过自己的 __clone 处理嵌套对象,那么从 LineItem 开始仍会继续共享。

新增对象属性时忘记更新 __clone 类在演进过程中新增了嵌套对象,但 __clone 没有更新,于是新的错误已经潜伏下来,等待日后出现。

对不应克隆的对象使用 clone 某些对象(单例、服务、有身份的实体)并不存在有意义的克隆语义。可以考虑在 __clone 中使用 throw new \LogicException() 禁止克隆。

修改克隆对象中的共享集合。 常见模式是克隆订单后移除订单项,但订单项集合仍然共享,结果原始订单中的订单项也被移除。必须始终确认集合是否独立。

对包含资源的对象使用 serialize/unserialize 这会失败或产生损坏的对象。序列化从根本上无法保留文件句柄、数据库连接等资源。

未经性能分析就在热点路径中使用 serialize/unserialize 对偶尔执行的深拷贝而言,4 倍开销并无大碍,但在循环中会不断累积。

不测试克隆行为。 引用陷阱错误通常隐藏很深,因为它们只在特定时序或使用模式下暴露。对于确实需要克隆的类,单元测试应明确验证克隆对象是否独立。

出于习惯优先选择 clone,而不是不可变性。 对类似值对象的类而言,不可变设计通常优于“可变性加克隆”。新代码应默认采用不可变设计。

__clone 内克隆 $this 这是自引用模式中的常见错误,通常说明当前设计正在对抗对象模型,应重新审视设计。

简短问答

PHP 的 clone 与其他语言中的克隆相同吗?

不同语言的语义不同。Java 的 Object.clone() 默认也是浅拷贝,与 PHP 类似。Python 的 copy.copy() 是浅拷贝,copy.deepcopy() 是深拷贝,二者区分得更清楚。C++ 的复制构造函数默认执行深拷贝,会复制每个成员,而这通常意味着递归调用各成员的复制构造函数。JavaScript 没有内置的 clone,通常由库来提供。

是否应该始终使用 serialize/unserialize 来避免引用陷阱?

不应该。对于简单场景,__clone 更快也更清晰。对于复杂嵌套结构,如果在每一层实现 __clone 并不现实,那么 serialize/unserialize 是一种合理的权衡。应根据结构复杂度和性能需求选择。

克隆 DateTime 对象时该如何处理?

DateTime 需要克隆,因为 modify 等可变方法会修改对象本身。如果不执行克隆,在处理所谓的“副本”时就会意外修改原始对象。也可以使用 DateTimeImmutable,它的方法会返回新实例,这正是不可变值对象模式。PHP 建议新代码使用 DateTimeImmutable

clone 会调用构造函数吗?

不会。构造函数只由 new 调用。clone 会绕过构造函数,直接复制属性,并在定义了 __clone 时调用它。如果构造函数建立的状态也需要由 __clone 建立,这一点很容易造成意外。

如何阻止对象被克隆?

__clone 中抛出异常:

public function __clone(): void {
   
    throw new \LogicException(self::class . ' cannot be cloned');
}

这种做法常用于单例、资源包装器,以及克隆语义没有意义的类。

可以克隆 stdClass 或匿名类实例吗?

可以。clone 可用于任何对象。stdClass 的克隆遵循同样的规则,即对属性执行浅拷贝。

使用 array_map 克隆对象数组时会怎样?

array_map(fn($item) => clone $item, $items) 会克隆每个元素。每次调用 clone 都遵循标准克隆规则,因此,除非各元素实现了自己的 __clone,否则其内部的嵌套对象仍然共享。同样的规则会递归适用。

clone $obj 与执行 $new = new SameClass() 后再复制属性有何区别?

二者不同。clone 会绕过构造函数,并复制所有属性,包括私有属性和受保护属性。通过构造函数和属性赋值手动复制更加显式,但必须处理属性可见性和构造函数要求。clone 是标准惯用写法。

总结

PHP 的 clone 看似简单,实际却具有影响正确性的细微语义。“浅拷贝”并非局限,而是一种规则明确的既定行为。正确使用 clone 的前提,是理解这些规则:标量值按值复制,数组按值复制,而对象类型属性仍指向同一个对象实例。

实践建议很明确:始终假设 clone 只会提供浅拷贝。如果对象包含应由各实例独立拥有的嵌套对象,就实现 __clone 来克隆它们,并在对象图的每一层都这样做。通过测试明确验证经由克隆对象进行的修改不会影响原始对象。对于复杂对象图,可以考虑使用 serialize/unserialize,同时留意性能;也可以将结构重塑为不存在共享问题的不可变值对象。

经过验证的数据在这里很重要。浅克隆每次操作耗时 0.42 µs,几乎没有成本;通过 __clone 链执行深度克隆每次耗时 0.44 µs,只慢了微不足道的一点;serialize/unserialize 每次耗时 1.74 µs,慢 4 倍,但绝对速度仍然很快。性能很少会决定选择,真正起决定作用的是语义。

更深层的启示是,语言特性具有精确语义,而这些语义并不总是符合直觉。clone 并不表示“创建独立副本”,而表示“按照 PHP 的具体规则执行浅拷贝”。如果系统只以大致含义理解语言特性,就会不断累积“原以为它会执行 X,实际却执行了 Y”这一类错误。尊重精确语义的系统才能构建正确的代码。“理解语言实际会做什么,而不是想当然地认为它会做什么”,这种工程纪律区分了仅仅能够通过测试的代码与真正能够可靠运行于生产环境的代码。

回扣最初案例

出现折扣重复错误的电商团队在整个领域模型中实现了完整的 __clone 方法。OrderLineItemAddress,以及所有包含嵌套状态的类,都具备了正确的深度克隆行为。单元测试会明确验证各副本之间的独立性。

三个月后,团队在重构结账流程时,还使用 readonly 属性将 LineItem 从可变对象迁移为不可变值对象。由此,该层不再可能出现共享状态错误。

六个月后,团队新增“报价”功能,用于创建和修改类似订单的结构。此时模式已经确立:只要可行就使用不可变值对象,需要可变状态时使用 __clone,并通过显式测试验证独立性。新代码自然继承了这种模式。

一年后,团队在面试候选人时,将“PHP 中的 clone 会做什么?”作为一道实用问题。能够正确解释浅拷贝语义,并区分浅拷贝与深度克隆的候选人,展现出了对 PHP 的深入理解。只笼统回答“复制对象”、无法说明细微差异的候选人,则表明其尚未遇到引用陷阱。这是一项有用的判断信号。

这种工程文化进一步归纳出“精确语义很重要”的原则。该原则不仅适用于 clone,也适用于比较运算符(=====)、类型强制转换、错误处理、事务,以及任何因理解不够精确而可能引发隐蔽错误的语言特性。尊重精确语义的系统,可以避免因误解语言实际行为而产生的“为什么会失败”类事故。理解 clone 的这项具体投入,最终会扩展为一种更广泛的 PHP 熟练度准则。

大家还会问

  1. PHP 中的 clone 实际会做什么?它会创建一个相同类的新对象,并对属性执行浅拷贝。标量类型(intstringfloatbool)按值复制,数组按值复制(内部使用写时复制),但对象类型属性仍指向同一个对象实例,因此原始对象与克隆对象会共享同一个嵌套对象。验证结果表明,修改克隆 User 中嵌套的 Address 也会修改原始 UserAddressspl_object_id 显示二者具有相同的对象身份。这是文档明确规定的行为,却一直令那些把“复制”理解为深拷贝的开发者措手不及。

  2. 如何在 PHP 中创建对象的深拷贝?有两种方法。第一种是在对象图中的每个类上实现 __clone 魔术方法,手动克隆每个对象类型属性:public function __clone(): void { $this->address = clone $this->address; }。这要求每一层都协同处理,漏掉一个类,共享就会从那里继续。第二种是使用 unserialize(serialize($obj)),无需在每一层实现 __clone 即可创建完整的独立副本。经验证,它比 __clone 链慢 4 倍,但仍然很快(1.74 µs0.44 µs)。这是性能与实现复杂度之间的权衡。

  3. 为什么修改克隆对象也会修改原始对象?因为 clone 是浅拷贝,嵌套对象由原始对象与克隆对象共享,而不是彼此独立的副本。执行 $copy = clone $original 时,标量值彼此独立,但 $copy->address$original->address 等对象属性会指向同一个 Address 对象。通过任一引用进行修改,都会修改这个共享对象。修复方法是在 __clone 中显式克隆嵌套对象,或者改用无法就地修改的不可变值对象。

  4. PHP 何时调用 __clone 魔术方法?在 clone 创建浅拷贝之后自动调用。它不会在原始对象上运行,而是在新的克隆实例上运行。可以用它修正应当彼此独立的引用:public function __clone(): void { $this->address = clone $this->address; }。它在克隆对象的上下文中运行,因此可以访问私有属性。它不会调用构造函数,clone 会完全绕过 __construct

  5. 是否需要专门克隆对象数组?需要。包含数组的对象被克隆时,数组本身会被复制,因此向副本中添加元素不会影响原始数组,但数组内部的对象仍然是共享引用。验证中执行 $copiedOrder->items[0]->quantity = 999 后,原始订单中 Widget 的数量也变成了 999。修复方式是在 __clone 中使用 $this->items = array_map(fn($item) => clone $item, $this->items); 克隆每个元素。

  6. 浅克隆与深度克隆的性能差异有多大?经验证,浅克隆为每次操作 0.42 µs;通过 __clone 链实现的深拷贝为 0.44 µs,对小型对象图而言几乎相同;通过 serialize/unserialize 实现的深拷贝为 1.74 µs,慢 4 倍。对于典型应用,这三种方式的绝对速度都很快。性能很少决定选择,真正的决定因素是正确性,即避免引用陷阱。serialize/unserialize 较慢,但无需各层协作式实现 __clone

  7. 可以使用 readonly 属性(PHP 8.1+)避免克隆问题吗?对于值对象而言可以。如果嵌套对象采用 readonly 属性,就无法被修改,因此通过浅克隆共享它们也是安全的。再结合返回新实例的 withX() 方法,就能得到彻底避免引用陷阱的不可变值对象。示例模式为:class ImmutableAddress { public function __construct(public readonly string $city) {} public function withCity(string $city): self { return new self($city); } }。不需要 __clone,也不可能存在共享状态。

  8. 应该使用 DateTime 还是 DateTimeImmutable?新代码应使用 DateTimeImmutableDateTime 是可变的,modify() 等方法会就地修改对象。如果不执行克隆,就可能意外修改其他代码所依赖的“日期”。DateTimeImmutable 的方法会返回新实例,因此更安全,也不会产生克隆相关错误。二者功能相同,变更语义不同。现代 PHP 风格会在存在不可变变体时优先使用它。

注:本文中的所有 PHP 行为和性能测量均已在 PHP 8.3.6 中验证。具体耗时(浅克隆 0.42 µs__clone0.44 µsserialize1.74 µs)来自测试系统和特定对象图;实际数据取决于对象复杂度、PHP 版本和系统。PHP 各版本中的克隆语义保持一致,但确切性能特征会持续变化。readonly 属性需要 PHP 8.1+;从旧版 PHP 迁移时,应检查相关语法。对于生产环境中涉及克隆的复杂代码,验证克隆独立性的单元测试必不可少,因为引用陷阱错误通常间歇出现,并且只会在特定使用模式下暴露。对于新建的值对象类,应尽可能采用不可变设计,从而完全绕开克隆语义问题。

PHP clone 之后,为什么改副本会影响原对象?

目录
相关文章
|
4天前
|
人工智能 自然语言处理 安全
阿里云AI数智鉴密:AI 生成内容如何拿到一张"防篡改的身份证"
隐形水印 + C2PA签名:让AI生成内容“持证上岗”。
1122 0
|
13天前
|
人工智能 自然语言处理 安全
阿里云千问办公、Qoder Teams、Qoder CN区别与选择指南:模型能力、适用场景与最新活动参考
本文聚焦阿里云2026年推出的三款自研AI办公产品,清晰拆解千问办公、Qoder Teams、Qoder CN的差异化定位与能力边界:千问办公主打职场全场景提效,支持自然语言指令一键完成PPT生成、数据分析等高频办公任务;Qoder Teams面向程序员团队,深度整合AI代码生成、团队协同与企业知识库能力;Qoder CN则专为金融、政务等强合规场景打造,实现数据不出境与VPC私有化部署。文章同步给出分场景选型指南与最新活动定价,帮助不同类型的企业按需组合产品,实现业务岗、研发岗与强合规场景的AI能力全覆盖。
3735 4
阿里云千问办公、Qoder Teams、Qoder CN区别与选择指南:模型能力、适用场景与最新活动参考
|
4天前
|
人工智能 运维 BI
阿里云千问办公QwenWork深度解析:基于Qwen3.8,六大核心能力重构企业全自动化工作流与计费选型指南
传统AI办公工具大多停留在对话问答、文档摘要、简单文案生成层面,只能完成单点碎片化任务,无法自主拆解复杂业务流程,很难串联多工具、多文档、外部业务系统完成端到端完整工作交付。很多企业在落地AI办公的时候,需要组合多款不同工具,来回切换界面,手动复制粘贴中间结果,智能化改造落地门槛居高不下。千问办公QwenWork是整合多款智能体产品能力打造的一体化企业办公智能体平台,底层基座依托Qwen3.8大模型,打通桌面端Agent、云端Agent、企业协同Agent三种运行形态,不再局限简单问答,接收业务目标之后自主拆解任务步骤,调用各类工具,处理文档、表格、浏览器自动化、数据查询,直接输出可交付的办公
1352 0
|
4天前
|
人工智能 安全 前端开发
刚刚 GPT-6 Astra 发布,全球最强,AGI 时代到来!
OpenAI 正式推出 GPT-6 Astra 模型,带大家看看这次 GPT 有哪些提升,跟 Claude Fable 5.1 有什么差距?AI 编程能力如何?AGI 真的来了么?
612 0
|
10天前
|
人工智能 并行计算 数据可视化
秋叶ComfyUI-AKI最新整合包|完整部署教程+核心指令手册
秋叶ComfyUI-AKI一键整合包,国内适配最优、稳定性最强的商用/学习级版本:全封装虚拟环境、预装90%常用节点、内置绘世启动器与成熟工作流,免配置、零依赖、解压即用,完美兼顾新手入门与专业批量生产需求。(239字)
|
14天前
|
人工智能 Linux iOS开发
Ollama使用教程:Ollama官网下载、Ollama本地部署大模型(2026最新)
Ollama 是一款免费开源的本地大模型运行工具,支持在 Windows/macOS/Linux 上离线运行 Qwen、DeepSeek、Llama 等主流开源模型,数据不出本机、隐私安全。提供 OpenAI 兼容 API,命令行一键拉取/运行/管理模型,无需联网,无调用限制,是开发者与 AI 爱好者部署本地 AI 助手的理想选择。(239 字)