PHP 8.4 属性钩子实战:金额字段为什么不该再有 setter

2026-10-09 0 955

去年线上出过一个跟金额有关的 bug。订单对象里 $this->amount 存的是元,一位同事在退款流程里写的是分,结果退出去 100 倍。事后复盘,问题不在算错,在于那个字段有一个公开的 setter:setAmount(),任何地方都能往里塞任何数字,单位、精度、正负号全靠调用方自觉。我们后来在 setAmount 里加了几行校验——不能是负数、最多两位小数、不能超过十万——线上是稳了,但那段校验代码散落在四个不同的商品模型里,每个都长得差不多,改一个忘三个。

PHP 8.4 引入的属性钩子(property hooks)正是冲这类问题来的。它不是语法糖,因为它把「读写一个字段」变成了可编程的行为,并且这个行为绑定在字段上,而不是绑定在一个可能被绕过的 setter 方法上。同事想 $order->amount = 100 的时候,无论他有没有想起调用 setter,校验都会跑。

这篇文章我用一个金额值对象和一个订单模型,把属性钩子从语法到踩坑都过一遍。中间有五处坑是我自己调试出来的,网上讲得少。

先看老写法哪里不对

用 setter 的写法长这样:

class Money
{
    private int $cents;
    private string $currency = 'CNY';

    public function setCents(int $cents): void
    {
        if ($cents < 0) {
            throw new InvalidArgumentException('金额不能为负');
        }
        $this->cents = $cents;
    }

    public function getCents(): int
    {
        return $this->cents;
    }

    public function setCurrency(string $currency): void
    {
        if (!in_array($currency, ['CNY', 'USD', 'EUR'], true)) {
            throw new InvalidArgumentException('不支持的币种');
        }
        $this->currency = $currency;
    }
}

这段代码本身没错,但它有三个隐性的问题。

一,字段名和访问方法名脱节。内部叫 $cents,对外叫 setCents。同事写新代码时可能直接改 private 属性——PHP 里这在同一个类的实例间是允许的——于是校验被绕过。这种情况在 code review 里很难发现。

二,赋值和校验分离。$money->setCents(500) 看起来是一次普通调用,看不出这里有会抛异常的逻辑。IDE 也不会在赋值的时候给你任何提示。

三,序列化、克隆、json_encode 一律不触发 setter。这个是最坑的。你从数据库反序列化一个对象,字段能直接写进去;你 clone 一个对象,克隆体里的字段是从源对象复制的,不经过 setter;你反序列化 JSON,也是直接写属性。你写了半天的校验,认真看一下覆盖了多少种赋值路径?可能只有一两种。

属性钩子把这三条一次性解决了:字段本身可编程,所有读写路径都走钩子。

属性钩子的基本写法

一个最简单的例子,自动把邮箱转小写:

class User
{
    public string $email {
        set (string $value) {
            $value = strtolower(trim($value));
            if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
                throw new InvalidArgumentException('邮箱格式不对: ' . $value);
            }
            $this->email = $value;
        }
    }
}

语法要点:get 和 set 是两个钩子,写在花括号里。set 钩子里可以不写参数名,PHP 会自动提供一个叫 $value 的隐式变量;写了参数的话也能用类型声明限制传入类型。

钩子可以简写。只有一句表达式时用箭头语法:

public string $email {
    set => strtolower(trim($value));
}

注意箭头语法里没有 return,表达式的值会被隐式赋给字段。get 钩子的箭头语法里则必须出现一次 return:

public string $upper {
    get => strtoupper($this->name);
}

用钩子重写 Money

回到开头那个金额对象:

class Money
{
    public private(set) int $cents {
        set (int $value) {
            if ($value < 0) {
                throw new InvalidArgumentException('金额不能为负');
            }
            if ($value > 10_000_000) {
                throw new InvalidArgumentException('单笔金额超过十万');
            }
            $this->cents = $value;
        }
    }

