写 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;
}
}
这只是两个字段,已经把类撑得够臃肿了。要是有十个字段,光这些访问器就够写一整天,而且全都是复制粘贴,毫无技术含量。
属性钩子是什么?一句话概括
属性钩子允许你在定义属性的时候直接写 get 和 set 逻辑,像定义方法一样。这样属性从视觉上仍然是属性,调用的时候直接访问 $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,那样会无限递归。你需要一个没有钩子的“底层存储属性”。最正规的做法是把属性设为 private 或 protected,然后另写一个公开的钩子属性指向它。其实还有一种更优雅的方式,往下看案例。
用属性钩子重构 Money 类
我改了一下 Money 类,让 amount 和 currency 保留为纯私有变量,然后通过两个公开属性 amount 和 currency 加上钩子来暴露它们。这看起来有点绕,但实际用起来很顺手。
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。

