PHP 8.4 属性钩子实战:用钩子干掉 getter/setter 重写订单模型

2026-08-21 0 155

PHP 8.4 在 2024 年 11 月发布,最令人兴奋的莫过于属性钩子(property hooks)的引入了。以前写个 DTO、实体类,满屏都是 getter/setter,又长又啰嗦。现在有了属性钩子,终于可以像写 Python/Swift 那样,直接给属性定义访问逻辑了。这篇我就拿一个真实的订单金额计算场景,把属性钩子从语法到坑讲清楚。

为什么我们需要属性钩子?

先看看老写法。假设有个订单项类,里面有数量、单价、折扣比例,最终要算总额。以前你至少要写几个方法:

class OrderItem
{
    public function __construct(
        private float $quantity,
        private float $unitPrice,
        private float $discountRate = 0.0
    ) {}

    public function getQuantity(): float { return $this->quantity; }
    public function setQuantity(float $quantity): void { $this->quantity = $quantity; }

    public function getUnitPrice(): float { return $this->unitPrice; }
    public function setUnitPrice(float $unitPrice): void { $this->unitPrice = $unitPrice; }

    public function getDiscountRate(): float { return $this->discountRate; }
    public function setDiscountRate(float $discountRate): void { $this->discountRate = $discountRate; }

    public function getSubtotal(): float
    {
        return $this->quantity * $this->unitPrice;
    }

    public function getDiscountedTotal(): float
    {
        return $this->getSubtotal() * (1 - $this->discountRate);
    }
}

这还不算过分。但项目里有十几个这样的类,那就全是模板代码了。更要命的是,你需要处理“属性变了,关联值自动更新”的逻辑,还得在 getter 里加各种判断,很容易漏。

PHP 8.4 的属性钩子允许你直接给属性挂上 getset 逻辑,调用时跟访问普通属性一样,不再需要方法名。上面的代码可以重构成下面这样:

class OrderItem
{
    public float $quantity;
    public float $unitPrice;
    public float $discountRate = 0.0;

    public float $subtotal {
        get => $this->quantity * $this->unitPrice;
    }

    public float $discountedTotal {
        get => $this->subtotal * (1 - $this->discountRate);
    }
}

是不是清爽多了?外部调用直接 $item->subtotal,访问属性和访问普通属性一模一样。

案例:订单金额计算器的完整实现

为了更实用,我们扩展一下:一个订单里可能有多个订单项,还要加上满减优惠、运费。我们用属性钩子把这些计算都架起来。

1. 订单项类:使用虚拟属性

class OrderItem
{
    public float $quantity {
        get => $this->quantity;
        set => $value > 0 ? $value : throw new InvalidArgumentException('数量不能小于0');
    }

    public float $unitPrice {
        get => $this->unitPrice;
        set => $value >= 0 ? $value : throw new InvalidArgumentException('单价不能为负');
    }

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

    // 注意:下面两个是虚拟属性,没有 backing field
    public float $subtotal {
        get => $this->quantity * $this->unitPrice;
    }

    public float $discountedTotal {
        get => $this->subtotal * (1 - $this->discountRate);
    }

    public function __construct(
        float $quantity,
        float $unitPrice,
        float $discountRate = 0.0
    ) {
        $this->quantity = $quantity;
        $this->unitPrice = $unitPrice;
        $this->discountRate = $discountRate;
    }
}

上面有几个细节值得说:

  • set 钩子里可以用 $value 表示传入的值。需要取原始值时,可以用 $this->quantity,但要注意钩子会递归。所以在 set 钩子里给属性赋值必须是 $this->quantity = $value,而读取的话…这里我直接用了 get => $this->quantity 会引发递归!因为 get 钩子又去读 quantity,读 quantity 又触发 get 钩子。

啊,我踩坑了。在上面的代码里,public float $quantity { get => $this->quantity; ... } 这样写是无限递归的。实际上,对于有后备属性(backing field)的属性,PHP 提供了 $this->quantity 在钩子内部代表原始存储的值,但是你要保证这个属性本身没有钩子。如果你明确写了 get 钩子,再去 $this->quantity,它还是会走 get 钩子,导致递归。