    public private(set) string $currency = 'CNY' {
        set (string $value) {
            $value = strtoupper($value);
            if (!in_array($value, ['CNY', 'USD', 'EUR'], true)) {
                throw new InvalidArgumentException('不支持的币种: ' . $value);
            }
            $this->currency = $value;
        }
    }

    public function __construct(int $cents, string $currency = 'CNY')
    {
        $this->cents = $cents;
        $this->currency = $currency;
    }
}

这段代码里有几件事同时发生了。

第一,读写路径统一。无论调用方是构造函数里赋值、外部直接 $m->cents = 500、还是 clone、还是 unserialize(),只要走的是属性写入,set 钩子都会跑。这解决了我一开始列举的前两个问题。

第二,public private(set) 是 PHP 8.4 的另一项特性——非对称可见性。这条声明读作「读是公开的,写是私有的」。也就是说外部可以 echo $m->cents,但 $m->cents = 100 会报 Error: Cannot modify private(set) property。写权限只保留给类内部。

这一步直接干掉了「setter 被忘记调用」的问题:外部代码根本没有写入口。要改金额,必须走一个显式的方法,比如 $money->add(new Money(500)),而这个方法内部会走钩子经过校验。三种赋值路径——直接写、序列化、克隆——全都在控制之下了。

非对称可见性和 setter 的区别

有人可能会说,public private(set) 不就是一个变相的 getter 吗?有一点像,但差别很大。

getter 是一个方法,你可以在任何时候给它改成别的方法名、加参数、返回别的类型。接口里如果定义 getter,别人实现的时候要么覆盖它,要么在父类里拷贝一份。public private(set) 是语言的属性修饰符,它在类型系统层面告诉你:这个字段对外只读。IDE 会立刻标红,静态分析工具会报警,运行时也会抛异常。

更重要的是,你可以为这个只读字段加一个 get 钩子:

public private(set) string $display {
    get => $this->currency === 'CNY'
        ? '¥' . number_format($this->cents / 100, 2)
        : '$' . number_format($this->cents / 100, 2);
}

这是一个虚拟属性——没有存储,每次读都现算。外部看到的是 $money->display,读出来是 ¥12.34。如果没有 get 钩子,你得写一个 getDisplay() 方法,然后全项目都得记着用这个方法名。

虚拟属性:看起来像字段,其实在计算

虚拟属性是属性钩子最实用的部分。它指的是没有后备存储、完全由钩子产生的属性。判断标准很简单:钩子里引用了 $this->属性名 的就有存储,没引用的就是虚拟属性。

订单模型里加一个总金额:

class Order
{
    /** @var OrderItem[] */
    public private(set) array $items = [];

    public private(set) int $totalCents {
        get {
            $sum = 0;
            foreach ($this->items as $item) {
                $sum += $item->subtotalCents;
            }
            return $sum;
        }
    }

    public private(set) string $summary {
        get => sprintf('%d 件商品,合计 %.2f 元',
            count($this->items),
            $this->totalCents / 100
        );
    }
}

调用方使用的方式和读普通属性一样:$order->totalCents。但它永远是最新的,不需要手动调用 recalc() 之类的同步方法。你也不用担心有人忘记更新它。

$items 有 public private(set),意味着外部不能直接 $order->items = [...] 覆盖它,只能通过 addItem 之类的方法,这也是我们想要的。

坑一:虚拟属性不能有 set 钩子

这个坑我第一天就踩了。给 $totalCents 写了一个 set 钩子想着方便测试时直接赋值:

public int $totalCents {
    get => array_sum(array_map(fn($i) => $i->subtotalCents, $this->items));
    set => $this->totalCents = $value; // 这行会死循环
}

PHP 会直接报错:「Cannot define a set hook on a virtual property」。因为虚拟属性没有后备存储,你没法「写」它。只能有 get。

测试里想造数据的话,直接构造真实的 items 数组即可,不要走捷径。这也算是一种提醒:如果某个字段你发现想给它写一个 set 钩子,说明它不是真正的虚拟属性。

坑二:接口里的属性钩子只能用 get

PHP 8.4 允许在接口里声明属性钩子,但有个限制——接口只能要求 get,不能要求 set。也允许 get 和 set 都声明,但 set 在接口里的表达力有限。

