编程 给 readonly 属性加了默认值,构造函数里再赋值就抛 Cannot modify readonly property

2026-10-21 21:03:32

给 readonly 属性加了默认值,构造函数里再赋值就抛 Cannot modify readonly property

PHP 8.6 允许 readonly 属性在声明处直接写默认值(RFC: readonly_property_defaults,投票 24-0 通过;RC3 版本日期 2026-10-08,GA 计划 2026-11-19)。语法上看起来只是给属性加了个初始值,但语义上有个容易踩的点:默认值本身已经占用了 readonly 属性的那一次初始化赋值。

报错现场

final readonly class Rule
{
    public string $name = 'default';

    public function __construct(string $name)
    {
        $this->name = $name; // Error: Cannot modify readonly property Rule::$name
    }
}

错误原文是:

Cannot modify readonly property Rule::$name

不是引擎没识别出构造函数,而是属性在构造函数体执行之前就已经被设置好了。$this->name = $name 在 readonly 语义下属于第二次写入,因此会直接失败。

规则只有一句

默认值算作那一次初始化赋值。readonly 属性只允许初始化一次,声明处的默认值占掉了这个名额,后续任何赋值——包括构造函数体里的——都算修改 readonly 属性。

也因此,带默认值的 readonly 属性是一个「声明即定型」的常量式属性,而不是「给个初始值、构造里再覆盖」的普通属性。

它和 8.4 的接口属性是配套的

这个 RFC 的动机来自 PHP 8.4 的接口属性(interface property hooks)。接口可以声明:

public string $name { get; }

实现类必须为这个属性提供不可变值。PHP 8.5 之前只有两种写法:在构造函数体里逐个常量赋值,或者写四个返回常量的 getter。构造函数版本长这样:

// PHP 8.5
final readonly class CsvReport implements ReportBlueprint
{
    public string $name;
    public string $extension;
    public string $template;
    public array $columns;

    public function __construct()
    {
        $this->name = 'CSV';
        $this->extension = 'csv';
        $this->template = 'reports/%s.csv';
        $this->columns = ['id', 'name', 'total'];
    }
}

到 8.6 可以直接写默认值:

// PHP 8.6
final readonly class CsvReport implements ReportBlueprint
{
    public string $name = 'CSV';
    public string $extension = 'csv';
    public string $template = 'reports/%s.csv';
    public array $columns = ['id', 'name', 'total'];
}

两者语义等价,后者省掉了整个构造函数。也正因为它们是同一个「唯一初始化」的两种写法,不能混用——一旦属性上有默认值,构造函数体里的赋值就变成非法的修改。原始 readonly RFC 曾认为默认值「没什么用」,接口属性的出现改变了这个判断。

unset 也是错误

对这类带默认值的 readonly 属性调用 unset() 是错误。readonly 属性初始化后不能被取消设置,默认值不会让它成为例外。

迁移时的两种改法

  1. 值本来就是固定的:把构造函数体里的赋值全部删掉,只保留声明处的默认值,参照上面 CsvReport 的 8.6 版本。

  2. 仍然需要「构造时可传可不传」:不能在 readonly 属性上同时用默认值 + 构造赋值。把默认值移到构造参数上,属性本身不声明默认值:

final readonly class Rule
{
    public string $name;

    public function __construct(string $name = 'default')
    {
        $this->name = $name;
    }
}

另一种做法是拆成两个类或工厂,由调用方选择走哪条路径。

这里的关键判断是:属性上的默认值表达的是「这个值只有一个」,构造参数上的默认值表达的是「调用方可以不传」。前者是 8.6 新增的能力,后者在 readonly 属性上一直可用。迁移时先确认要表达的是哪一种,再决定默认值写在属性上还是参数上。

版本信息

  • 特性:readonly 属性声明默认值
  • RFC:readonly_property_defaults,24-0 通过
  • 可用版本:PHP 8.6(RC3 2026-10-08,GA 计划 2026-11-19)
  • 相关背景:PHP 8.4 接口属性(interface property hooks)
复制全文 生成海报 PHP 8.6 类型系统

推荐文章

程序员茄子在线接单