PHP 8.4 属性钩子实战:把 getter/setter 踢进回收站

2026-08-26 0 613

写 PHP 写了快十年,最让我腻歪的就是那些只负责存取值、没有任何逻辑的 getter/setter。明明一个属性叫 $name,偏要写 getName()setName(),有时候还要在里面塞一堆 if 判断。PHP 8.4 总算把这个老毛病治了治,给了个正经的新特性:属性钩子(Property Hooks)。

这玩意儿其实早在其他语言里见过了,比如 Kotlin 的 property accessors,C# 的属性。PHP 终于在 8.4 里跟上了。我最近在项目里试着用了一下,发现确实能省掉一大半样板代码,今天就用一个实际的例子把它讲明白。

我的痛点:一个 Money 类让我写了 80 行重复代码

假设我正在写一个支付系统,里面有个 Money 类,表示金额和币种。需求是:金额始终保留两位小数,币种必须是有效的大写字母代码,同时在取值的时候能顺便格式化成字符串。

老式写法肯定要定义私有属性,然后写一堆 getter/setter:

class Money
{
    private float $amount;
    private string $currency;

    public function __construct(float $amount, string $currency)
    {
        $this->amount = round($amount, 2);
        if (!preg_match('/^[A-Z]{3}$/', $currency)) {
            throw new InvalidArgumentException('币种必须为三位大写字母');
        }
        $this->currency = $currency;
    }

    public function getAmount(): float
    {
        return $this->amount;
    }

    public function setAmount(float $amount): void
    {
        $this->amount = round($amount, 2);
    }

    public function getCurrency(): string
    {
        return $this->currency;
    }

    public function setCurrency(string $currency): void
    {
        if (!preg_match('/^[A-Z]{3}$/', $currency)) {
            throw new InvalidArgumentException('币种必须为三位大写字母');
        }
        $this->currency = $currency;
    }

    public function getFormatted(): string
    {
        return number_format($this->amount, 2) . ' ' . $this->currency;
    }
}

这只是两个字段,已经把类撑得够臃肿了。要是有十个字段,光这些访问器就够写一整天,而且全都是复制粘贴,毫无技术含量。

属性钩子是什么?一句话概括

属性钩子允许你在定义属性的时候直接写 getset 逻辑,像定义方法一样。这样属性从视觉上仍然是属性,调用的时候直接访问 $money->amount,而不是 $money->getAmount(),代码会清爽很多。

基本语法长这样:

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

这里有个细节要注意:在 get 里不能直接读 $this->name,那样会无限递归。你需要一个没有钩子的“底层存储属性”。最正规的做法是把属性设为 privateprotected,然后另写一个公开的钩子属性指向它。其实还有一种更优雅的方式,往下看案例。

用属性钩子重构 Money 类

我改了一下 Money 类,让 amountcurrency 保留为纯私有变量,然后通过两个公开属性 amountcurrency 加上钩子来暴露它们。这看起来有点绕,但实际用起来很顺手。

class Money
{
    private float $rawAmount;
    private string $rawCurrency;

    public float $amount {
        get => $this->rawAmount;
        set {
            if ($value < 0) {
                throw new InvalidArgumentException('金额不能为负数');
            }
            $this->rawAmount = round($value, 2);
        }
    }

    public string $currency {
        get => $this->rawCurrency;
        set {
            if (!preg_match('/^[A-Z]{3}$/', $value)) {
                throw new InvalidArgumentException('币种必须为三位大写字母');
            }
            $this->rawCurrency = $value;
        }
    }

    public function __construct(float $amount, string $currency)
    {
        // 注意:直接调用钩子属性来赋值,而不是给 private 赋值
        $this->amount = $amount;
        $this->currency = $currency;
    }

    public function formatted(): string
    {
        return number_format($this->amount, 2) . ' ' . $this->currency;
    }
}

看明白了吗?公开属性 $amount$currency 都带钩子,读的时候返回底层的 raw 值,写的时候做校验和格式化。外部调用代码完全感知不到钩子的存在,就是一个普通的属性:

$money = new Money(12.345, 'USD');
echo $money->amount;      // 12.35
echo $money->formatted(); // 12.35 USD

$money->amount = 99.999;
echo $money->amount;      // 100.00

这些 getter/setter 不见了,类的代码量瞬间少了三分之一。而且语义更清晰,读代码的人第一眼就知道 $money->amount 是一个属性,不是函数。

更爽的玩法:虚拟属性(Virtual Properties)

有时候你根本不需要底层存储,而是想通过其他属性计算一个结果。以前的写法是 getFullName() 方法,现在可以直接声明一个没有底层存储的属性,钩子里写计算逻辑。比如我给 Money 加一个 formatted 属性:

public string $formatted {
    get => number_format($this->amount, 2) . ' ' . $this->currency;
}

外面用的时候就是 $money->formatted,读起来像属性,实际上是动态计算出来的。之前那个 formatted() 方法可以直接删掉,调用处从 $money->formatted() 改成 $money->formatted

