PHP 8.1 引入的 readonly 修饰符,表面上看只是一个语法糖,让属性只能在初始化时赋值一次。但当你把它与 PHP 的写时复制(Copy-on-Write, COW)机制放在一起审视时,会发现两者之间存在微妙而深刻的交互。理解这些交互,不仅能帮你写出更安全的不可变对象,还能避免一些看似诡异的运行时行为。
readonly 的语义边界
readonly 属性有三个核心约束:
- 只能在声明所在类的作用域内初始化一次。
- 初始化后任何写入都会抛出
Error。 - 不能有默认值(因为默认值意味着隐式初始化)。
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 的内核实现与写时复制交互

