PHP只读属性适用场景

wen PHP项目 3

PHP只读属性深度解析:适用场景、实战技巧与避坑指南


目录导读

  1. 什么是PHP只读属性?(快速回顾)
  2. 只读属性的核心价值:为什么你需要它?
  3. 五大高频适用场景(含代码示例)
    • 领域模型与值对象(DTO)
    • 配置对象与不可变设置
    • 依赖注入容器的服务定义
    • 缓存键与请求参数封装
    • 事件对象与消息队列消息
  4. 必知必会:只读属性的限制与陷阱
  5. 实战问答:开发者最关心的4个问题
  6. 什么时候不该用只读属性?

什么是PHP只读属性?(快速回顾)

PHP 8.1 正式引入了只读属性(readonly),它的核心语法非常简单:

PHP只读属性适用场景

class User {
    public readonly string $name;
    public function __construct(string $name) {
        $this->name = $name;
    }
}

一旦在类内部(通常是构造函数)完成初始化,该属性就禁止再次被修改,任何从外部或内部尝试重新赋值的操作,都会抛出 Error 异常,这并非运行时“静默忽略”,而是强制的编译期+运行期双重保护。


只读属性的核心价值:为什么你需要它?

在传统的PHP开发中,我们经常使用 private 属性配合 getter 方法来模拟“只读”,但这种做法存在三个痛点:

  • 代码冗余:每个属性都要写 getter,类体膨胀。
  • 非强制:类内部方法依然可能意外修改私有属性,产生难以追踪的Bug。
  • 可读性差:无法直观看出某个属性是“只读语义”还是“私有存储”。

只读属性从语言层面解决了这个问题,它带来的直接收益是:

  • 不可变性(Immutability):对象状态一旦构建,永不改变,这极大降低了多线程(Swoole/Worker)或复杂回调中的竞态条件风险。
  • 自文档化readonly 关键字就是最清晰的注释,告诉所有人“这个值不允许改”。
  • 内存优化辅助:配合 __clone() 和共享对象,可以安全复用实例,无需深拷贝。

五大高频适用场景(含代码示例)

领域模型与值对象(DTO)

这是最典型的应用场景,当你需要从数据库或API拉取数据,并希望在业务逻辑层传递不变量时。

class Money {
    public readonly float $amount;
    public readonly string $currency;
    public function __construct(float $amount, string $currency) {
        if ($amount < 0) {
            throw new InvalidArgumentException('金额不能为负数');
        }
        $this->amount = $amount;
        $this->currency = $currency;
    }
    // 业务方法:增加金额时返回新对象,而非修改原对象
    public function add(Money $other): Money {
        if ($this->currency !== $other->currency) {
            throw new LogicException('货币不一致');
        }
        return new Money($this->amount + $other->amount, $this->currency);
    }
}

好处:防止业务逻辑中不小心覆盖了订单金额或币种,保证金额计算的严谨性。

配置对象与不可变设置

读取配置文件(如 config.php)后,你希望这些设置全局只读。

class AppConfig {
    public readonly string $dbHost;
    public readonly int $dbPort;
    public readonly bool $debugMode;
    public function __construct(array $settings) {
        $this->dbHost = $settings['host'] ?? 'localhost';
        $this->dbPort = (int)($settings['port'] ?? 3306);
        $this->debugMode = (bool)($settings['debug'] ?? false);
    }
}
// 加载后,任何试图修改 $config->debugMode = true 的操作都会报错
$config = new AppConfig(require 'config.php');

注意:此场景下,需配合 final 类使用,防止子类覆盖。

依赖注入容器的服务定义

在框架或自写的DI容器中,服务定义通常是一次性绑定的,只读属性可以防止容器在运行时被意外篡改。

class ServiceDefinition {
    public readonly string $id;
    public readonly string $class;
    public readonly array $arguments;
    public function __construct(string $id, string $class, array $arguments = []) {
        $this->id = $id;
        $this->class = $class;
        $this->arguments = $arguments;
    }
}

