上周把项目从PHP 8.2升级到PHP 8.4,本来只是冲着性能提升去的。结果用上属性钩子(Property Hooks)的第二天,我默默删掉了原来十几个DTO类里的一大堆getter和setter。这东西不是糖,它直接把类的设计方式给改了。
今天不念文档,直接拿一个真实场景来说透。我们要做一个订单明细类,里面有价格、数量、折扣,还得算总价,而且总价不能被外人随意修改。但最重要的是,我要让代码看起来像普通属性,但内部会自动校验和计算。
老写法:getter/setter堆成山
以前我们写一个订单明细类,大概是这个味儿:
class OrderItem
{
private float $price;
private int $quantity;
private float $discount = 0.0;
private float $total;
public function __construct(float $price, int $quantity, float $discount = 0.0)
{
$this->setPrice($price);
$this->setQuantity($quantity);
$this->setDiscount($discount);
}
public function getPrice(): float
{
return $this->price;
}
public function setPrice(float $price): void
{
if ($price price = $price;
$this->recalculateTotal();
}
public function getQuantity(): int
{
return $this->quantity;
}
public function setQuantity(int $quantity): void
{
if ($quantity quantity = $quantity;
$this->recalculateTotal();
}
public function getDiscount(): float
{
return $this->discount;
}
public function setDiscount(float $discount): void
{
if ($discount 1) {
throw new InvalidArgumentException('折扣必须在0到1之间');
}
$this->discount = $discount;
$this->recalculateTotal();
}
public function getTotal(): float
{
return $this->total;
}
private function recalculateTotal(): void
{
$this->total = $this->price * $this->quantity * (1 - $this->discount);
}
}
用起来倒是不复杂,但每个字段都要写两个方法,重复代码多,而且一眼看过去全是样板。尤其是当你有一堆DTO类的时候,真的想骂人。最烦的是,属性一变必须在setter里手动调用recalculateTotal,忘记一次就出bug。
属性钩子(Property Hooks)到底是啥?
PHP 8.4允许你像声明属性一样声明钩子逻辑。你不再需要写单独的getXxx()和setXxx(),而是直接在属性后面定义钩子。
钩子有两种:get钩子和set钩子。
get钩子在你读取属性时执行,set钩子在属性赋值时执行。这么一来,属性本身就变成了有逻辑的入口。
看一个最简单的例子:
class User
{
public string $name {
get => strtoupper($this->name);
}
}
如果没有属性钩子,你读$user->name,直接拿到原始值。加了get钩子后,读到的就是大写的名字。赋值仍然正常,只是读取时被加工了。
实战:用属性钩子重构订单明细类
我把刚才那个OrderItem用属性钩子重写一遍,你感受一下差异。
class OrderItem
{
public float $price {
set {
if ($value < 0) {
throw new InvalidArgumentException('价格不能为负数');
}
$this->price = $value;
}
}
public int $quantity {
set {
if ($value < 1) {
throw new InvalidArgumentException('数量至少为1');
}
$this->quantity = $value;
}
}
public float $discount {
set {
if ($value 1) {
throw new InvalidArgumentException('折扣必须在0到1之间');
}
$this->discount = $value;
}
}
public float $total {
get => $this->price * $this->quantity * (1 - $this->discount);
}
public function __construct(
float $price,
int $quantity,
float $discount = 0.0,
) {
$this->price = $price;
$this->quantity = $quantity;
$this->discount = $discount;
}
}
注意几个变化:
1. price、quantity、discount变成了public属性,但通过set钩子保证数据合法性。外部赋非法值时照样抛异常,和以前setter一样。
2. total变成了只读计算属性,它只有一个get钩子,在每次读取时动态计算结果。你再也不用担心忘记更新total了,因为它根本不存在存储值。
3. 构造函数里直接对三个属性赋值,触发了对应的set钩子,所以校验自动生效。也没有所谓“构造时别给我调setter”的别扭感。
不对称可见性:让外部只读,内部可写
订单ID、创建时间这些字段,从业务上一般不允许外部修改。以前的写法是把属性设为private,只提供getter。现在可以用不对称可见性配合属性钩子,让属性本身对外只读,对内可写。
class Order
{
public private(set) string $id;
public private(set) DateTimeImmutable $createdAt;
public function __construct()
{
$this->id = uniqid('ord_', true);
$this->createdAt = new DateTimeImmutable();
}
}
这个语法public private(set)意思是:读属性时是public,任何人都能读;写属性时是private,只有类内部方法(包括构造方法)可以赋值。这个特性就是PHP 8.4的不对称可见性。
你可能会说:“以前我写private+getter,不也一样吗?” 对,但代码量少了,语义也更清楚了。你一眼就能看出id是只读属性,不需要再去看有没有setter。
钩子里的特殊变量:$value和$this
set钩子会有一个特殊的变量$value表示要赋的值。get钩子不需要参数,但可以通过$this访问其他属性。
有个陷阱就是,在set钩子内部使用$this->price = $value时,会再次触发set钩子,导致无限递归。所以PHP规定,在set钩子内赋值给同一个属性必须直接操作存储变量。看上面代码,我写的是$this->price = $value会不会递归?其实不会,因为PHP在set钩子内部对这个属性赋值时,会直接写入真实存储区域,而不会再次调用钩子。放心用。但为了更清晰,你也可以用专门的$this->price这样写。
get钩子内部如果直接写$this->total也会递归,所以我用了$this->price这些别的属性。如果你在get钩子里访问被钩子修饰的属性,需要注意。
钩子带来的更多可能性:延迟加载和缓存
get钩子不仅是计算属性,还能做懒加载。比如一个用户对象,有些字段(比如“已下单单数”)从数据库里查很费劲,只有真正用到的时候才去查,查一次之后缓存住。
class User
{
private ?int $orderCountCache = null;
public int $orderCount {
get {
if ($this->orderCountCache === null) {
$this->orderCountCache = $this->fetchOrderCountFromDb();
}
return $this->orderCountCache;
}
}
}
这样业务代码里直接$user->orderCount,内部懒加载并缓存,细节全被封装了。
属性钩子和接口、抽象类怎么搭配?
钩子还可以在接口和抽象类中定义。比如定义一个有速率限制的接口:
interface RateLimitable
{
public int $rateLimit { get; }
}
class ApiClient implements RateLimitable
{
public private(set) int $rateLimit = 100;
}
我们在实现接口时,只需确保属性的公共读取能力一致即可。写起来很自然,比要求实现getRateLimit()方法更简洁。
实际升级中遇到的坑
再好的特性也会有点小坑。我这几天实战下来,遇到三个问题,提醒大家注意。
1. 不能被钩子修饰的属性不能直接初始化? 其实可以。有钩子的属性,只要没有set钩子,你是没办法从外部赋值的。但可以在构造函数里赋值。对于对称可见性,如果写了set钩子,那么外部赋值会经过set钩子。没问题。
2. 性能问题。属性钩子本质上还是方法调用,和以前直接访问public属性比,稍微慢一点。但正常业务开发完全可忽略。如果你在做极致性能优化的大循环里,频繁访问计算属性可能带来开销。这时候可以把计算结果缓存到一个中间变量。
3. 和现有代码的兼容性。这个最头疼。你的项目里有些类用的是getXxx()/setXxx()方法,现在改成属性钩子以后,所有外部调用代码都要改。比如$orderItem->getTotal()变成$orderItem->total。如果项目里引用很多,建议先用IDE全局替换,再跑一遍测试。没有测试你敢改?反正我是不敢,好在有单元测试兜底。
既然这么爽,以后怎么写类?
我个人是越来越喜欢“属性私有明明,但通过钩子暴露规则”的写法。原来一个DTO类写三四十行getter/setter,现在十行搞定,而且看起来像数据类,不是结构体。
下面这个对比很直观:
// 老写法
$item->setPrice(29.9);
$item->setQuantity(3);
$item->setDiscount(0.1);
$total = $item->getTotal();
// 新写法
$item->price = 29.9;
$item->quantity = 3;
$item->discount = 0.1;
$total = $item->total;
看起来没什么大不了,但这就是自然语言。业务代码可读性提高很多,也更符合直觉。
总结:该用还是得用,但别无脑
如果你正在新项目里使用PHP 8.4,那不用纠结,大胆使用属性钩子。特别是那些纯DTO、实体类、配置类,用上之后代码少一半。但如果你维护的是老代码,兼容性考虑可能要保守一点。
最后送上一句:别被“这是新语法”吓住。它就是给属性加个门卫,只是这个门卫比setter简洁、直白。用了就回不去了。

