PHP 8.6 允许 readonly 属性声明默认值

PHP 8.6 允许 readonly 属性声明默认值。本文将说明这一变化的具体内容、接口属性为何使这项功能值得引入,以及需要留意的边界情况。

如果曾经写过下面这段代码,却发现 PHP 拒绝编译,那么这种情况并不罕见:

php
final class CreateBooksTable
{
    public readonly string $name = '2026_01_01_create_books_table';
}

自 PHP 8.1 引入 readonly 属性以来,这行代码一直会导致致命错误:Readonly property CreateBooksTable::$name cannot have default value。从 PHP 8.6 开始,它可以正常编译。Nick Sdot 提出的 Readonly Property Defaults RFC 于 2026 年 8 月 7 日通过,共获得 24 票赞成、0 票反对和 5 票弃权,并在几天后合并到 php-src。PHP 8.6 计划于 2026 年 11 月 19 日发布。

这是一项很小的改动。该 RFC 没有引入新语法,没有改变引擎的内部表示,也没有带来任何向后不兼容的变化。它所做的只是移除一项编译期检查。不过,对于插件系统、迁移执行器或规则注册表的开发者而言,它填补了一个长期造成不便的缺口。

这项限制最初为何存在

值得注意的是,这从来都不是技术限制。2021 年最初的 Readonly Properties RFC 直接说明了原因:

由于默认值会被视为一次初始化赋值,因此拥有默认值的 readonly 属性本质上与常量相同,并没有太大的实际用途。如果未来允许将 new 表达式用作属性默认值,这一概念可能会变得更有价值。

这一理由在此后的三年中一直成立。如果需要在类上保存一个固定值,可以使用类常量。带默认值的 readonly 属性本质上仍然只是常量。

随后,PHP 8.4 引入了接口属性,原有的权衡也随之发生变化。现在可以编写一个要求实例属性的契约:

php
interface Rule
{
    public string $className { get; }
}

类常量无法满足这个契约,方法同样无法满足。接口明确要求一个属性,而在 PHP 8.6 之前,为它提供固定值的唯一方式是编写样板代码:

php
final class RuleA implements Rule
{
    public readonly string $className;

    public function __construct()
    {
        $this->className = SomeParser::class;
    }
}

如果一个构造函数存在的唯一目的只是硬编码一个字符串,那么它并非设计决策,而是一项额外负担。在 PHP 8.6 中,可以直接这样写:

php
final class RuleA implements Rule
{
    public readonly string $className = SomeParser::class;
}

// 也可以使用 readonly 类,其中的属性隐式为 readonly:
final readonly class RuleB implements Rule
{
    public string $className = SomeParser::class;
}

RFC 自身用于说明动机的示例是一个蓝图驱动的数据摄取器,这也是实际开发中更可能遇到的形态:

php
interface IngestorBlueprint
{
    public string $name { get; }
    public string $stub { get; }
    public string $path { get; }
    public array $steps { get; }
}

final readonly class SourceOneChangelogIngestor implements IngestorBlueprint
{
    public string $name = 'Source One';
    public string $stub = 'stubs/output.md';
    public string $path = 'source-one/changelog/%s/%s';

    public array $steps = [
        ParserShapeA::class,
        HandleShapeA::class,
    ];
}

固定元数据由契约强制约束,同时不需要构造函数和 getter 方法。如果维护的系统需要发现类并从类实例中读取配置,无论是 Symfony Compiler Pass、扫描目录的 Laravel Service Provider,还是 php[architect] 用于构建其期刊清单的工具,这都是过去不得不使用冗长写法实现的模式。

最重要的规则

默认值会被视为初始化赋值。属性会在构造函数体运行之前完成赋值。此后的每一次写入,包括在构造函数内部写入,都属于修改操作,因此会失败:

php
final readonly class Rule
{
    public string $className = SomeParser::class;

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

如果某个值有时需要注入、有时需要使用回退值,那么 readonly 属性默认值并不是合适的工具。此时应改用构造函数属性提升,并为相应参数提供默认值。RFC 明确把属性提升列为非目标:属性提升参数上的默认值仍然是参数默认值,而不是属性默认值,其行为没有变化。

值得了解的边界情况

接口仍然区分读取与写入

带默认值的 readonly 属性可以满足 { get; },但不能满足 { get; set; },编译器会明确报告这一问题。这是有意设计的行为。

继承遵循常规规则

子类可以重新声明属性并覆盖默认值,但仍须遵守通常的属性兼容性检查:

php
abstract class ParentRule
{
    public readonly int $priority = 1;
}

final class ChildRule extends ParentRule
{
    public readonly int $priority = 2;
}

var_dump(new ParentRule()->priority); // int(1)
var_dump(new ChildRule()->priority);  // int(2)

无法再使用 unset()

尚未初始化的 readonly 属性可以在其声明作用域内通过 unset() 清除,这正是利用 __get() 实现延迟初始化技巧的基础。带默认值的 readonly 属性已经完成初始化,因此 unset($this->prop) 会抛出错误,__get() 也永远不会触发。

克隆时仍有一次写入机会

__clone() 内部以及使用带修改项的克隆(clone-with)语法时,可以像处理其他已经初始化的 readonly 属性一样,对该属性重新初始化一次:

php
final class Counter
{
    public readonly int $value = 1;

    public function withValue(int $value): self
    {
        return clone($this, ['value' => $value]);
    }
}

$counter = new Counter();
var_dump($counter->value);               // int(1)
var_dump($counter->withValue(2)->value); // int(2)

序列化会包含该属性

由于属性已经初始化,它会进入序列化后的数据,并且可以通过原生机制或 __unserialize() 从中恢复。对象完成还原后,该属性会再次受到 readonly 约束。如果对象会被序列化后写入缓存,之后又修改了默认值,就需要注意:反序列化恢复的是旧值,而不是新的默认值。

非对称可见性不会形成漏洞

public public(set) readonly int $id = 1; 是合法语法,但写入 $id 仍然会失败,因为默认值已经完成了初始化。

trait 只有在声明完全匹配时才能组合

两个 trait 如果声明了相同的 readonly 属性和相同的默认值,可以正常组合;如果默认值不同,则会产生致命错误。这与现有的 trait 属性规则一致。

反射行为符合预期

isReadOnly() 返回 truehasDefaultValue() 返回 truegetDefaultValue() 返回对应的默认值,不需要进行任何特殊处理。

实际需要做什么

在 11 月之前,无需采取任何行动。这项变化不会破坏向后兼容性,也不需要迁移。RFC 的影响分析章节明确指出,生态系统中唯一需要处理的是工具支持:目前仍把 readonly 属性默认值报告为非法的 IDE、语言服务器和静态分析器需要更新。如果在相关工具完成适配之前,使用严格的分析配置检查 PHP 8.6 Alpha 版本代码,可能会看到来自分析器而非引擎的误报。

更广泛的意义值得思考。这项 RFC 之所以能够出现,是因为 2021 年的设计者记录了当初拒绝这一功能的原因,并明确说明了哪些变化可能促使这一决定被重新考虑。接口属性随后到来,条件得以满足,也有人完成了具体实现。与推翻一个从未记录原因的决定相比,这是一种更加健康的语言演进方式。

本作品采用《CC 协议》,转载必须注明作者和本文链接