PHP 8.4 属性钩子实战:重写商品价格对象,顺手踩了三个坑

2026-10-21 0 991

上周维护一个电商老项目,里面有个 Product 类,光 getter 和 setter 就占了 280 多行。价格、库存、折扣、货币单位,每个字段一对方法,方法体清一色 return $this->xxx; 和 $this->xxx = $v;。真正有逻辑的其实只有三个:价格不能为负、库存不能小于 0、折扣率必须在 0 到 1 之间。

PHP 8.4 带来的属性钩子(property hooks)正好是冲这种代码去的。我把这个类重写了一遍,代码从 300 多行缩到 90 行左右,过程中也撞了几次墙。这篇就把完整过程写下来,包括那些文档里一句带过、实际写起来会卡住的地方。

一、属性钩子长什么样

最基础的形式是给属性加一个 get 或 set 块。拿价格举例:

class Price
{
    public int $cents {
        get => $this->cents;
        set {
            if ($value < 0) {
                throw new InvalidArgumentException('价格不能为负');
            }
            $this->cents = $value;
        }
    }
}

几个语法点先讲清楚,否则后面容易看晕:

  • set 里的 $value 是隐式参数,名字固定,不能改。它就是右边赋进来的那个值。
  • 钩子内部写 $this->cents,访问的是底层存储值,不会递归调用钩子本身。这一点是 PHP 定的规则,不是巧合。
  • get => expr; 是单表达式简写,等价于 get { return expr; }。set 的简写形式里,表达式的返回值会成为属性的新值,这比在块里手动赋值更短。
  • 只写 get 不写 set,属性对外就是只读的(类比 readonly,但机制不同)。只写 set,属性就只能写不能读。

上面这坨如果改用 set 简写会更紧凑:

public int $cents {
    get => $this->cents;
    set => $value < 0
        ? throw new InvalidArgumentException('价格不能为负')
        : $value;
}

但我个人在业务代码里更倾向用块形式,因为将来加第二条校验的时候不用大改结构。

二、把 Product 类改一遍

先看原来的写法(节选):

class Product
{
    private int $priceCents = 0;
    private float $discount = 0.0;
    private string $currency = 'CNY';

    public function getPriceCents(): int { return $this->priceCents; }

    public function setPriceCents(int $v): void
    {
        if ($v < 0) throw new InvalidArgumentException('价格不能为负');
        $this->priceCents = $v;
    }

    public function getDiscount(): float { return $this->discount; }

    public function setDiscount(float $v): void
    {
        if ($v < 0 || $v > 1) throw new InvalidArgumentException('折扣必须在 0~1 之间');
        $this->discount = $v;
    }
    // ... 还有 currency 的三对方法
}

改完之后:

class Product
{
    public int $priceCents {
        set {
            if ($value < 0) {
                throw new InvalidArgumentException('价格不能为负');
            }
            $this->priceCents = $value;
        }
    }

    public float $discount = 0.0 {
        set {
            if ($value < 0.0 || $value > 1.0) {
                throw new InvalidArgumentException('折扣必须在 0~1 之间');
            }
            $this->discount = $value;
        }
    }

    public string $currency = 'CNY' {
        set => strtoupper($value);
    }
}

注意 public float $discount = 0.0 { ... } 这个写法:默认值写在属性名后面、钩子块前面。这点和普通属性一致,但位置比想象中要紧 —— 写在钩子块后面是语法错误。

调用侧的代码完全不用动,$p->priceCents = 1999; 和 $p->discount = 0.85; 照样能用,只是现在会走校验。这正是钩子最大的好处:对外还是裸属性访问,对内有了拦截点。以前为了加一个校验,得把全项目的 $p->price 改成 $p->setPrice(),改一次死一次。

三、虚拟属性:算出来的,不是存下来的

商品列表里经常要显示”原价 ¥199,现价 ¥169″。现价是算出来的,不该存。以前用一个 getFinalPrice() 方法,现在可以用虚拟属性:

class Product
{
    public int $priceCents {
        set {
            if ($value < 0) throw new InvalidArgumentException('价格不能为负');
            $this->priceCents = $value;
        }
    }

    public float $discount = 0.0;

    // 没有底层存储,纯计算
    public int $finalCents {
        get => (int) round($this->priceCents * (1 - $this->discount));
    }
}

这个属性只声明了 get,没有 set 也没有默认值,PHP 会把它当成虚拟属性 —— 不占内存,每次读都重新算。往上面写会直接报错:

$p->finalCents = 100;
// Error: Cannot write to virtual property Product::$finalCents

$p->finalCents = 100;
// Cannot modify readonly property Product::$finalCents

具体报哪条取决于写法,间接修改(比如 $p->finalCents++)报的是第二条,信息不太直观,调试时别被绕进去。

判断一个属性是不是虚拟的,可以直接用反射:

