用 PHP 8.4 属性钩子手写一个 Money 值对象:告别重复的 getter/setter

2026-08-04 0 596

升级到 PHP 8.4 之后,除了属性钩子,我还被不对称可见性吸引了。这俩一结合,我立马想到了之前写的一个 Money 类。那会儿为了确保金额不能是负数,又不能让外部直接修改金额,我写了一堆 getter 和 setter,看着就烦。现在好了,用属性钩子重新写一遍,整个类从 80 行缩到 40 行。

这篇就拿 Money 值对象作为案例,一步步给你看我怎么用上 PHP 8.4 的。

一、属性钩子到底改了啥

在 PHP 8.4 之前,你没办法让一个公共属性具备代码逻辑。想校验赋值,就只能写 setter。但属性钩子让公共属性自带 get 和 set 行为,从语法层面消灭了那些冗长的访问器。比如这样:

class Point {
    public int $x {
        get => $this->_x;
        set (int $value) {
            if ($value _x = $value;
        }
    }
    private int $_x = 0;
}

注意钩子不会自动存数据,所以你还是需要一个私有字段来保存真实值。如果不搞一个底层存储,你在 set 钩子里直接操作 $this->x 就会无限重复调用 set,直接栈溢出。我第一次试的时候,差点把电脑搞重启。

二、Money 值对象:需求也很简单

一个 Money 类需要包含金额和币种。关键约束有两条:金额不能是负数,币种必须是合法的 ISO 代码。外部拿到这个对象后,希望可以读币种和金额,但不希望随意修改金额。如果非要改,就得通过一个明确的业务方法,比如增加金额或者扣除金额。

传统写法是 private 属性 + getter,再在构造方法里校验。现在有了属性钩子,我直接把公共属性暴露出来,但用不对称可见性让外部只能读,不能写。内部的构造函数(或内部方法)却可以直接赋值,并自动触发钩子校验。这样代码看起来更自然,也不需要一堆方法了。

三、新语法实现:看着真舒心

直接上代码,下面就是完整的 Money 类:

<?php
declare(strict_types=1);

final class Money
{
    public private(set) int $amount {
        get => $this->_amount;
        set (int $value) {
            if ($value < 0) {
                throw new InvalidArgumentException('金额不能为负数');
            }
            $this->_amount = $value;
        }
    }

    public private(set) string $currency {
        get => $this->_currency;
        set (string $value) {
            // 简单模拟 ISO 4217 校验
            if (!in_array($value, ['CNY', 'USD', 'EUR'], true)) {
                throw new InvalidArgumentException('不支持该币种');
            }
            $this->_currency = $value;
        }
    }

    private int $_amount;
    private string $_currency;

    public function __construct(int $amount, string $currency)
    {
        $this->amount = $amount;    // 触发 set 钩子
        $this->currency = $currency; // 触发 set 钩子
    }

    // 业务方法:增加金额
    public function add(Money $other): Money
    {
        if ($this->currency !== $other->currency) {
            throw new InvalidArgumentException('币种不一致');
        }
        return new self($this->amount + $other->amount, $this->currency);
    }

