前阵子翻旧项目里的购物车类,自己都看笑了。一个 CartItem,光是 getQuantity、setQuantity、getUnitPrice、setUnitPrice 就占了几十行,里面的参数校验几乎一模一样。最难受的是,类里真正的业务逻辑早被这些样板代码淹没,看半天不知道这个对象到底想表达什么。
后来切到 PHP 8.4,用 property hooks(属性钩子)把这个类重写了一遍,类整体短了差不多一半。于是想把这个过程完整写下来,因为我自己第一次看钩子时,也被“set 里为什么要给自身赋值”这个问题卡住过。
先从旧代码说起
这是很常见的写法:quantity 和 unitPrice 都被做成 private,靠一堆 getter/setter 保护起来,然后在小计金额上再补一个 getter。
class CartItem
{
private int $quantity;
private float $unitPrice;
public function getQuantity(): int
{
return $this->quantity;
}
public function setQuantity(int $quantity): void
{
if ($quantity < 1) {
throw new InvalidArgumentException('数量最少是 1');
}
$this->quantity = $quantity;
}
public function getUnitPrice(): float
{
return $this->unitPrice;
}
public function setUnitPrice(float $unitPrice): void
{
if ($unitPrice < 0) {
throw new InvalidArgumentException('单价不能是负数');
}
$this->unitPrice = $unitPrice;
}
public function getSubtotal(): float
{
return $this->quantity * $this->unitPrice;
}
}
这段代码没毛病,但问题也很明显:quantity 的赋值边界和 unitPrice 的赋值边界,是两套长得几乎一样的方法。以后想加个“数量超过 100 要二次确认”的逻辑,你得先进到这堆方法里找到对应位置,然后小心翼翼改。
属性钩子想干的事很简单:把读写时要做的事,直接放到属性声明旁边。
用属性钩子重构
下面是同一个类在 PHP 8.4 下的写法:
class CartItem
{
public int $quantity {
set {
if ($value < 1) {
throw new InvalidArgumentException('数量最少是 1');
}
$this->quantity = $value;
}
}
public float $unitPrice {
set {
if ($value < 0) {
throw new InvalidArgumentException('单价不能是负数');
}
$this->unitPrice = $value;
}
}
public float $subtotal {
get => $this->quantity * $this->unitPrice;
}
public function __construct(int $quantity, float $unitPrice)
{
$this->quantity = $quantity;
$this->unitPrice = $unitPrice;
}
}
先别急着说“看不懂”,其实语法很直白:set 块里有一个固定的变量 $value,它代表外部赋值时的表达式结果。你在 set 块里写完校验之后,再把这个值“落”到属性背后,赋值才算真正完成。
很多人第一次看到 $this->quantity = $value 会以为它在递归调用自己,接着脑补一场死循环。我一开始也这么想。
关键点在这里:在钩子的代码块内部写 $this->quantity,访问的不是“带钩子的属性本身”,而是属性背后那块最原始的存储空间(backing store)。PHP 特意让钩子内部访问绕过了钩子逻辑,所以不会递归。
如果你不在 set 块里主动把 $value 写回 backing store,那这次赋值会直接消失,外部赋过去的值不会对对象产生任何影响。
public string $nickname {
set {
// 忘记写 $this->nickname = $value;
// 外部给 $user->nickname 赋值后,等于没赋
}
}
上面这个坑不看文档确实容易踩,我也是在 PHP 8.4 刚出时试运行,发现属性读出来还是 null,才回头去查的说明。
虚拟属性让“小计”变得非常自然
重构后的 CartItem 里,$subtotal 是最让我喜欢的一段:它只有 get,没有 set,是一个“虚拟属性”。
所谓“虚拟”,就是它背后没有真实的数据槽位,每次读取都按 get 里的表达式现算。由于没写 set,你要是手贱写一句 $item->subtotal = 100,PHP 会直接报错,非常安全。
public float $subtotal {
get => $this->quantity * $this->unitPrice;
}
传统的 getSubtotal() 方法当然也能做到同样的事,但属性写法把“这是一个由其他属性推导出来的结果”这件事表现得更加明确:读它像读一个普通字段,行为却像一个只读方法。
在旧代码里,你需要去方法列表里找 getSubtotal 才能理解“小计是现算的”;新代码里,你只要看一眼 subtotal 上面的三行声明,就全明白了。
什么场景最适合用属性钩子
经过这次重构,我的感受是:以下两种情况收益最大。
第一种是属性在赋值前必须做统一校验。比如金额不能为负、标题必须去空格、枚举必须落在白名单里。以前这些校验分散在 setter 里,现在有了 set 钩子,校验就写在属性身上,不会被放到类文件的其他位置。
第二种是需要实时计算的聚合结果。购物车小计、订单总金额、用户头像地址拼接,这些“看一眼就会算出来的值”适合做成只有 get 的虚拟属性。相比直接用方法,属性钩子对下游调用方更省事:调用方不需要知道它到底是内存字段还是计算结果。
但别把昂贵操作伪装成属性
不推荐把所有 getter 都改成钩子。特别是当一个 getter 后面藏着数据库查询、缓存读取、RPC 请求时,我会老老实实保留方法。
比如加载一个用户的完整档案,写成 $order->loadCustomer() 比写成 $order->customer 要好。原因是:属性这个语法本身会给读代码的人一种“访问成本不高”的直觉。如果哪天真有人在一个循环里访问了你的 $order->customer,然后发现每次触发一次远程调用,那时候追查起来会相当酸爽。
换句话说,属性钩子更适合表达“轻量逻辑”:校验、格式化、归一化、简单推导。重量逻辑还是留给方法吧。
另外有一点也要留意:PHP 8.4 的属性钩子目前和 readonly 属性之间限制不少,想在一个只读属性上做动态校验仍然需要构造函数来完成,不能指望 set 钩子去碰 readonly。
重构后的样子
最终那个购物车类,代码量从原来的老长一段变成下面这个清爽结构。去掉注释后,核心逻辑非常容易扫完:
class CartItem
{
public int $quantity {
set {
if ($value < 1) {
throw new InvalidArgumentException('数量最少是 1');
}
$this->quantity = $value;
}
}
public float $unitPrice {
set {
if ($value < 0) {
throw new InvalidArgumentException('单价不能是负数');
}
$this->unitPrice = $value;
}
}
public float $subtotal {
get => $this->quantity * $this->unitPrice;
}
public function __construct(int $quantity, float $unitPrice)
{
$this->quantity = $quantity;
$this->unitPrice = $unitPrice;
}
}
少了大概 40% 的样板代码,多出来的全是之前被 getter/setter 夹住的业务规则。我看完后很直接的想法是:以后写这种领域小类,应该优先考虑属性钩子,让校验和推导逻辑回到它们真正该待的地方。

