登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  php教程

PHP readonly 属性克隆对象时的状态复制边界

来源:17golang原创

时间:2026-10-10 10:59:02 206浏览 收藏

结论先说:PHP 的 clone 会创建新的外层对象,但对象属性中的对象引用默认仍按浅复制处理。readonly 只限制属性槽位被重新赋值,并不会自动把它指向的可变对象深复制。因此,原对象和克隆对象可能各自拥有一个只读槽位,却共同指向同一个可变对象。

从 PHP 8.3 开始,可以在 __clone() 执行期间把已初始化的 readonly 属性重新初始化一次。这正是隔离嵌套可变对象的窗口;离开这个窗口、对同一属性写第二次,或者在 PHP 8.1/8.2 上执行同样代码,都会触发 Error。

本文依据 PHP 官方手册与 Readonly amendments RFC 核对版本行为:

# PHP 属性与 readonly 官方手册
https://www.php.net/manual/en/language.oop5.properties.php

# readonly 克隆重新初始化规则的官方 RFC
https://wiki.php.net/rfc/readonly_amendments

先把复制边界说清:外层对象复制,不等于对象图深复制

假设订单快照里有一个 readonly 的 DateTime。执行 clone $snapshot 后,快照本身已经是另一个实例,但两个快照的 capturedAt 起初仍指向同一个 DateTime 对象。readonly 阻止的是 $snapshot->capturedAt = ... 这种重新赋值,不会阻止 $snapshot->capturedAt->modify(...) 对嵌套对象内部状态的修改。

capturedAt->modify('+1 day');

// 两个属性仍指向同一对象,所以原对象也会看到日期变化。
var_dump($original->capturedAt === $copy->capturedAt); // true

这类问题最容易被“外层对象编号已经不同”掩盖。判断是否真正隔离,不能只比较 $original !== $copy,还要比较需要隔离的嵌套对象。

PHP readonly 属性在浅复制和 __clone 深复制时的嵌套对象引用关系
图1:readonly 属性在浅复制与 __clone() 深复制下的引用关系说明图,不是截图或运行证据。

PHP 8.3 的门禁:只在 __clone() 执行期重新初始化一次

PHP 8.3 放开的不是“readonly 可以随便改”,而是一个非常窄的生命周期门禁:对象已经由引擎复制完成、正在执行该对象的 __clone(),并且目标 readonly 属性在这次克隆中还没有被重新初始化。满足这些条件时,可以为它换一个新值。

最小的深复制写法如下。它保留原对象不变,只让克隆对象的属性改为指向新的 DateTime 实例。

capturedAt = clone $this->capturedAt;
    }
}

$original = new Snapshot(new DateTime('2026-10-10 10:00:00'));
$copy = clone $original;

// 克隆对象修改自己的嵌套对象,不再影响原对象。
$copy->capturedAt->modify('+1 day');

var_dump($original !== $copy);                         // true
var_dump($original->capturedAt !== $copy->capturedAt); // true

如果最低运行版本仍是 PHP 8.2,不能仅靠条件判断绕过运行时错误;应继续在工厂或 wither 方法里调用构造器创建新对象,或者把最低版本提升到 8.3 后再启用这条路径。

PHP 8.3 和 PHP 8.4 readonly 属性克隆重新初始化边界
图2:PHP 8.3+ readonly 克隆重新初始化的执行期与错误边界说明图,不是截图或运行证据。

不是每个 readonly 属性都要深复制

标量、数组与对象要分开判断。字符串、整数等标量随对象复制后没有共享可变实例的问题。PHP 数组本身采用写时复制,通常也不需要在 __clone() 里为了隔离而重复赋值。真正需要重点检查的是对象和资源。

  • 值类型或不可变对象:例如字符串、整数、DateTimeImmutable,通常可以保持默认复制。
  • 可变对象:例如 DateTime、可变集合、可写配置对象,需要判断克隆后是否必须独立。
  • 有意共享的服务对象:记录器、连接池或无状态服务可能就应共享,盲目深复制反而破坏语义。
  • 对象组成的数组:数组外壳会分离,但数组元素仍可能是共享对象,需要逐个按业务语义处理。