缓存键与请求参数封装

对于HTTP请求的Query参数或POST数据,封装成只读对象后,路由中间件无法恶意修改值,增强安全性。

class SearchRequest {
    public readonly string $query;
    public readonly int $page;
    public readonly int $perPage;
    public function __construct(array $queryParams) {
        $this->query = trim($queryParams['q'] ?? '');
        $this->page = max(1, (int)($queryParams['page'] ?? 1));
        $this->perPage = min(100, max(1, (int)($queryParams['per'] ?? 20)));
    }
}

事件对象与消息队列消息

事件总线或MQ的消息体,发布后不应被监听器修改。

class UserRegisteredEvent {
    public readonly int $userId;
    public readonly string $email;
    public readonly \DateTimeImmutable $occurredAt;
    public function __construct(int $userId, string $email) {
        $this->userId = $userId;
        $this->email = $email;
        $this->occurredAt = new \DateTimeImmutable();
    }
}

必知必会:只读属性的限制与陷阱

  • 不能有默认值:只读属性不能在声明时赋默认值(public readonly int $x = 1; 是语法错误)。
  • 不能在构造函数之外初始化:除了构造函数,其他方法内赋值都会抛错。
  • 不能与 static 一起使用:静态属性不能是只读的(因为静态属性属于类,不依赖于实例)。
  • 注意克隆行为clone 后的对象,其只读属性依然是只读的,除非你在 __clone() 方法中显式重新初始化(因为__clone() 方法内部可以重新赋值)。
  • 类型限制:只读属性不能是 uninitialized 状态,必须在构造函数的 return 之前完成赋值,否则访问时会抛 Error: Typed property must not be accessed before initialization

实战问答:开发者最关心的4个问题

Q1: 只读属性和 private setter 有什么区别?

private setter 是方法级别的控制,外部无法调用,但类内部方法可以修改,只读属性是属性级别的,连类内部方法(除了构造函数)都无法修改,只读更严格,且写法更简洁。

Q2: 如果我想在中间业务逻辑中“修改”一个只读属性怎么办?

不能改,正确做法是创建新对象并复制必要的数据。Money::add() 方法返回新实例,而不是修改原金额,这在领域驱动设计(DDD)中被视为最佳实践。

Q3: 只读属性是否影响性能?

代码层面没有运行时开销,PHP引擎只是添加了写保护检查,类似于 private 的访问控制,性能损耗可忽略不计,但它要求你写出更多“创建新对象”的代码,可能会略微增加内存分配,但通常利大于弊。

Q4: 只读属性可以配合 __set 魔术方法吗?

不行,在类中声明了 readonly 属性后,PHP会跳过 __set 魔术方法对该属性的处理,且如果你尝试在类外通过 __set 来修改一个未定义的可访问属性,那“未定义”本身就是问题。


什么时候不该用只读属性?

虽然只读属性好处多多,但并非万能:

  • 不适合“可变实体”:例如ORM实体类(如Doctrine Entity),它们需要跟踪字段变化,用于脏检查,对它们使用只读属性会严重阻碍持久层操作。
  • 不适合“延迟加载”场景:如果属性需要依赖其他服务在运行中填充,那么只读属性会导致无法初始化。
  • 不适合“简单数据传输”但字段过多:如果一个类有几十个属性,全部写 readonly 并在构造函数里赋值会显得笨拙,此时考虑使用 array 或者配合 ValueObject 工厂函数。

终极建议:将只读属性视为“值类型”的构建块,而不是“实体类型”,在你的代码中,凡是符合“一旦创建,永不变化”语义的对象,大胆使用 readonly;凡是需要状态流转的,请回归传统的 private + 修改器。


(本文旨在提供技术实践参考,所有代码均可直接运行于 PHP 8.1+ 环境。)

抱歉,评论功能暂时关闭!