☰
PHP 8.3的只读类怎么用才规范
2026/10/6 16:30:14 网站建设 项目流程

前言

先纠正一个流传很广的版号错误:readonly类(readonly class)是 PHP 8.2 引入的,不是 8.3。PHP 8.1 引入的是readonly属性(在属性前加readonly关键字),到了 8.2 才允许把readonly写在class前面,一次性把类里所有属性都变成只读。本文按 8.2 讲。如果你按标题里的 8.3 去写,代码在 8.2 环境上其实也能跑,但如果你以为"8.1 也支持",那就会在解析阶段直接失败。

典型症状是这些:给实体类加readonly之后,框架的 hydration(数据填充)突然报Cannot modify readonly property;克隆一个对象想改一个字段,结果抛错;从别处抄来的readonly class在自己的机器上能跑,在同事的机器上整个文件解析失败。

根因在于readonly不是"把属性变成常量",而是把属性的写入限制在"声明它的类作用域内的第一次初始化"。理解了这个语义,绝大多数坑都能提前避开。本文会从语义、初始化时机、值对象写法、反射与克隆四个方面讲清楚,并给出可直接运行的示例(最低版本 PHP 8.2)。

一、readonly class到底是什么

readonly class Foo {}等价于给类里每一个属性都加上readonly,同时带来几条硬性约束:


  • 每个属性必须有类型声明,不能写无类型的属性;

  • 属性不能有默认值(静态属性除外,但静态属性根本不允许存在);

  • 不能声明static属性;

  • 不能加#[\AllowDynamicProperties],动态属性会报错;

  • 只能被同样是readonly的类继承,反过来说,非readonly的类不能继承readonly类。


最后一条经常被忽略。你写了一个readonly class Base,然后class Child extends Base,会直接Fatal error。子类必须也写成readonly class Child extends Base。

二、初始化时机:只有一次,且必须在类作用域内

readonly属性的赋值只允许发生在声明它的那个类的作用域里,并且只能成功一次。最常见的写法就是构造函数属性提升:

final readonly class UserId { public function __construct(public int $value) {} }

这里$value虽然在__construct里被赋的值,但因为提升语法把它声明的类作用域和构造函数绑定了,所以合法。

关键点:作用域比"对象"更重要。子类里不能给父类声明的readonly属性赋值——因为那不在声明它的类作用域内。而通过反射从外部强行赋值同样不行:只要属性已经初始化过,ReflectionProperty::setValue()也会抛Error。

有一个官方文档明确记载的行为值得记住:在声明它的类作用域内,可以对readonly属性执行unset(),让它回到"未初始化"状态,然后就能再次赋值。这不是"把它变成可写属性",而是"把初始化重来一次"。它正是实现克隆后修改字段的技巧基础,但请只在__clone()这类受控场景使用。

三、值对象(Value Object)与不可变更新的写法

readonly类最适合的场景是值对象和 DTO:一旦构造完成就不再变化,用withXxx()方法返回一个修改后的新对象,而不是原地改。