interface HasLabel
{
    // 只要求有 get,实现者自己决定后备存储和写权限
    public string $label { get; }
}

class Product implements HasLabel
{
    public private(set) string $label {
        get => $this->name . ' (' . $this->sku . ')';
    }
}

这个限制挺合理的。如果接口能要求 set,那么每个实现类都得提供一个可写入口,那非对称可见性就没法用了。设计师是故意留出这个口子的。

坑三:克隆时钩子不会重跑

clone 的语义是浅拷贝。克隆出来的对象,它的非对象属性直接从源对象复制,不走 set 钩子。这意味着如果你在 set 钩子里做了副作用——写日志、更新计数、触达另一个对象——克隆完之后,那些副作用不会为克隆体重跑。

一般情况下这是对的行为,因为你不想克隆一次就发一封邮件。但有两个例外要考虑。

第一个例外是唯一 ID 字段。很多项目里会用 uniqid() 生成订单号,写在 set 钩子里。克隆之后,两个对象共享同一个 ID:

class Order
{
    public private(set) string $no {
        set (string $value) {
            // 只在为空时生成一次
            $this->no = $value ?: $this->generateNo();
        }
    }

    public function __clone()
    {
        // 克隆时清空订单号,重走一次生成
        // 但不能直接 $this->no = '',那样会命中"为空时生成"
        // 得先绕开钩子
        $this->regenerateNo();
    }

    private function regenerateNo(): void
    {
        // 直接操作后备存储,绕过 set 钩子
        // 用 unset 可以让下次访问走一遍钩子初始化
        unset($this->no);
    }
}

这里要解释一下 unset($this->no) 的效果。对于有 set 钩子的属性,第一次被读时如果后备存储为空,PHP 会调用 set 钩子。所以 unset 之后,下次 $this->no 或者 $this->no = '' 都会重新走一遍生成逻辑。

第二个例外是引用类型字段。如果你有一个 $tags 数组属性,克隆出来的两个对象会共享同一个数组引用吗?不会。PHP 的数组是值语义,浅拷贝会把数组复制一份,两个对象各自拥有独立的数组。这点不用担心。但如果数组里装的是对象,两个数组会共享同一个对象——那是另一回事,得写 __clone 手动深拷贝。

坑四:序列化和钩子没有关系

很多人以为序列化会触发 get 钩子,反序列化会触发 set 钩子。不是的。

serialize() 和 unserialize() 走的是 PHP 内部的属性表,不经过钩子。json_encode 也一样,它读的是后备存储,虚拟属性会被直接忽略掉。

这个行为跟我们想要的可能不一样。比如说,你希望订单被 json 序列化时带上前面的 totalCents 和 summary:

$json = json_encode($order);
// 结果里没有 totalCents,也没有 summary
// 因为它们是虚拟属性,没有后备存储

解决办法是实现 JsonSerializable:

class Order implements JsonSerializable
{
    public function jsonSerialize(): array
    {
        return [
            'items' => $this->items,
            'totalCents' => $this->totalCents,
            'summary' => $this->summary,
        ];
    }
}

这是用钩子时的一句硬性提醒:如果你依赖 json_encode 输出虚拟属性,就必须实现 JsonSerializable。没有别的办法。

同样的道理,var_export() 和 print_r() 也只读后备存储,虚拟属性在调试输出里是不可见的。这一点其实挺好的——调试时你会立刻意识到那些是不存在的字段。

坑五:父类和子类的钩子关系

如果子类要覆写父类的 get 钩子,同时还想用父类的逻辑,得用 parent:: 语法,写法跟调用父类方法类似:

class DiscountedOrder extends Order
{
    public private(set) int $totalCents {
        get => (int) round(parent::$totalCents * 0.9);
    }
}

注意 parent::$totalCents 这种写法——属性访问符还是属性访问符,只是前面加上了 parent::。这个语法我第一眼看的时候觉得有点怪,写两次就习惯了。

