PHP 8.4 属性钩子实战:用 get/set 干掉 DTO 里的样板代码

2026-09-26 0 484

维护过老项目的朋友应该都见过这种东西:一个数据表对应一个实体类,字段十个,getter 加 setter 二十个方法。每个方法里面就一行,return $this->name; 或者 $this->name = $value;,写起来没难度,抄起来手酸。IDE 能自动生成,但那二十个方法实打实躺在文件里,翻代码的时候要认真跳过,不然一屏看不见有用的东西。

更麻烦的是加校验。比如 price 字段必须大于零,你得在 setter 里塞几行判断;updatedAt 需要在每次设置时自动改一下 updatedBy——这种跨字段联动写进 setter 之后,职责边界就开始模糊。到底哪些字段有验证、哪些没有,得翻遍所有 setter 才知道。

PHP 8.4 的属性钩子(Property Hooks)就是为了这事来的。它允许你把读写逻辑直接写在属性定义旁边,不用再另外开方法。这篇文章把语法、常见模式和实际坑都过一遍。

一、先看一段旧代码

class Product
{
    private string $name;
    private int $priceInCents;

    public function getName(): string
    {
        return $this->name;
    }

    public function setName(string $name): void
    {
        $name = trim($name);
        if ($name === '') {
            throw new InvalidArgumentException('商品名不能为空');
        }
        $this->name = $name;
    }

    public function getPriceInCents(): int
    {
        return $this->priceInCents;
    }

    public function setPriceInCents(int $cents): void
    {
        if ($cents < 0) {
            throw new InvalidArgumentException('价格不能为负');
        }
        $this->priceInCents = $cents;
    }

    public function getPriceYuan(): string
    {
        return number_format($this->priceInCents / 100, 2);
    }
}

三十多行代码,实际做的事情就三件:读、写、格式化。属性钩子能把这三件事压缩成一段:

class Product
{
    public string $name {
        get => $this->name;
        set (string $value) {
            $value = trim($value);
            if ($value === '') {
                throw new InvalidArgumentException('商品名不能为空');
            }
            $this->name = $value;
        }
    }

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

    public string $priceYuan {
        get => number_format($this->priceInCents / 100, 2);
    }
}

功能完全一样,代码短了一半多。而且读写逻辑贴着属性本身,看一个字段的时候不用上下翻。

二、语法要点

整体结构是:

public 类型 属性名 {
    get => 表达式;
    set (参数类型 $value) { 语句块 }
}

几个规则需要说清楚。

get 可以直接是表达式,也可以是块

// 表达式形式
public string $fullName {
    get => $this->first . ' ' . $this->last;
}

// 语句块形式
public string $fullName {
    get {
        $parts = array_filter([$this->first, $this->last]);
        return implode(' ', $parts);
    }
}

表达式形式更简洁,但只能写一条。return 关键字省略。块形式需要显式 return。

set 也可以有名参数

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

参数名可以随便写,不一定要叫 $value。上面这个例子里 $raw 更能表达”传进来还没处理的原始值”。

参数类型要和属性类型一致

如果属性声明是 public string $name,那 set 的参数也必须是 string。写错了会在编译期报错。这一条和 PHP 一贯的”类型声明要一致”是同一个原则。

只写 get 就是只读属性

class Circle
{
    public float $radius;
    public float $area {
        get => M_PI * $this->radius ** 2;
    }
}

$c = new Circle();
$c->radius = 5.0;
echo $c->area;   // 78.539816339745
$c->area = 90;   // Error: Cannot set readonly property

只定义 get 的属性不能赋值,尝试写会抛错误。这就是虚属性(virtual property)——它不占用实际内存,每次读取的时候动态算出来。

get 和 set 可以只定义一个

反过来也一样,只写 set 不写 get 的用法比较少见,但语法上允许。这时读这个属性会得到 null(前提是属性没被赋过值)或者触发未初始化错误。看起来有点怪,但某些”只写不读”的场景(比如密码)是有意义的。

class User
{
    public string $password {
        set (string $value) {
            $this->password = password_hash($value, PASSWORD_DEFAULT);
        }
        get => throw new LogicException('密码字段只写不读');
    }
}

这样既能安全地存密码,又能在别人尝试读出来的时候给出明确错误。

三、不对称可见性:一个新的武器

属性钩子还有一个配套特性:asymmetric visibility(不对称可见性)。它允许 get 和 set 用不同的可见性级别。