    // 业务方法:扣除金额(允许结果为0,不允许负数)
    public function subtract(Money $other): Money
    {
        if ($this->currency !== $other->currency) {
            throw new InvalidArgumentException('币种不一致');
        }
        $newAmount = $this->amount - $other->amount;
        if ($newAmount < 0) {
            throw new InvalidArgumentException('扣减后余额不能为负');
        }
        return new self($newAmount, $this->currency);
    }
}

这里用到了 public private(set),意思是该属性可以公开读取,但赋值只能在类内部进行。从外部来看,你就不能直接写 $money->amount = 999 了,这样的意图特别明确:金额只读,但内部可以修改。

构造方法里直接给属性赋值,就会触发对应的 set 钩子,从而完成校验。你不用再去写两个 setter 方法,代码干净了一大半。

四、实际用起来感觉如何

把 Money 类接入订单金额计算中,使用体验非常顺滑:

$price = new Money(1999, 'CNY');
$shipping = new Money(0, 'CNY');
$total = $price->add($shipping);

echo $total->amount;    // 1999
echo $total->currency;  // CNY

// 下面这行会直接报 Error: Cannot modify private(set) property
$total->amount = 1;

这种约束比依赖私有属性强得多。以前如果只提供 getAmount(),外部还是会去寻找 setAmount() 或者反射来破坏不变性。现在通过语言层面限制了赋值行为,连反射都会失败(除非你手动改变可见性)。

五、几个必须注意的坑

1. 所有赋值都会走 set 钩子

这意味着在构造函数中赋值也会校验。如果 $amount 传负数,构造函数直接抛异常。这是一个好事,杜绝了通过构造传入非法状态。

2. 不要在 set 钩子内部修改同一个属性

千万不能写 $this->amount = $value,这会引发循环调用。必须像上面一样,在 set 钩子里把数据存储到私有字段 $_amount

3. 不要忘了不对称可见性的边界

private(set) 意味着只有当前类内可以写。如果想从父类或 trait 中赋值?那也不行。但可以使用 protected(set) 来允许子类修改。我在设计领域对象时,经常用 protected(set) 让子类可以调整状态,但保持对外只读。

4. 序列化注意字段泄漏

因为真实值存在 _amount_currency 里,json_encode 会把这两个下划线前缀的字段也带出去。为了避免这种情况,最好实现 JsonSerializable 接口,或者用 __serialize 方法控制序列化字段。

public function jsonSerialize(): array
{
    return [
        'amount' => $this->amount,
        'currency' => $this->currency,
    ];
}

这样前端拿到的 JSON 就干净了。

六、属性钩子还能玩出什么花

除了上面这种保留底层存储的用法,属性钩子也可以当做只读计算属性,不用建私有字段。比如我想在 Money 里加一个 “格式化字符串” 属性:

public string $formatted {
    get => $this->currency === 'USD' ? '$' . number_format($this->amount / 100, 2) : '¥' . number_format($this->amount / 100, 2);
}

这里就不需要额外的存储字段了。每次访问 $money->formatted 都动态计算。这比写一个方法可读性好得多,而且它天然是只读的,因为没有 set 钩子。

再比如,你也可以用 set 钩子实现“变更后自动更新其他字段”的副作用,比如把金额的美元版本同步修改,或者记录最后修改时间。不过这种副作用要谨慎,容易让行为隐晦。

七、值对象还是用 readonly 吗?

PHP 8.2 引入了 readonly 属性,也可以实现不可变对象。那和属性钩子的区别在哪?readonly 类只能初始化一次属性,无法提供业务方法修改状态。比如你希望 Money 的 add() 方法返回一个新的对象,那用 readonly 没问题。但如果你是想在内部对金额做一些运算并重新赋值,readonly 就不行了。属性钩子更灵活,它让你看起来是公共属性,但实际上可以控制所有赋值行为,并且能保持内部的动态更新。

比如我可以为内部的某个临时变量赋值多次,只要不违反业务规则。readonly 做不到这一点。

八、总结与个人感受

我越来越觉得,属性钩子是 PHP 向更现代语法演进的一大步。它把“属性”本身的逻辑内聚到属性声明旁边,而不是散落在 getter/setter 方法中。Money 类只是牛刀小试,如果你有一堆 DTO 或者值对象,这个特性绝对能帮你删掉不少重复代码。

但要记住,钩子只是语法糖,底层的责任边界还是要自己把握。不要在 set 钩子里做太多无关的事,保持逻辑可预测。

最后建议:如果你的项目 PHP 版本还停在 8.2 或 8.3,赶紧准备升级吧。8.4 带来的东西比我想象中带劲。

以上代码都在 PHP 8.4 RC 中实际跑过,可以看到输出符合预期。如果你也准备试用,建议开启 strict_types,配合静态分析工具一起用,效果更佳。

用 PHP 8.4 属性钩子手写一个 Money 值对象:告别重复的 getter/setter
收藏 (0) 打赏

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

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

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

淘吗网 php 用 PHP 8.4 属性钩子手写一个 Money 值对象:告别重复的 getter/setter https://www.taomawang.com/server/php/2483.html

常见问题

相关文章

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

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