所以规则不是“看到 readonly 就 clone”,而是先画出对象所有权:这个子对象由谁拥有,克隆后是否允许共同变化,复制成本是否可接受。readonly 解决的是属性写入约束,不等于完整的不可变对象模型。

一次写入是硬边界,unset 也算重新初始化流程的一部分

在 __clone() 中,同一个 readonly 属性只能重新初始化一次。先赋回原值再赋新值并不是两阶段更新,而是第二次写入,后一次会报错。RFC 还允许在克隆执行期先 unset(),之后再初始化;这主要服务于惰性属性或魔术访问器,不应成为普通深复制的默认写法。

capturedAt = $this->capturedAt;

    // 第二次写入会抛出 Error,不能把 readonly 当临时变量使用。
    $this->capturedAt = clone $this->capturedAt;
}

PHP 8.4 还明确禁止在 __clone() 中通过引用间接修改 readonly 属性,例如获取 &$this->capturedAt 的引用。迁移旧代码时,应使用清晰的直接赋值,不要依赖引用技巧。

把验证做成双向测试,而不是只看一次输出

好的回归测试至少验证三件事:外层对象不同、需要隔离的嵌套对象不同、任意一侧改变都不会串到另一侧。仅验证初始值相等是不够的,因为浅复制与深复制在刚完成时看起来完全一样。

capturedAt !== $copy->capturedAt);

// 再修改克隆侧,确认原对象仍保持原值。
$copy->capturedAt->modify('+1 day');
assert($original->capturedAt->format('Y-m-d') === '2026-10-10');
assert($copy->capturedAt->format('Y-m-d') === '2026-10-11');

如果类里有多个可变子对象,逐个列入测试。新增 readonly 属性时,也应把“共享还是隔离”作为代码评审项,否则未来一次看似安全的属性扩展就可能重新引入共享状态。

兼容性落地清单

  1. 在 composer.json 和部署镜像中确认最低 PHP 版本;使用重新初始化能力时最低必须是 PHP 8.3。
  2. 枚举 readonly 属性里的对象,标注“不可变、需要隔离、有意共享”三种所有权。
  3. 只在 __clone() 中直接替换需要隔离的属性,每个属性只写一次。
  4. 避免通过引用修改 readonly 属性,尤其是目标环境包含 PHP 8.4 及更高版本时。
  5. 测试对象身份和双向变更,不用单次序列化输出代替引用隔离验证。
  6. 对 PHP 8.1/8.2 保持构造器重建路径,不要让同一套包在低版本运行时才暴露致命错误。

常见问题

readonly 属性里的对象完全不能改吗?

不是。readonly 阻止属性被重新赋值,但对象内部仍可能被修改,这就是“内部可变性”。要完全不可变,应优先保存不可变对象,或在 API 层同时约束其修改方法。

PHP 8.2 中的 __clone() 能重新赋值 readonly 属性吗?

不能。已初始化 readonly 属性在 PHP 8.2 中仍不能于克隆时重新初始化。需要用构造器创建新实例,或把最低运行版本升级到 PHP 8.3。

为什么 clone 后 readonly 的 DateTime 仍会互相影响?

因为默认 clone 是浅复制,两个 readonly 属性槽位都保存了同一个 DateTime 引用。readonly 没有自动深复制语义。

可以在 __clone() 里连续修改同一个 readonly 属性吗?

不可以。每个属性在一次克隆执行中只能重新初始化一次,第二次写入会抛出 Error。

DateTimeImmutable 也需要在 __clone() 中复制吗?

通常不需要。它的修改方法返回新实例,不会原地改变旧实例。除非业务明确要求不同对象身份,否则共享不可变实例更简单。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>