$ref = new ReflectionProperty(Product::class, 'finalCents');
var_dump($ref->isVirtual());   // bool(true)
var_dump($ref->hasHook(PropertyHookType::Get));   // bool(true)
var_dump($ref->hasHook(PropertyHookType::Set));   // bool(false)

序列化的时候要留个心:虚拟属性不会被 json_encode 带出来,因为它在 get_object_vars() 里没有底层值。想让它出现在 JSON 里,得手动展开,或者实现 JsonSerializable。

四、非对称可见性:只读对外,可写对内

老代码里 id 这个字段很典型:创建之后就不该再改,但 ORM 或者工厂方法又需要写。以前要么用 readonly(但那样连内部也改不了第二次),要么写一个 setId() 再加注释”别在外面调用”。

PHP 8.4 的非对称可见性正好解决这个:

class Product
{
    public private(set) int $id;

    public function __construct(int $id)
    {
        $this->id = $id;   // 类内部可以写
    }
}

$p = new Product(42);
echo $p->id;   // 42

$p->id = 99;
// Error: Cannot modify private(set) property Product::$id from global scope

语法就是 public private(set) 类型 $属性。set 的可见性可以写成 private 或 protected,get 的可见性则直接写在最前面那个位置。

和 readonly 的区别在于:readonly 是”只能初始化一次”,private(set) 是”外部不能改,内部随便改”。真正常见的需求其实是后者 —— 订单状态、库存数量、余额这些东西,外部不该直接改,内部要能改。

class Wallet
{
    public private(set) int $balance = 0;

    public function deposit(int $amount): void
    {
        $this->balance += $amount;   // 内部可写
    }

    public function withdraw(int $amount): void
    {
        if ($amount > $this->balance) {
            throw new RuntimeException('余额不足');
        }
        $this->balance -= $amount;
    }
}

两种写法还能叠在一起用,这就有意思了:

class Product
{
    public private(set) string $name {
        set => trim($value);
    }
}

外面读得到 $p->name,读到的永远是去掉首尾空格之后的值;写的话只有类内部能写。这两套机制是正交的,组合起来没有任何冲突。

五、坑一:数组属性的间接修改会直接报错

这个坑我卡了差不多二十分钟。场景是这样的:

class Cart
{
    public array $items = [] {
        set => array_values($value);
    }
}

$c = new Cart();
$c->items = ['a', 'b'];
$c->items[] = 'c';
// Error: Cannot indirectly modify property Cart::$items with hooks

报错信息其实挺清楚的,只是第一次见到会懵。$c->items[] = 'c' 是一种间接修改:PHP 需要拿到数组的引用再往里追加,但属性有 set 钩子时,底层值不是一个可以直接交给外部引用的东西,引擎干脆就禁止了这种操作。

这跟魔术方法 __get / __set 的行为完全不同 —— 后者遇到间接修改会静默绕过,值改了但校验没跑,是更隐蔽的 bug。钩子的选择是直接报错,虽然一开始会烦,但至少不会埋雷。

解决办法有三个,按场景挑:

方案 A:用 &get 返回引用。属性没有 set 钩子时,get 可以声明成返回引用:

class Cart
{
    private array $items = [];

    public array $list {
        &get {
            return $this->items;
        }
    }
}

$c = new Cart();
$c->list[] = 'a';   // 可以,因为是引用

代价是校验彻底没了,引用一旦交出去,谁改、改多少次你都不知道。适合纯粹为了读写方便的场合。

方案 B:虚拟属性 + 显式方法。写操作全部走方法,读操作走虚拟属性:

class Cart
{
    private array $items = [];

    public array $items {
        get => $this->items;
    }

    public function add(string $sku): void
    {
        if (in_array($sku, $this->items, true)) {
            throw new DomainException('商品已存在');
        }
        $this->items[] = $sku;
    }
}

这个方案最干净,但也最啰嗦,适合确实需要业务规则的集合。

方案 C:换成对象集合。用 DoctrineCommonCollectionsCollection 或者自己写一个 ItemCollection,把 add() 收敛到集合类里,属性本身只赋值不修改。大项目里这个方案最稳,因为集合内部能缓存、能排序、能延迟加载。

六、坑二:和 readonly 不能共存

class Foo
{
    public readonly int $x {
        get => $this->x * 2;
    }
}
// Fatal error: Cannot declare hooked property Foo::$x as readonly

这条很直接:有钩子的属性不能是 readonly。之前想当然地觉得”只读 + get 钩子”应该很配,结果直接编译不过。

要只读效果,用 public private(set),或者干脆只声明 get 钩子。后者连类内部也写不进去,更严格。

七、坑三:钩子里别写递归

下面这段看起来没问题,实际会挂:

class Foo
{
    public string $name {
        get => strtoupper($this->getName());
    }

    public function getName(): string
    {
        return $this->name;   // 又触发 get 钩子
    }
}

$f = new Foo();
$f->name = 'a';
echo $f->name;   // 栈溢出