class Order
{
    // 外部可以读,但只有本类内部能改
    public private(set) string $status = 'pending';
}

读法:public 是读的可见性,private(set) 是写的可见性。

以前表达同样的语义,要么写个 public getter + private 属性(多一个方法),要么写 public 属性 + 私有 setter,要么靠魔术方法兜底。现在一行就够。

这个特性单独拿出来都值得写一篇文章,和属性钩子叠加起来,能表达相当丰富的模型约束:

class Order
{
    public private(set) string $status = 'pending' {
        set (string $value) {
            if (!in_array($value, ['pending', 'paid', 'shipped'], true)) {
                throw new InvalidArgumentException("非法状态:{$value}");
            }
            $this->status = $value;
        }
    }

    public function markPaid(): void
    {
        $this->status = 'paid';   // 类内部可以改
    }
}

$order = new Order();
echo $order->status;        // 'pending',可以读
$order->status = 'paid';    // Error:外部不能写
$order->markPaid();         // OK:走内部方法

这段代码表达的意思很清楚:状态对世界公开、对世界只读,但内部可以通过有自己的业务方法修改,并且修改的时候会走一遍合法性校验。整个约束就在这一个属性定义里说完了。

四、案例一:带货币处理的订单金额

来写一个稍微完整点的东西。金额是电商系统里最容易出问题的地方之一,涉及精度、货币单位、格式化、跨币种转换。

class Money
{
    public function __construct(
        public readonly int $cents,
        public readonly string $currency = 'CNY',
    ) {}

    public static function fromYuan(float $yuan, string $currency = 'CNY'): self
    {
        return new self((int) round($yuan * 100), $currency);
    }

    public function toYuan(): float
    {
        return $this->cents / 100;
    }

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

class Order
{
    public private(set) Money $amount {
        set (Money|int|float $value) {
            $this->amount = match (true) {
                $value instanceof Money => $value,
                is_int($value)           => new Money($value),
                is_float($value)         => Money::fromYuan($value),
                default => throw new InvalidArgumentException('金额类型不支持'),
            };
        }
        get {
            if (!isset($this->amount)) {
                throw new LogicException('订单金额还没设置');
            }
            return $this->amount;
        }
    }

    public float $amountYuan {
        get => $this->amount->toYuan();
    }

    public string $displayAmount {
        get => (string) $this->amount;
    }
}

用起来是这样:

$order = new Order();
$order->amount = 199.9;        // 传 float,自动转成 Money 对象

echo $order->displayAmount;    // 199.90 CNY
echo $order->amountYuan;       // 199.9

这个例子里有意思的地方是 set 钩子的参数类型。它接受三种类型(Money、int、float),但实际存储的永远是 Money 对象。这种转换逻辑以前得写在 setter 里,调用方还要知道到底该传什么;现在从使用者的角度看,怎么传都行。

还有一处细节值得注意:amount 的 get 钩子里有未初始化检查。因为属性声明时没有默认值,在没赋过值的情况下读取,会抛一个明确的 LogicException,比默认返回 null 要安全很多。

五、案例二:用户资料字段联动

再来看一个跨字段联动的例子。用户设置昵称之后,系统需要自动生成一段英文 slug,用来做 URL 友好展示。

class UserProfile
{
    public string $displayName {
        set (string $value) {
            $value = trim($value);
            if (mb_strlen($value) > 30) {
                throw new InvalidArgumentException('昵称不能超过 30 个字');
            }
            $this->displayName = $value;
            $this->slug = $this->buildSlug($value);
        }
        get => $this->displayName;
    }

    public private(set) string $slug = '';

    private function buildSlug(string $name): string
    {
        $slug = strtolower($name);
        $slug = preg_replace('/[^a-z0-9p{Han}]+/u', '-', $slug);
        return trim($slug, '-');
    }
}
$profile = new UserProfile();
$profile->displayName = '张三 三';

echo $profile->displayName;  // 张三 三
echo $profile->slug;         // 张三-三

要注意的地方是:slug 用 public private(set) 声明,外部只能读,不能写。它的更新完全由 displayName 的 set 钩子驱动。这种”派生字段”用钩子表达特别自然,比在 setter 里手动维护要清爽。

当然这个 slug 例子里中文字符被保留了,实际项目中可能想全部转拼音,那就需要引入额外的库。这里为了说明钩子机制,简单处理一下即可。

六、虚属性、条件和延迟求值

虚属性是属性钩子里一个特别实用的能力。它不占内存,每次读取时算一遍。开销大的表达式配合 memo 会有性能优势。

class Report
{
    public function __construct(
        private array $rawData,
    ) {}

