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

PHP 8.3 readonly 类如何设计可变集合:深拷贝、clone 与运行时不变量

来源:17golang原创

时间:2026-08-30 10:48:31 245浏览 收藏

做订单草稿快照时,很多 PHP 代码会把购物车对象声明成 readonly,然后误以为里面的商品集合也自动冻结了。实际边界更细:readonly 类锁住的是属性重新绑定,CartItem 对象和数组里的内容仍要由设计者控制。要让“快照 + 加一件商品”可靠,通常需要用新数组构造新对象,或者在 PHP 8.3 中明确利用 clone 的重初始化能力。

readonly 不是深度不可变。先区分“属性不能换”和“集合内容不能改”,再决定使用浅拷贝、深拷贝还是返回新实例。

要点速览
  • readonly class 从 PHP 8.2 可用,所有实例属性必须有类型且不能动态追加。
  • 对象属性存在内部可变性;readonly 的 items 不等于其中每个 CartItem 都不可变。
  • clone 默认是浅拷贝;PHP 8.3 允许在 __clone() 中重新初始化 readonly 属性。
  • 最容易验收的契约是:原购物车数量不变,新购物车拥有新数组,并且每个商品对象按需要独立复制。

先把购物车快照的“不可变”说准确

下面的例子只处理一个任务:从当前购物车生成一个追加商品后的新快照。CartItem 表示商品行,items 保存行对象,withItem() 不改旧购物车,而是返回新实例。这样调用方可以把旧快照留给审计,把新快照交给结算。

 $items */
    public function __construct(public array $items) {}

    public function withItem(CartItem $item): self
    {
        return new self([...$this->items, $item]);
    }
}

这里的 items 属性不能被重新赋一个数组,但数组中的对象仍然是对象。若调用方拿到同一个 CartItem,仍可执行 $item->quantity++。所以“旧购物车不变”尚未成立,需要先决定商品行是否也必须独立。

PHP readonly class 中 Cart 的 items 属性与 CartItem 内部 quantity 可变边界

浅拷贝为什么只解决了数组容器这一层

数组展开语法会创建一个新数组容器,但不会自动复制数组里的对象。下面的断言可以证明:withItem() 得到的新数组不同,原有的 CartItem 却还是同一对象。

$item = new CartItem('A-100', 1);
$old = new Cart([$item]);
$next = $old->withItem(new CartItem('B-200', 1));

var_dump($old->items !== $next->items); // true:数组容器不同
$old->items[0]->quantity = 3;
var_dump($next->items[0]->quantity); // 3:对象仍被共享

这就是“readonly 集合”最常见的误读。若商品行的数量会更新,快照就应该复制行对象;若 CartItem 本身也是完整 readonly 值对象,则共享对象通常安全,但仍应把这个前提写进类的契约。

需要深拷贝时,把复制动作放在领域对象里

不要在业务调用处到处手写对象复制。可以给 CartItem 一个返回新对象的方法,再由 withItem() 对现有行逐个复制。这个选择的好处是字段增加时,复制规则只维护一处。

final class CartItem
{
    public function __construct(
        public readonly string $sku,
        public int $quantity,
    ) {}

    public function copy(): self
    {
        return new self($this->sku, $this->quantity);
    }
}

readonly class Cart
{
    /** @param list $items */
    public function __construct(public array $items) {}

    public function withItem(CartItem $item): self
    {
        $copied = array_map(
            static fn (CartItem $current): CartItem => $current->copy(),
            $this->items,
        );
        return new self([...$copied, $item->copy()]);
    }
}

这条数据路径很明确:旧的 items → 每个 CartItem::copy() → 新数组 → 新 Cart。验收时不要只比较购物车对象的地址,还要检查行对象的 spl_object_id() 不相同。

PHP Cart 的 items 经过 CartItem copy、withItem 生成新数组和新快照的数据路径

PHP 8.3 的 clone 重初始化适合什么场景

PHP 手册把 clone 描述为浅拷贝;常规情况下,readonly 属性在克隆后仍不能再次赋值。PHP 8.3 增加了在 __clone() 中重新初始化 readonly 属性的能力,这让“复制对象并替换一部分状态”有了更直接的写法。

不过这不是深拷贝开关。若 items 里是普通对象,clone 后仍要逐项 clone 或调用 copy()。否则只是复制了 Cart 的外壳。

readonly class Cart
{
    /** @param list $items */
    public function __construct(public array $items) {}

    public function __clone(): void
    {
        $this->items = array_map(
            static fn (CartItem $item): CartItem => $item->copy(),
            $this->items,
        );
    }

    public function withItem(CartItem $item): self
    {
        $copy = clone $this;
        $copy->items = [...$copy->items, $item->copy()];
        return $copy;
    }
}

这段写法把复制规则集中到 __clone(),但要特别注意运行版本:项目最低版本低于 PHP 8.3 时,不应把它当成兼容写法;可以继续使用构造函数 + copy() 的方案,并在 CI 中固定 PHP 版本检查。

用不变量把边界状态测出来

测试应围绕“旧快照是否被污染”展开,而不是只测 clone 语法能否运行。最小验收至少包含四项:旧数组数量不变、新数组数量增加、对应行对象不共享、SKU 与数量保留。

检查点期望结果失败意味着什么
count($old->items)保持原值追加操作改写了旧快照
count($next->items)增加一行withItem() 未生成新集合
spl_object_id()旧、新行不同只做了浅拷贝
skuquantity值一致复制逻辑漏字段或归一化错误
$old = new Cart([new CartItem('A-100', 1)]);
$next = $old->withItem(new CartItem('B-200', 2));

assert(count($old->items) === 1);
assert(count($next->items) === 2);
assert(spl_object_id($old->items[0]) !== spl_object_id($next->items[0]));
assert($next->items[0]->sku === 'A-100');
assert($next->items[1]->quantity === 2);

相关问题

readonly class 能阻止数组里的对象被修改吗?

不能。它限制属性重新绑定,数组中的对象仍可能发生内部变化;需要值对象或显式复制来建立更强的不变量。

数组展开语法算深拷贝吗?

不算。它只创建新的数组容器,里面的对象引用仍可能共享。

PHP 8.3 的 __clone() 能自动复制所有嵌套对象吗?

不能。__clone() 可以重新初始化 readonly 属性,但嵌套对象仍需逐层 clone 或调用领域复制方法。

把复制策略写进类的契约

readonly 解决的是属性绑定边界,不会替你设计集合的生命周期。对只读值对象可以安全共享;对会变化的 CartItem,应让 withItem() 返回新数组并执行深拷贝。最后用旧快照数量、对象身份和字段值做回归,才算真正验证了运行时不变量。

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