需要注意的是,属性钩子不能单独被覆写一个。如果子类覆写了 totalCents 的 get 钩子,那么它的 set 钩子(如果有的话)也要一起声明,否则父类的 set 会被继承下来。对于虚拟属性这种情况没法发生——虚拟属性本来就只有 get——但对于有存储的属性要小心。

另外,父类如果用了非对称可见性 public private(set),子类覆写时不能放宽也不能收紧,必须保持一致。子类想反向操作——比如让它变得外部可写——PHP 会报错。

性能上值不值得

我拿一个简单的 benchmark 大概跑了一下:一个带 set 钩子校验的整数字段,赋值一百万次,跟直接写公开属性比,慢大约 15%。这个数字挺小的,因为钩子本身是被编译进 opcode 的,不用走一次方法调用。

真正影响性能的是虚拟属性里做了什么。比如 $totalCents 里遍历 items 数组,每次读都是 O(n)。如果一个请求里读了它十次,那就是遍历十次。这种地方我一般会加一个手动缓存:

class Order
{
    private ?int $cachedTotal = null;

    public private(set) int $totalCents {
        get {
            if ($this->cachedTotal === null) {
                $sum = 0;
                foreach ($this->items as $item) {
                    $sum += $item->subtotalCents;
                }
                $this->cachedTotal = $sum;
            }
            return $this->cachedTotal;
        }
    }

    public function addItem(OrderItem $item): void
    {
        $this->items[] = $item;
        $this->cachedTotal = null; // 让缓存失效
    }
}

看起来啰嗦,但这一块的写法跟不用钩子时是一样的——你总得在某个地方做缓存失效,只是以前那个地方叫 setter,现在叫 addItem。

什么时候别用

三种情况我会刻意避开属性钩子。

第一,字段名和方法名对不上的场景。$user->getName() 和 $user->name 语义上确实有差别:前者是一个动作,后者是一次数据读取。如果你要表达的是「获取」而不是「读取」,比如从缓存里取、从数据库查、从 API 拉,这种语义差异会让人懵。用钩子的时候读一个虚拟属性可能触发一次 I/O,外人看不出来,这是我没法接受的隐式成本,所以这类场景我还是写方法。

第二,需要参数化的访问。比如 $money->roundedTo(2)、$order->itemsByStatus('paid')。参数化的东西天生就该是方法。

第三,追求极致性能的热路径。ORM 的实体映射层、每秒几万次的请求处理循环,这些地方能省一次函数调用就省一次。属性钩子的开销虽小,但在这些场景里能成为累加项。

最后一个诚实的建议。如果你维护的是 PHP 8.3 及以下的项目,不要为了用钩子升级到 8.4。升级带来的兼容性排查成本,远超过省下 getter/setter 的收益。等下一次有其他必须升级的理由时,顺手把这块重构了比较划算。

收尾

回到开头那个金额 bug。改造完之后,同事的那行代码变成了:

// 之前
$refund->setAmount(100);
// 之后
$refund->amount = new Money(100, $refund->currency->value);

看着更长了,但错误不可能发生。单位藏在 Money 这个类型里,币种必须显式给出,校验在构造时通过钩子完成。如果同事真的写错单位,编译器会先报错——因为 100 是 int,不是 Money。

这就是属性钩子真正值钱的地方。它让你能够用类型系统表达「金额不是数字」,而这个表达在以前只能用文档和 code review 来维护。

如果你现在打开项目,发现同样的校验逻辑在四五个类里重复出现,那就从那一处开始练手。先用钩子把校验搬进字段,再加上非对称可见性关掉外部的写入口。改造过程中会碰到序列化和克隆那几个坑,文章里都列了处理方式。真正花时间的不是写钩子,是发现哪些地方原来在偷偷直接写私有属性——这一步往往能揪出几个陈年老 bug。

PHP 8.4 属性钩子实战:金额字段为什么不该再有 setter
收藏 (0) 打赏

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

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

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

淘吗网 php PHP 8.4 属性钩子实战:金额字段为什么不该再有 setter https://www.taomawang.com/server/php/2912.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

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

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