    public array $summary {
        get {
            // 每次读都会重算 —— 如果数据大就很浪费
            return array_reduce(
                $this->rawData,
                fn($acc, $row) => [
                    'count' => $acc['count'] + 1,
                    'sum'   => $acc['sum'] + $row['amount'],
                ],
                ['count' => 0, 'sum' => 0],
            );
        }
    }
}

如果 summary 会被多次读取,每次重算显然不划算。可以加一个私有属性做缓存:

class Report
{
    private ?array $summaryCache = null;

    public function __construct(
        private array $rawData,
    ) {}

    public array $summary {
        get => $this->summaryCache ??= array_reduce(
            $this->rawData,
            fn($acc, $row) => [
                'count' => $acc['count'] + 1,
                'sum'   => $acc['sum'] + $row['amount'],
            ],
            ['count' => 0, 'sum' => 0],
        );
    }
}

??= 是 PHP 7.4 引入的 null 合并赋值运算符,写法简洁。但有两个点要注意。

第一,缓存字段在任何会改 rawData 的地方都要重置。如果 rawData 是 private 并且只能在构造时赋值,那没问题;如果可以被修改,就得在修改处一并清空缓存。

第二,属性钩子本身不提供”被动失效”机制。这一点不像 Vue 的 computed,不会自动追踪依赖。缓存失效得手动管理。

条件逻辑用 in_array 判断

class PagedResult
{
    public bool $hasNext {
        get => $this->page * $this->perPage < $this->total;
    }

    public bool $hasPrev {
        get => $this->page > 1;
    }

    public array $pageNumbers {
        get {
            $start = max(1, $this->page - 2);
            $end   = min($this->lastPage, $start + 4);
            return range($start, $end);
        }
    }
}

分页对象里的这些派生状态,用虚属性表达比用方法要自然得多。使用方写 $result->hasNext 而不是 $result->hasNext(),读起来更像在读数据,而不是在调用方法。

七、六个实际踩过的坑

1. 不要在钩子里递归访问自己

// 死循环
public string $name {
    get => $this->name;
}

这段代码是无限递归。$this->name 又会触发 get,又读到 get,又读……直到栈溢出。

正确写法有两种。第一种是让 PHP 自动处理——如果钩子里访问的属性本身有存储,直接用 $this->name 是不会有递归的,引擎会区分”钩子调用”和”实际存储访问”。第二种是显式把存储字段区分出来:

private string $nameValue;

public string $name {
    get => $this->nameValue;
    set (string $value) {
        $this->nameValue = trim($value);
    }
}

这种写法麻烦但直观,不容易出问题,尤其是在跨版本迁移的时候更保险。

需要提醒的是:更复杂的情况(比如虚属性里访问另一个虚属性、而那个又反过来访问自己)仍然可能栈溢出。写的时候注意一下依赖方向,最好保持单方向的引用关系。

2. parent 和 self 的类型检查不会自动适配

class Base
{
    public string $value {
        get => $this->value;
        set (string $value) { $this->value = $value; }
    }
}

class Child extends Base
{
    // 错误:重写钩子时参数类型要一致
    public string $value {
        set (string|int $value) { ... }
    }
}

子类重写父类的属性钩子时,参数类型和返回类型必须兼容。PHP 的协变和逆变规则在这里也适用。看似简单,但混着继承一起用的时候,很容易在运行时才发现问题。

3. readonly 和属性钩子不能同时用

// 语法错误
public readonly string $name {
    get => $this->name;
}

属性钩子本身就意味着属性可以被 hook 修改,和 readonly 直接冲突。如果确实需要”只在构造时赋值、之后只读”,用 public private(set) 加一个只在构造函数里赋值的模式。

4. 不支持引用传递

属性钩子不能返回引用,也不能在 set 参数里接引用。这样设计是为了避免钩子里做副作用写到外部状态上,属于刻意限制。如果原来有类似 $obj->items[] = $x 这种写法,需要改成显式方法调用。

// 会报错
$order->items[] = $item;

// 改成
$order->addItem($item);

5. 序列化行为

json_encode($product) 的时候,虚属性(只定义了 get、没有实际存储的属性)会不会被序列化出来?答案是不会。JSON 序列化只看属性实际是否存在,虚属性没有对应的存储槽,因此不会被包含进去。

如果希望虚属性也出现在 JSON 里,需要实现 JsonSerializable,在 jsonSerialize() 里手动拼出想要的数组结构。这其实是个好事——默认行为更保守,不会因为一个虚属性就改变 API 响应结构。

反序列化时也要注意:如果属性声明的类型和 JSON 中的类型不匹配,会触发 set 钩子的类型检查。如果业务上需要宽容地接受更多输入类型,就得在钩子里做转换。

6. 属性钩子和魔术方法不要混用

如果类里同时定义了 __get/__set 和属性钩子,行为可能会让人困惑。基本规则是:属性钩子优先,魔术方法只在属性未定义或不可访问时才被调用。

结论很简单:不要混用。迁移到属性钩子之后,把对应的 __get/__set 都删掉,避免留下两个互相不知道对方存在的机制。

八、一个完整的 DTO 示例

把这些要点综合起来,写一个实际项目中可以直接用的 DTO:

final class CreateUserInput
{
    public string $username {
        set (string $value) {
            $value = trim($value);
            if (!preg_match('/^[a-zA-Z0-9_]{3,20}$/', $value)) {
                throw new InvalidArgumentException('用户名需为 3-20 位字母、数字或下划线');
            }
            $this->username = $value;
        }
        get => $this->username;
    }