正确定义应该是:

class OrderItem
{
    public float $quantity { 
        set {
            if ($value <= 0) throw new InvalidArgumentException('数量不能小于0');
            $this->quantity = $value;
        }
    }

    public float $unitPrice {
        set {
            if ($value < 0) throw new InvalidArgumentException('单价不能为负');
            $this->unitPrice = $value;
        }
    }

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

    public float $subtotal {
        get => $this->quantity * $this->unitPrice;
    }

    public float $discountedTotal {
        get => $this->subtotal * (1 - $this->discountRate);
    }

    public function __construct(
        float $quantity,
        float $unitPrice,
        float $discountRate = 0.0
    ) {
        // 这里在构造函数中直接使用钩子赋值,会走 set 钩子逻辑
        $this->quantity = $quantity;
        $this->unitPrice = $unitPrice;
        $this->discountRate = $discountRate;
    }
}

注意:quantityunitPrice 没有显式 get 钩子,因为默认读取就是返回 backing field 的值。一旦定义了 set 钩子,并且没定义 get 钩子,PHP 会保留一个隐含的 get 逻辑,直接返回原始值,所以我们在 subtotal 的 get 钩子里读 $this->quantity 是安全的,它不会递归。这是 PHP 8.4 的规则:如果一个属性定义了钩子,但缺少 getset,缺失的操作按普通属性处理。

2. 订单类:把运费、满减和总额串起来

现在写订单类。满减策略:满 200 减 20;运费:不满 99 收 10 块,超过免运费。这些都写进虚拟属性里:

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

    public float $subtotal {
        get {
            $sum = 0.0;
            foreach ($this->items as $item) {
                $sum += $item->discountedTotal;
            }
            return $sum;
        }
    }

    public float $discount = 0.0 {
        get => $this->subtotal >= 200 ? 20 : 0.0;
    }

    public float $shippingFee {
        get => $this->subtotal >= 99 ? 0.0 : 10.0;
    }

    public float $grandTotal {
        get => $this->subtotal - $this->discount + $this->shippingFee;
    }

    public function addItem(OrderItem $item): void
    {
        $this->items[] = $item;
    }
}

这里 discountshippingFee 依赖 subtotal,而 grandTotal 又依赖它们。用属性钩子表达这种依赖关系非常直观,而且每次读取都会实时计算,不会出现“价格变了但总额缓存没更新”的问题。

3. 测试一下

$order = new Order();
$order->addItem(new OrderItem(12.5, 8.8));            // 110.0,折扣率0,小计110
$order->addItem(new OrderItem(3, 39.9, 0.1));        // 3*39.9*0.9 = 107.73
echo '小计:' . $order->subtotal . PHP_EOL;          // 217.73
echo '满减:' . $order->discount . PHP_EOL;           // 20 (因为≥200)
echo '运费:' . $order->shippingFee . PHP_EOL;        // 0 (因为≥99)
echo '应付:' . $order->grandTotal . PHP_EOL;         // 197.73

输出结果符合预期。你甚至可以在 set 时触发联动逻辑。比如修改一个订单项的数量后,订单的 subtotal 自动变化:

$orderItem = $order->items[0];
$orderItem->quantity = 100;  // 小计立即变成 100*8.8 + 107.73 = 987.73
echo $order->grandTotal;    // 987.73 - 20 + 0 = 967.73

这种“属性间自动推导”的能力,在旧写法里非常繁琐。你要靠监听器、观察者模式,或者一遍遍手动调用 getter 重算。现在一行 get 钩子全搞定。

属性钩子的关键语法要点

为了避免大家踩坑,我把语法规则和注意事项列一遍:

1. 基本形式

public string $name {
    get => $this->name;          // 有后备属性
    set => $this->name = $value; // 可以使用 $value
}

