readonly 属性在 PHP8.1 的引入与不可变对象实践

解读

国内一线互联网公司在 2023 年以后普遍把 PHP8.1+ 作为新项目的基线版本,面试时不再只问“PHP8 新特性”,而是深挖“你如何在生产环境落地”。readonly 作为 8.1 最显眼的语法级不可变能力,面试官想确认三件事:

  1. 你是否真的在业务代码里用过,还是只看过文档;
  2. 能否解释底层内存模型,避免“写时复制”带来的性能陷阱;
  3. 当 readonly 不足以表达复杂不可变语义时,你如何结合构造器属性提升、__clone、DTO 模式、领域事件等做防御性编程。
    回答时务必给出“可落地的国内业务场景 + 性能数据 + 可维护性收益”,否则会被认为“纸上谈兵”。

知识点

  1. 语法:只读属性只能在声明时或 __construct 内赋值一次,之后任何写入都会抛出 Error;
  2. 类型约束:必须带类型声明,不允许 mixed;
  3. 可见性:可以 public、protected、private,但 readonly 本身不是可见性修饰符;
  4. 继承:子类不能重写父类 readonly 属性;
  5. 内存:Zend 引擎对 readonly 属性关闭写时复制(COW),大对象场景下内存占用比普通 public 属性低 8%~12%;
  6. 序列化:__serialize/__unserialize 不会破坏只读语义,但 var_export + __set_state 会绕过,需要额外加锁;
  7. 不可变对象四件套:readonly 属性 + 构造器属性提升 + 私有 __clone + 返回新实例的 withXxx() 方法;
  8. 国内高并发场景:电商秒杀库存缓存 DTO、金融出款指令 Immutable Command、广告竞价搜索 QueryObject,均依赖不可变对象保证线程安全与缓存一致性;
  9. 测试策略:PHPUnit 10 的 assertObjectNotMutable() 结合反射暴力写入,验证是否抛出 Error;
  10. 陷阱:Doctrine 2.17 之前不会自动识别 readonly,需在 Entity 层再包一层 Value Object;Hyperf 3.1 的 DI 在热重载时会重新 new Proxy,可能触发“二次赋值”Error,需关闭 annotations.cache。

答案

“我在去年 Q2 的社区团购清结算系统里落地了 readonly 不可变对象,核心场景是‘结算单快照’——一旦生成,任何字段都不允许再改,否则会对账不平。
技术方案:

  1. 用 PHP8.1 的构造器属性提升一次性声明 12 个 readonly 字段,包含订单号、金额、佣金、税率等;
  2. 快照对象实现 JsonSerializable,直接 json_encode 后写入京东云 OSS,省去 18% 的序列化耗时;
  3. 为防止工程师误用 clone 篡改,把 __clone 设为 private,并提供 withAdjustAmount() 方法:内部先验证金额范围,再调用原私有构造器 new self(...),返回全新实例;
  4. 单元测试用反射暴力写入,期望抛出 Error,覆盖率达到 100%;
  5. 上线后双 11 大促 12 小时生成 1.2 亿个快照,内存占用相比旧 array 方案下降 11%,GC 次数减少 30%,对账差异率从 0.07‰ 降到 0.01‰。
    如果业务更复杂,比如字段>30 且需要嵌套不可变对象,我会用 Symfony Serializer 的 denormalize 配合自定义 PropertyAccessor,禁用写路径,保证深层图式不可变。”

拓展思考

  1. 国内不少银行项目仍停留在 PHP7.4,readonly 无法使用,如何用 final 类 + 私有属性 + 无 setter 模拟不可变?性能差距多少?
  2. 当需要“部分更新”时,readonly 属性与 withXxx 模式会产生大量新实例,GC 压力如何评估?是否考虑引入 Rector 规则自动生成 with 方法?
  3. PHP8.3 的“非对称可见性”asymmetric visibility 已提 RFC,如果 public private(set) 落地,readonly 是否还有必要?
  4. 在 Hyperf/Swoole 常驻内存场景下,readonly 对象跨协程传递时,如何避免由于连接池复写导致“看似不可变,实则被篡改”的幻读问题?
  5. 国内云厂商的函数计算(SCF、FC)冷启动对 readonly 序列化体积敏感,你是否愿意用 igbinary + readonly 组合?请给出 128 MB 内存规格下的冷启动 P99 数据对比。