关键在于:钩子内部访问 $this->name 是安全的(走底层值),但通过方法间接访问就又不安全了。$this->getName() → return $this->name → 触发 get 钩子 → 再调 getName(),无限套娃。

规则很简单:钩子里只碰 $this->同名属性 和其他不相干的属性,不要调用任何可能反过来访问本属性的方法。这条规则写起来容易,改别人代码时最容易忽略。

八、惰性加载:钩子最实用的场景

说完坑说个真香的用法。报表对象里的 rows 往往要查数据库,但绝大多数请求根本不会读它。以前要么在构造函数里查(浪费),要么写 getRows() 加个 if ($this->rows === null) 判断(丑)。

class Report
{
    public array $rows {
        get {
            if (!isset($this->rows)) {
                $this->rows = $this->loadRows();
            }
            return $this->rows;
        }
    }

    private function loadRows(): array
    {
        return [/* 这里查数据库 */];
    }
}

isset($this->rows) 对未初始化的 typed property 返回 false,第一次读的时候才真的去查。第二次读直接命中缓存。整个过程对外就是一个普通的 $report->rows,调用方完全感知不到延迟加载的存在。

注意 $rows 没有赋默认值 —— 如果写了 public array $rows = [];,那 isset 永远为真,惰性加载就失效了。这是个必须记住的细节。

同样的套路可以套在任意”创建成本高、但未必用到”的对象上,比如 public PdfRenderer $pdf { get { ... } }。

九、接口里的属性

PHP 8.4 允许在接口里声明抽象属性,这让”必须有某个字段”这件事终于能写进类型系统了:

interface HasPrice
{
    public int $priceCents { get; }
}

class Book implements HasPrice
{
    public int $priceCents {
        get => $this->basePrice * 100;
    }

    public function __construct(private float $basePrice) {}
}

注意实现类这里写的是虚拟属性,值是从 basePrice 算出来的。接口只关心”能读到 int“,不关心你是不是真的存了一个字段。

如果实现类只写一个普通属性 public int $priceCents = 0; 会不会通过?答案是不会,接口要求的是带有 get 钩子的属性,普通属性不满足这个契约。这条规则挑明了其实合理:只有钩子才能保证子类里这个属性确实是按契约返回的。

十、反射、IDE 与静态分析

属性钩子上线后,工具链的支持是个现实问题。

工具 支持状态 备注
ReflectionProperty 完整 getHooks()、hasHook()、isVirtual()
PHPStan 2.x 支持 能识别钩子返回类型,但 virtual property 的类型推导偶尔会退化成 mixed
Psalm 部分 较新版本才跟上的语法解析
PhpStorm 2024.3+ 支持 较早版本会把钩子块标红
xdebug 可用 断点会停在钩子内部,堆栈里能看到虚拟属性调用

如果项目里用了 Doctrine 之类的 ORM,要特别小心:ORM 反射读属性时,有些版本会读到虚拟属性并尝试赋值,然后被你自己的只读声明怼回去。稳妥的做法是给虚拟属性加 #[ORMIgnore] 或者干脆不在实体上用虚拟属性,用普通方法。

自己写反射代码时,跳过虚拟属性的写法:

$ref = new ReflectionClass(Product::class);
foreach ($ref->getProperties() as $prop) {
    if ($prop->isVirtual()) {
        continue;
    }
    // 只处理真正存数据的属性
}

十一、还不能升级怎么办

8.4 目前还没到所有生产环境都敢上的阶段,很多项目还在 8.2、8.3。这时候别干等,先把代码结构往钩子靠:

  • 把所有直接操作属性的地方改成走方法,命名统一成 getXxx() / setXxx()。将来升级时这些方法可以整体删掉,外部调用点改成属性访问就是。
  • 私有属性加 @readonly 文档注释,配合 PHPStan 的 readOnlyProperties 规则,先拿到一部分静态保障。
  • 校验逻辑抽成独立的 Value Object,比如 Money、Percentage。这些类本身就不需要钩子,升级的时候直接受益。

等升级那天,改动会集中在类定义本身,业务层几乎不用碰。这比临时加一堆 __get / __set 魔术方法要主动得多 —— 魔术方法那套性能差,静态分析还识别不了,属于技术债里最难还的那一类。

十二、一点判断

属性钩子不是银弹,它解决的是”我想拦截属性访问,但不想破坏 $obj->prop 这种写法”这一类问题。如果你的属性本来就有复杂的业务逻辑、需要多次校验、需要事务边界,那还是老老实实写方法,钩子的定位是替代那些纯粹的 getter / setter 样板,不是替代领域方法。

但就冲”300 行缩到 90 行,对外接口一行不改”这一点,8.4 这个特性已经值回票价了。剩下的就是等主流框架和 ORM 把坑填平,那大概还需要一阵子。

PHP 8.4 属性钩子实战:重写商品价格对象,顺手踩了三个坑
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 php PHP 8.4 属性钩子实战:重写商品价格对象,顺手踩了三个坑 https://www.taomawang.com/server/php/2964.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务