虚拟属性不能设置值,因为只有 get 没有 set。如果你尝试写 $money->formatted = 'xxx',PHP 会直接抛异常。这种限制其实是合理的,避免有人对计算属性乱赋值。

和只读属性(readonly)怎么共存?

PHP 8.1 引入了 readonly 属性,但 readonly 和属性钩子一开始看起来有点冲突。PHP 8.4 里允许 readonly 属性使用钩子,但仅限 get 钩子,不能有 set。为什么?因为 readonly 属性本来就不能在初始化后修改,所以 set 钩子没有意义。

你可以这样写:

class Order
{
    public function __construct(
        private float $amount
    ) {}

    public readonly float $roundedAmount {
        get => round($this->amount, 2);
    }
}

这个 $roundedAmount 只能读,不能写,每次读都会根据底层 $amount 计算。虽然底层属性在构造时已经固定,但钩子每次运行还是重新计算一遍。如果你的计算逻辑很重,可以加一层缓存,不过那已经属于优化的范畴,平时用不到。

几个坑,或者说需要注意的地方

1. 钩子属性的默认值和后置初始化。带钩子的属性不能有默认值,也不能在构造函数里先声明,然后再用钩子初始化?实际上可以在构造里赋值,但属性声明时不能写默认值。比如 public int $count { get; set; } = 0; 是不允许的。

2. set 钩子里的两个特殊变量。$value 是要写入的值。如果你想保留旧的引用,钩子里没法直接拿到旧值,除非你在 set 里自己手动存一个快照。这确实有不便,不过大多数场景用不上。

3. 如果 set 钩子不写任何赋值逻辑,那这个属性就变成只读了吗?不一定。如果你写了 set { } 但什么都没做,外部的赋值操作会被静默吞掉。这跟“只读”不一样,只读是报错,这是忽略。我建议如果没必要自定义 set,就不要声明 set 钩子,让它默认行为变成普通赋值。

4. 性能影响。属性钩子本质上是方法调用,所以跟直接访问属性相比有一点点性能损失。但这个损失非常小,除非在循环里十万次读属性,否则感知不到。而且使用属性钩子通常可以让代码更精简,缓存优化空间更大。

实际项目里的应用场景

我在写一个 DTO(数据传输对象)的时候特别喜欢用属性钩子。比如性别字段存的是数字 0/1,但我想让它直接以“男/女”字符串暴露给前端。以前要么在 DTO 里放两个字段,要么写 getter。现在直接用计算属性:

public string $genderLabel {
    get => $this->gender === 1 ? '男' : '女';
}

还有一个场景是数据脱敏。用户手机号在数据库里存完整 11 位,但接口不能全给出去。老规矩,写个 getMaskedPhone() 方法。现在直接声明一个 maskedPhone 属性,读取的时候自动打码,调用处更自然。

public string $maskedPhone {
    get => substr($this->phone, 0, 3) . '****' . substr($this->phone, -4);
}

这比我以前写一堆 getter 然后到处调用舒服太多了。

总结一下,这玩意值不值得升

如果你还在用 PHP 8.3 或者更老,为了属性钩子升到 8.4 是完全值得的。它不改变 PHP 的语言风格,不强迫你改掉所有代码,只是在你想写 getter/setter 的时候给你一个更优雅的替代。老的访问器方法当然还能用,但新代码里再写一大堆 getXxx() 就有点说不过去了。

有一点要注意:属性钩子跟反射和序列化有一些细微的交互,比如 json_encode 序列化时,公开的钩子属性会被当作普通属性序列化,所以你可以直接序列化整个对象,不用担心丢失钩子里的格式化逻辑。

最后放一个完整的 Money 类最终版,加上了 formatted 虚拟属性和只读的 notation 属性,你们感受一下:

<?php

class Money
{
    private float $rawAmount;
    private string $rawCurrency;

    public float $amount {
        get => $this->rawAmount;
        set {
            if ($value < 0) {
                throw new InvalidArgumentException('金额不能为负数');
            }
            $this->rawAmount = round($value, 2);
        }
    }

    public string $currency {
        get => $this->rawCurrency;
        set {
            if (!preg_match('/^[A-Z]{3}$/', $value)) {
                throw new InvalidArgumentException('币种必须为三位大写字母');
            }
            $this->rawCurrency = $value;
        }
    }

    public string $formatted {
        get => number_format($this->amount, 2) . ' ' . $this->currency;
    }

    public readonly string $code {
        get => $this->currency . $this->amount;
    }

    public function __construct(float $amount, string $currency)
    {
        $this->amount = $amount;
        $this->currency = $currency;
    }
}

$m = new Money(199.999, 'CNY');
echo $m->amount;    // 200
echo $m->formatted; // 200.00 CNY
echo $m->code;      // CNY200

代码干净了,写的也开心。这就是我想要的 PHP。

PHP 8.4 属性钩子实战:把 getter/setter 踢进回收站
收藏 (0) 打赏

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

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

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

淘吗网 php PHP 8.4 属性钩子实战:把 getter/setter 踢进回收站 https://www.taomawang.com/server/php/2641.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

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

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