    public string $email {
        set (string $value) {
            $value = strtolower(trim($value));
            if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
                throw new InvalidArgumentException('邮箱格式不正确');
            }
            $this->email = $value;
        }
        get => $this->email;
    }

    public int $age {
        set (int $value) {
            if ($value < 0 || $value > 150) {
                throw new InvalidArgumentException('年龄必须在 0-150 之间');
            }
            $this->age = $value;
        }
        get => $this->age;
    }

    public string $displayName {
        get => $this->displayName ??= $this->username;
    }
}

用途很清晰:在控制器接收请求参数之后,构造这个对象时就完成了全部校验,往下传给 Service 层的永远是一个合法的数据结构。不用在每次进入业务流程之前都检查一遍字段。

try {
    $input = new CreateUserInput();
    $input->username = $request->post('username');
    $input->email    = $request->post('email');
    $input->age      = (int) $request->post('age');

    $userService->create($input);
} catch (InvalidArgumentException $e) {
    return json(['error' => $e->getMessage()], 400);
}

这段代码有个明显的好处:字段的规则和字段本身绑在一起。以后要改用户名的规则,去 username 属性那里改就行,不用全项目搜”哪个地方在验证用户名”。

九、什么时候该用,什么时候别用

属性钩子是个好东西,但不是所有场景都值得上。

适合的场景:DTO 和值对象;需要在读写时做校验的字段;派生字段(如全名、格式化金额、布尔状态);配置类对象;任何字段读写逻辑比较独立的地方。

不适合的场景:逻辑很重、涉及多个外部依赖的属性——这种更适合抽成独立方法或者服务;需要延迟加载大对象的属性——钩子里做太多事,读属性的时候很容易触发意外 IO;已经被 ORM 魔术方法管理的实体类——和钩子混用容易出问题,具体看 ORM 的版本支持程度。

还有一件事需要提醒:如果你维护的类库需要兼容 PHP 8.3 及更早版本,属性钩子暂时不能用。这种情况下可以先在项目内部约定使用,等所有环境升级完之后再统一迁移公共代码。

十、写在最后

属性钩子看起来只是语法糖——把 getter 和 setter 换个地方写。但用了一段时间之后,我的感受是它改变了写代码时的思考方式。

以前写实体类的时候,习惯先把字段列一遍,然后去写对应的方法,字段的约束散落在方法体里。现在写的时候,是一个字段一个字段地过:这个字段的类型是什么,读起来要做什么处理,写进去之前要验证什么。整个类的结构从”方法集合”变成了”字段以及它们的规则”。

这种视角的变化,比语法上的简化更有价值。在调试的时候也一样——看到某个字段的值不对,直接去看它的钩子就行,不用先找 setter 再进去。

如果你的项目正在升级到 PHP 8.4,建议先挑一个简单的 DTO 试一下。看到 public private(set) string $status 这一行是怎么替代八个方法的,就会知道这个特性值不值得用起来。

PHP 8.4 属性钩子实战:用 get/set 干掉 DTO 里的样板代码
收藏 (0) 打赏

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

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

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

淘吗网 php PHP 8.4 属性钩子实战:用 get/set 干掉 DTO 里的样板代码 https://www.taomawang.com/server/php/2822.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

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

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