登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  php教程

PHP readonly 类怎样处理需要延迟初始化的属性

来源:17golang原创

时间:2026-11-14 05:09:46 263浏览 收藏

PHP 的 readonly 并不要求每个属性都在构造函数里立刻加载。真正的限制是:有类型的只读属性只能在允许的作用域内完成一次初始化,之后不能再次赋值。把昂贵数据保留为“未初始化”,在对象第一次真正需要它时赋值,就能同时得到延迟加载和一次写入约束。

如果只是延迟一个字段,优先在类内部用访问方法完成加载;如果要延迟整个服务对象的构造,PHP 8.4 的 Lazy Objects 更合适。本文不把 readonly 说成深度不可变,也不把字段级懒加载和代理对象混为一谈。

官方文档:https://www.php.net/language.oop5.properties

先判断:要延迟的是属性还是整个对象

先看延迟范围,再选实现方式。一个报告对象可能已经有轻量的标识信息,但只有调用 rows() 时才需要从存储层读取大量记录;这属于字段级延迟。相反,如果连服务对象的构造、依赖注入或实体水化都不希望发生,就应该考虑 PHP 8.4 的 Lazy Objects。

场景更合适的边界需要留意
一个数组、配置或计算结果很昂贵readonly 属性 + 类内访问方法第一次成功赋值后不能重算或覆盖
希望兼容魔术属性访问未初始化属性 + __get()要明确哪些名称允许触发加载
整个服务或实体都应按需创建PHP 8.4 lazy ghost / lazy proxy任何会观察对象状态的操作都可能触发初始化
PHP readonly 属性从构造完成到第一次赋值的一次性状态流转说明图
图1:状态说明图,展示 readonly 属性从未初始化到第一次赋值的单向生命周期;这是静态解释图,不是运行截图。

让 readonly 属性保持未初始化

readonly 属性必须有类型,不能写普通默认值。构造函数只负责保存轻量字段,昂贵字段先不读、不写;访问方法用 isset() 判断它是否已经初始化,第一次进入时完成赋值。之后每次调用只读取同一个结果。

source;
    }

    public function rows(): array
    {
        // isset 对未初始化的 typed property 返回 false,不会读取出一个伪造的空数组。
        if (!isset($this->rows)) {
            // 只有第一次成功走到这里时,readonly 属性才被赋值。
            $this->rows = $this->loadRows();
        }

        return $this->rows;
    }

    private function loadRows(): array
    {
        // 示例只表达延迟边界;真实项目应在这里处理存储层异常。
        return [
            ['id' => 1, 'state' => 'ready'],
            ['id' => 2, 'state' => 'queued'],
        ];
    }
}

$report = new Report('daily-import');
// 构造函数没有加载 rows,第一次调用才触发 loadRows()。
$first = $report->rows();
// 第二次调用只读已经初始化的属性,不会再次加载或覆盖它。
$second = $report->rows();

这里的关键不是把属性声明成可空类型,而是让“未初始化”和“已初始化”成为两个明确状态。如果加载失败,赋值语句没有完成,属性仍然没有成功初始化;因此要不要允许下一次调用重试,应由业务代码决定。若失败结果也必须缓存,就应另设状态字段或把失败对象作为一次结果保存,而不是把异常吞掉。

用 __get() 做魔术访问时先收紧边界

PHP 的 readonly properties RFC 说明,初始化前显式 unset() 可以让属性保持未初始化,并配合魔术方法实现传统的懒加载模式。这个方案适合需要兼容 $object->property 访问的旧接口,但它更容易把拼写错误当成加载请求,因此必须做名称白名单。