也可以完整写法:

public string $name {
    get { return $this->name; }
    set {
        if ($value === '') throw new Exception('不能为空');
        $this->name = $value;
    }
}

2. 虚拟属性(virtual property)

如果一个属性没有后备字段,只有 get/set 钩子,那么它就是虚拟的。例如上面 subtotal 没有赋值操作,也没有声明类型默认值。这种属性只能读,不能写。如果你写 $item->subtotal = 100; 会抛 Error,因为只有 get 钩子。

3. 只读属性

可以结合 readonly 吗?在 PHP 8.4 中,readonly 属性是可以和钩子共存的,但有限制:如果属性声明了 readonly,那么不能再有 set 钩子。只有 get 钩子可以,这样保证了初始化后不可变。

public readonly float $maxQuantity {
    get => $this->maxQuantity;
}

但这个特性实际中用得少,因为 readonly 主要用于防止外部修改,有 get 钩子再读原始值反而容易绕。我建议项目里还是老老实实用普通属性。

4. 访问控制

钩子可以是 protected 或 private。比如你希望外部可读、但只有内部能设置:

public float $total {
    get => $this->price * $this->count;
    private set => $this->total = $value; // 这里会有问题吗?不推荐,因为没有后备字段
}

所以这种场景直接用虚拟属性更合适。

性能与适用场景

有人会担心属性钩子每次访问都要执行逻辑,性能会不会比 getter 差?其实 PHP 8.4 在 opcache 层做了优化,钩子的调用开销和普通方法几乎一样。真正影响性能的是你在钩子里面写了大量 I/O,比如数据库查询、调用远程服务。因为钩子在语义上等同于方法,所以规则很简单:不要在 get 钩子里干重活,保持计算简单快速。

适用场景几种:

  • DTO、VO 的字段格式化,比如日期格式化、金额单位转换。
  • 有依赖关系的计算属性,如订单总额、年龄(由生日算)。
  • 对属性赋值做校验、过滤,比如 trim 字符串。

反思:没那么神的“新特性”

说实话,PHP 8.4 的属性钩子更像是语法糖,它不能让你写出别的语言做不到的事。但对程序员体验的改善是巨大的:写少一点,读清楚一点,改起来风险低很多。我最初觉得“不过是省几个方法”,但用下来发现代码里更容易保持“状态一致”了。因为以前如果想通过 getter 方法访问一个计算值,调用方很可能忘了加括号;现在属性钩子强制我们使用纯属性访问,接口看起来自然得多。

另外,它也有坑。比如我一开始就没理清“在 set 钩子里给后备字段赋值会不会触发 set”这个问题。实际规则是:在钩子内部用 $this->prop 直接读写后备值,不会再次进入钩子(前提是当前作用域就是该属性的钩子)。但如果你在 get 钩子里写了 $this->foo = 1,这不是修改后备字段,而是把 foo 当成一个属性来 set,此时如果 foo 没有 set 钩子但可写,它就会调用 set 钩子或直接赋值。这很容易造成逻辑混乱,所以钩子内部访问同名属性时一定要想清楚。

结语

属性钩子绝对是 PHP 8.4 最实用的新语法。如果你正在维护一个拥有大量 Java 风格 getter/setter 的旧项目,不妨抽一个类出来用钩子重写。不用一步到位,可以先从计算属性开始,慢慢把校验逻辑也挪进去。我推荐的顺序是:先写虚拟属性(无后备字段),再给已有属性加 set 校验,最后再考虑访问控制和其他高级玩法。

最后留个小问题:如果订单项里有优惠券,券码要求必须大写字符串,用 set 钩子你会怎么写?欢迎在评论区交流。

PHP 8.4 属性钩子实战:用钩子干掉 getter/setter 重写订单模型
收藏 (0) 打赏

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

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

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

淘吗网 php PHP 8.4 属性钩子实战:用钩子干掉 getter/setter 重写订单模型 https://www.taomawang.com/server/php/2582.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

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

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