PHP 只读属性与不可变对象:readonly 的内核实现与写时复制交互

PHP 8.1 引入的 readonly 修饰符,表面上看只是一个语法糖,让属性只能在初始化时赋值一次。但当你把它与 PHP 的写时复制(Copy-on-Write, COW)机制放在一起审视时,会发现两者之间存在微妙而深刻的交互。理解这些交互,不仅能帮你写出更安全的不可变对象,还能避免一些看似诡异的运行时行为。

readonly 的语义边界

readonly 属性有三个核心约束:

  1. 只能在声明所在类的作用域内初始化一次。
  2. 初始化后任何写入都会抛出 Error
  3. 不能有默认值(因为默认值意味着隐式初始化)。
class Money {
    public readonly int $amount;
    public readonly string $currency;

    public function __construct(int $amount, string $currency) {
        $this->amount = $amount;
        $this->currency = $currency;
    }
}

一旦构造完成,$money->amount = 100 就会直接抛出错误。这看起来足够安全,但问题在于:readonly 保护的是属性槽位本身,而不是属性所指向的

内核视角:readonly 如何实现

在 Zend 引擎中,每个属性都有对应的 zend_property_info,其中包含一组标志位。PHP 8.1 新增了 ZEND_ACC_READONLY 标志。当引擎执行属性写入时,会检查这个标志以及属性是否已经被初始化。

关键点在于:readonly 的检查发生在属性写入路径上,而不是值层面。具体来说,zend_std_write_property 和相关 handler 会检查:

  • 属性是否标记为 readonly;
  • 当前作用域是否是声明类;
  • 属性是否已经处于已初始化状态(IS_UNDEF 之外的状态)。

如果属性已经被赋值,再次写入就会触发错误。这个检查是浅层的——它只关心属性槽位是否被写过,不关心写入的值是什么类型、是否可变。

写时复制的基本机制

PHP 的 COW 机制作用于 zval 层面。当你把一个数组或对象赋值给另一个变量时,引擎并不会立即复制底层数据,而是增加引用计数:

$a = [1, 2, 3];
$b = $a;        // 引用计数 +1,共享同一份 HashTable
$b[] = 4;       // 触发分离,$a 保持不变

对于对象,情况略有不同:对象变量存储的是指向 zend_object 的句柄,赋值时复制的是句柄,对象本身始终共享。也就是说,对象天然是“引用语义”,COW 对对象而言只作用于对象句柄的容器(比如数组中的对象元素),而不是对象内部状态。

readonly 与 COW 的交汇点

真正的复杂性出现在 readonly 属性持有数组或对象时。

场景一:readonly 数组属性

class Config {
    public readonly array $items;

    public function __construct(array $items) {
        $this->items = $items;
    }
}

$config = new Config(['a' => 1]);

此时 $config->items 是一个数组 zval。如果你尝试:

$config->items['b'] = 2;

会发生什么?引擎会先尝试写入 items 属性下的 'b' 键。由于 items 是 readonly,写入路径会检查属性标志,直接抛出错误。但这里有一个细节:如果 items 属性的引用计数大于 1(比如外部还持有同一个数组的引用),引擎在分离之前就已经因为 readonly 检查而失败了。也就是说,readonly 检查优先于 COW 分离。

这意味着你不能通过“间接修改”来绕过 readonly——$config->items['b'] = 2$config->items[] = 2 都会失败。这是符合预期的。

场景二:readonly 对象属性

class Order {
    public readonly Customer $customer;

    public function __construct(Customer $customer) {
        $this->customer = $customer;
    }
}

$customer = new Customer('Alice');
$order = new Order($customer);
$customer->name = 'Bob';  // 合法!
echo $order->customer->name;  // 输出 Bob

这是 readonly 最容易被误解的地方。readonly 保护的是 Order::$customer 这个属性槽位,不允许你把它替换成另一个 Customer 对象。但 Customer 对象本身是可变的,外部仍然可以修改它。

从内核角度看,$order->customer 存储的是一个对象句柄 zval。readonly 标志阻止的是对这个 zval 的重新赋值,而不是对句柄指向的 zend_object 内部属性的修改。

场景三:COW 与 readonly 的“伪不可变”

考虑以下代码:

class Container {
    public readonly array $data;

    public function __construct(array $data) {
        $this->data = $data;
    }
}

$source = ['x' => 1];
$container = new Container($source);
$source['y'] = 2;  // 触发 COW 分离,$container->data 不受影响

这里 COW 帮了忙:由于 $source$container->data 共享同一个 HashTable,当 $source 被修改时,引擎会分离出一份新的 HashTable 给 $source$container->data 仍然指向原始数据。这看起来像是“意外获得了不可变性”。

但这种保护是偶然的,不是 readonly 的语义保证。如果你通过其他方式持有 $container->data 的引用(比如 $ref = &$container->data),COW 的行为就会变得复杂。而且,如果数组元素本身是对象,COW 只复制数组结构,不复制对象,修改对象内部状态仍然会“穿透”到 readonly 属性。

构建真正的不可变对象

要让 readonly 发挥最大价值,需要配合以下模式:

第一,对数组使用 array_is_list + 防御性复制。 在构造函数中接收数组时,如果调用方可能保留引用,显式复制一份:

public function __construct(array $data) {
    $this->data = [...$data];  // 强制分离
}

第二,对对象属性使用不可变类型。 如果 Customer 本身也是 readonly 类,那么整个对象图就是不可变的:

final class Customer {
    public function __construct(
        public readonly string $name,
        public readonly string $email,
    ) {}
}

第三,使用 final 类防止子类破坏不可变性。 子类可以添加可变的属性或方法,从而绕过父类的不可变契约。

第四,注意 clone 的行为。 clone 一个包含 readonly 属性的对象时,克隆出的对象属性仍然是已初始化状态,不能重新赋值。如果需要“修改后克隆”,必须通过构造函数重新创建。

性能考量

readonly 检查在属性写入路径上增加了一次标志位判断,开销极小。但在高频写入场景中,如果属性不是 readonly,这个检查会被跳过。对于不可变对象,由于写入只发生在构造阶段,运行时几乎没有额外开销。

COW 与 readonly 的交互不会产生额外的内存复制——readonly 检查失败时,COW 分离根本不会发生。这意味着 readonly 在某些情况下反而避免了不必要的内存分配

小结

readonly 是属性级别的写保护,不是值级别的不可变保证。它与 COW 的交互体现在:readonly 检查优先于 COW 分离,阻止了通过间接修改绕过保护的可能;但 COW 对对象内部状态的“穿透”意味着 readonly 对象属性仍然可能被外部修改。要构建真正的不可变对象,需要 readonly 类、final 修饰、防御性复制和不可变类型设计的协同配合。理解内核中 ZEND_ACC_READONLY 标志的检查时机,以及 zval 引用计数与 COW 分离的触发条件,是写出正确且高效不可变代码的关键。

未经允许不得转载:任鹏个人博客 » PHP 只读属性与不可变对象:readonly 的内核实现与写时复制交互

赞 (0) 打赏

评论 0

取消
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