public function withAmount(int $amount): self { $clone = clone $this; $clone->amount = $amount; // 在类作用域内,但属性已初始化 → 会抛错 return $clone; }

上面这段是错的:clone出来的对象属性已经初始化过了。正确做法是在__clone()里先把属性unset()掉,再赋值。下面给出完整可运行的例子。

另外要记住一个容易被误判的点:readonly是浅不可变。属性里存一个对象,那个对象自己的字段照样能改;存一个数组,你对数组做$obj->items[] = x会被拒绝(因为这是修改属性),但如果数组元素本身是对象,改那个对象是允许的。

四、克隆、反射与序列化


  • clone是浅拷贝,且克隆后的readonly属性依然"已初始化";

  • ReflectionProperty::isReadOnly()可以判断属性是否只读,属性钩子/框架序列化常用;

  • json_encode()对readonly类完全正常,它只是普通的公有属性;

  • serialize()也能正常工作,反序列化时属性是通过内部机制恢复的,不受readonly限制。


如果一定要在__clone()里改字段,PHP 8.5 的clone with表达式是更干净的办法,但那要等到 8.5 才能用。下面是 8.2 的完整可运行版本:

<?php // 最低版本:PHP 8.2 declare(strict_types=1); final readonly class Money { public function __construct( public int $amount, // 单位:分 public string $currency = 'CNY', ) { if ($amount < 0) { throw new InvalidArgumentException('金额不能为负'); } } public function withAmount(int $amount): self { // 用 unset 让属性回到未初始化状态,再重新赋值 return $this->rebuilt(amount: $amount); } public function withCurrency(string $currency): self { return $this->rebuilt(currency: $currency); } private function rebuilt(?int $amount = null, ?string $currency = null): self { $clone = clone $this; unset($clone->amount, $clone->currency); // 类作用域内,回到未初始化状态 $clone->amount = $amount ?? $this->amount; $clone->currency = $currency ?? $this->currency; return $clone; } public function format(): string { return sprintf('%s %.2F', $this->currency, $this->amount / 100); } } $price = new Money(1999); $discounted = $price->withAmount(1599); echo $price->format(), PHP_EOL; // CNY 19.99 echo $discounted->format(), PHP_EOL; // CNY 15.99 echo ($price === $discounted ? 'same' : 'different'), PHP_EOL; // different // 反射初始化前检查 $rp = new ReflectionProperty(Money::class, 'amount'); var_dump($rp->isReadOnly()); // bool(true)

注意unset()只能对已经初始化的属性用;对未初始化属性unset()是无操作,随后赋值本来就合法。rebuilt()这种做法属于"为了不可变而绕了一圈",实际项目里更推荐用构造函数直接造新对象,unset只在需要保留大量字段、只改一两个的场景下才划算。

常见坑点

1. 用反射或 ORM 给已初始化的readonly属性赋值

❌$rp->setAccessible(true); $rp->setValue($obj, $v);——setAccessible()只能解决可见性,解决不了readonly,照样抛Error: Cannot modify readonly property。 ✅ 让 hydration 走构造函数,或者用专门的"未初始化时填充"流程(先newInstanceWithoutConstructor(),再在类作用域内赋值)。

2. 在readonly类里声明静态属性

❌readonly class Config { public static array $cache = []; }——Fatal error。 ✅ 静态数据用类常量或独立类承载。

3. 子类没加readonly

❌readonly class Base {}+class Child extends Base {}——Fatal error。 ✅ 子类一并写readonly class Child extends Base {},或者干脆给父类加final杜绝继承。

4. 以为readonly是深不可变

❌readonly class Order { public function __construct(public array $items) {} }之后改$order->items[0]['price'] = 1;,期望报错,实际这个写法本身就会因为"修改属性"被拒绝;但$order->items[0]->price = 1;(元素是对象时)是能改的,字段就"偷偷"变了。 ✅ 集合里的元素也用readonly类表达,或者在构造时深拷贝并冻结。

5. 克隆后直接改字段

❌ 在withXxx()里$clone = clone $this; $clone->amount = 5;——抛Error。 ✅ 在__clone()或专门的私有方法里先unset($clone->amount)再赋值。

6. 试图用__set()魔术方法兜住写入

❌ 给readonly类写public function __set($n, $v) { $this->$n = $v; }——既绕不过readonly,readonly类也不允许动态属性,反而多一层困惑。 ✅ 需要可变语义就别用readonly类,改成普通类 + 不对称可见性(public private(set),PHP 8.4)之类更贴切的表达。

7. 把无类型属性写进readonly类

❌readonly class A { public $x; }——Fatal error,报属性必须有类型。 ✅ 补上类型,实在不确定用mixed(但要注意属性类型为mixed时依然受readonly约束)。

总结

写法引入版本说明
readonly属性PHP 8.1单个属性只读,需有类型
readonly类PHP 8.2类内全部属性隐式只读(标题里的 8.3 有误)
类作用域内unset()PHP 8.1 起让只读属性回到未初始化状态,可重新赋值一次
clone withPHP 8.5不可变更新的语法糖,8.2 上不可用
不对称可见性PHP 8.4需要"外读内写"时比readonly更合适

readonly类的价值在于把"这个对象不会再变"写成编译期约束,而不是注释里的君子协定。规范用它的核心只有三条:约束住最低版本是 8.2、把初始化收敛到构造函数、需要"改一个字段"时返回新对象而不是想办法绕过只读。做到这三点,框架和工具都会站在你这边。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询