displayName);
    }

    public function __get(string $name): mixed
    {
        // 只允许明确列出的属性,避免把未知字段静默当成远程查询。
        if ($name !== 'displayName') {
            throw new LogicException('未知的延迟属性:' . $name);
        }

        // 只读属性在声明类作用域内完成第一次直接赋值。
        $this->displayName = $this->fetchDisplayName($this->userId);
        return $this->displayName;
    }

    private function fetchDisplayName(string $userId): string
    {
        // 示例用固定值代替外部请求;生产代码应保留超时和异常处理。
        return 'user-' . $userId;
    }
}

$profile = new LazyProfile('42');
// 第一次读取触发 __get;成功后 displayName 不能再次赋值。
echo $profile->displayName;

工程上更推荐前一个显式方法版本:它的调用路径、返回类型和错误处理都更容易被 IDE、静态分析器和代码审查识别。只有在必须保留既有公共属性访问语法时,才使用 __get(),并为未知名称、加载失败和重复进入建立明确规则。

PHP 8.4 用 Lazy Objects 延迟整个对象

PHP 8.4 引入 Lazy Objects,提供 lazy ghost 和 lazy proxy 两类策略。它们解决的是对象级问题:对象先以延迟状态存在,等属性被读取、修改,或其他操作需要观察对象状态时,再调用初始化器。

PHP 单属性懒加载与 lazy ghost、lazy proxy 的职责范围对比结构图
图2:决策结构图,比较单属性懒加载、lazy ghost 与 lazy proxy 的延迟范围;这是静态解释图,不是 PHP 运行结果。

lazy ghost 会在原对象上完成初始化,适合希望调用方最终拿到同一个对象身份的场景;lazy proxy 则把访问转发给实际对象,选择时要把代理对象与真实实例的身份差异考虑进去。

items);
    }
}

$reflection = new ReflectionClass(Catalog::class);

$catalog = $reflection->newLazyGhost(
    static function (Catalog $ghost): void {
        // 初始化器只在对象首次被观察时运行,并在原对象上补齐状态。
        $items = [
            ['sku' => 'A-1'],
            ['sku' => 'B-2'],
        ];
        $ghost->__construct($items);
    },
);

// 读取对象状态会触发初始化器,之后再按普通对象使用。
echo $catalog->count();

不要为了延迟一个数组就引入对象级代理。Lazy Objects 更适合服务、实体或依赖对象本身很重的场景;如果对象已经创建,只是其中一个字段昂贵,类内访问方法更直接。

失败时怎么处理:一次赋值不等于一次无条件重试

readonly 约束只管成功的属性写入,不会替你设计缓存失败、并发访问或内部对象变更。发布前可以按下面的运行手册检查:

  1. 确认属性是有类型的,并且没有试图声明普通默认值;readonly static property 也不受支持。
  2. 确认延迟赋值发生在声明类允许的作用域内,不能从外部直接初始化。
  3. 确认加载方法成功后不会再次覆盖同一属性;如果需要刷新,改成返回新对象或使用非 readonly 的显式缓存策略。
  4. 如果属性保存的是对象,记住 readonly 锁住的是属性引用,不会阻止该对象内部状态改变。
  5. 如果使用 PHP 8.4 Lazy Objects,检查哪些操作会观察对象状态,避免在日志、序列化或调试输出中意外触发昂贵初始化。

一个实用的判断是:字段级需求用“未初始化属性 + 显式访问方法”,兼容旧属性语法才考虑 __get();对象级需求再用 newLazyGhost() 或 newLazyProxy()。这样 readonly 保持的是清晰的一次写入边界,延迟加载负责的是发生时间,两者不会互相掩盖。

相关问题

readonly 属性可以声明默认值吗?

不能声明普通默认值,因为那会让属性在对象建立时就已经是固定常量语义。需要延迟初始化时,保留有类型但无默认值的状态。

readonly 类是不是深度不可变?

不是。属性不能重新指向别的值,但属性里保存的对象仍可能修改自己的内部状态。

什么时候不该使用 __get()?

只要可以使用明确的 rows()、config() 等访问方法,就不必隐藏加载路径。魔术访问应留给必须兼容既有调用方式的接口。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>