PHP 8.4 Property Hooks实战:彻底告别getter与setter样板代码

2026-06-17 0 713

 

上个月我把一个老旧的用户模块从PHP 8.1升级到8.4时,注意到一个让我眼前一亮的特性——属性钩子Property Hooks)。之前为了封装密码哈希、日期格式化、权限校验,每个属性都得配一对getXxx()setXxx()方法,或者用魔术方法__get__set来避免,但那样又会丢掉类型提示和IDE支持。Property Hooks恰好解决了这个长久以来的痛点:你可以在属性定义本身旁边,直接声明getset的逻辑,整个代码量缩水不少,可读性反而上来了。

这篇文字就结合一个用户模型的改造过程,把Property Hooks的用法、虚拟属性、以及和构造器提升、只读属性的配合方式完整跑一遍,全部代码都能在PHP 8.4下直接运行。

传统方式:被getter/setter淹没的模型

先看看过去我是怎么定义用户类的。为保护password属性,通常不允许直接赋值铭文密码,而是通过一个setPassword方法来哈希:

class User {
    private string $passwordHash;
    private string $email;
    private DateTimeImmutable $createdAt;

    public function __construct(string $email, string $plainPassword) {
        $this->email = $email;
        $this->passwordHash = password_hash($plainPassword, PASSWORD_BCRYPT);
        $this->createdAt = new DateTimeImmutable();
    }

    public function getPasswordHash(): string {
        return $this->passwordHash;
    }

    public function setPassword(string $plainPassword): void {
        $this->passwordHash = password_hash($plainPassword, PASSWORD_BCRYPT);
    }

    public function getEmail(): string {
        return $this->email;
    }

    public function getCreatedAt(): DateTimeImmutable {
        return $this->createdAt;
    }

    // ... 还有一堆业务方法
}

这种写法在属性多的时候特别冗长。而且调用方需要用$user->getCreatedAt()而不是直接$user->createdAt,无形中增加了许多括号噪音。

Property Hooks初探:钩子即方法

属性钩子的思路很简单:在属性声明后面加上用花括号包裹的getset块,分别定义读、写该属性时执行的操作。它看起来有点像C#的属性,但语法保持了PHP的风格。

我们把上面的$passwordHash改为带钩子的属性,同时保留存储值(需要一个后台字段)。PHP 8.4允许使用$this->实际属性名来存取原始值,但更推荐直接为钩子属性定义一个私有的真实存储属性。实践中常用方式是:将属性定义为private且带钩子,钩子内部读写该属性自身。不过要注意避免递归——在get钩子里不能再读自己,在set钩子里不能直接给自己赋值,需要用$value参数。

重构用户模型:第一步,哈希密码的set钩子

我们从密码入手。属性$password现在支持getset钩子:

class User {
    private string $password {
        set (string $plainPassword) {
            $this->password = password_hash($plainPassword, PASSWORD_BCRYPT);
        }
        get {
            return $this->password;
        }
    }

    public function __construct(string $email, string $plainPassword) {
        $this->email = $email;
        $this->password = $plainPassword; // 触发set钩子,自动哈希
        $this->createdAt = new DateTimeImmutable();
    }

    private string $email;
    private DateTimeImmutable $createdAt;
}

注意set钩子里我们用的是$this->password = ...来真正存储哈希值,这实际上又触发了自身的set钩子?不,在钩子内部对自身属性的直接赋值是允许的,并且它不会再次触发钩子,而是直接写入属性的存储。这是编译器专门处理过的。

外部调用$user->password = 'new_plain';就会自动哈希并存储,而echo $user->password;会返回哈希值。我们成功去掉了setPasswordgetPasswordHash两个方法。

第二步:虚拟属性——组合字段而不额外存储

用户类通常会有全名由姓和名拼接而成。过去我们会定义一个getFullName()方法。现在可以用虚拟属性实现:没有存储空间的属性,只定义get钩子(或加上set),根本不需要在类内部存值。

class User {
    // ... 其他属性

    public string $fullName {
        get {
            return $this->firstName . ' ' . $this->lastName;
        }
    }

    public function __construct(
        private string $firstName,
        private string $lastName,
        // ...其他参数
    ) {}
}

这里我们使用了构造器提升(private string $firstName直接在构造器参数),同时定义了虚拟属性$fullName,它没有后台字段,完全由get钩子动态计算。外部可以像访问普通属性一样读取$user->fullName,而且因为是只读的(没有set钩子),TypeEnforcement会自动阻止赋值。

第三步:带校验的set钩子与类型收窄

电子邮件地址需要格式校验。我们为$email加上set钩子,在赋值时过滤无效值:

private string $email {
    set (string $value) {
        if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException('无效的电子邮箱');
        }
        $this->email = strtolower($value);
    }
    get => $this->email;
}

这里get使用了箭头函数简写,等价于get { return $this->email; }。当尝试给$email赋无效值时,异常会在赋值时刻抛出,比事后验证更即时。

第四步:只读属性与钩子的配合

$createdAt通常只在对象构造时设置一次,之后不允许修改。PHP 8.1起支持readonly修饰符,但readonly属性不能定义钩子。不过我们可以用钩子实现“仅构造一次”的语义:在set钩子中检查是否已赋值,只允许首次写入。

private ?DateTimeImmutable $createdAt = null {
    set (DateTimeImmutable|string $value) {
        if ($this->createdAt !== null) {
            throw new LogicException('创建时间不可修改');
        }
        $this->createdAt = $value instanceof DateTimeImmutable 
            ? $value 
            : new DateTimeImmutable($value);
    }
    get => $this->createdAt;
}

构造函数里我们传入一个DateTimeImmutable或字符串,后续尝试修改会抛出异常,达到了readonly的效果,同时还能处理类型转换。

完整用户模型与应用示例

把所有改造整合到一起,得到最终的User类:

class User {
    public string $fullName {
        get => $this->firstName . ' ' . $this->lastName;
    }

    private string $email {
        set (string $value) {
            if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
                throw new InvalidArgumentException('无效邮箱');
            }
            $this->email = strtolower($value);
        }
        get => $this->email;
    }

    private string $password {
        set (string $plainPassword) {
            $this->password = password_hash($plainPassword, PASSWORD_BCRYPT);
        }
        get => $this->password;
    }

    private ?DateTimeImmutable $createdAt = null {
        set (DateTimeImmutable|string $value) {
            if ($this->createdAt !== null) {
                throw new LogicException('创建时间不可修改');
            }
            $this->createdAt = $value instanceof DateTimeImmutable
                ? $value
                : new DateTimeImmutable($value);
        }
        get => $this->createdAt;
    }

    public function __construct(
        private string $firstName,
        private string $lastName,
        string $email,
        string $plainPassword,
    ) {
        $this->email = $email;
        $this->password = $plainPassword;
        $this->createdAt = 'now'; // 取当前时间
    }

    // 业务方法
    public function verifyPassword(string $plainPassword): bool {
        return password_verify($plainPassword, $this->password);
    }
}

使用起来非常直观:

$user = new User('张', '三', 'zhangsan@example.com', 'secret123');
echo $user->fullName; // 张三
$user->email = 'NewEmail@Example.com'; // 自动转小写
echo $user->email; // newemail@example.com

// 尝试修改创建时间会抛出异常
// $user->createdAt = 'yesterday'; // LogicException

// 密码自动哈希,验证
var_dump($user->verifyPassword('secret123')); // true

性能注意和小坑

属性钩子本质上会生成对应的方法调用,所以在极高频访问的场景下可能有轻微性能损耗。但对于绝大多数业务代码,这点开销完全可以忽略不计。更重要的一点是,钩子内的递归保护:在set钩子里对$this->属性名赋值是直接写存储,不会导致无限循环;但如果在get钩子里又去读取同一属性,就会触发递归,导致致命错误。因此get钩子中只能返回后台字段或者引用其他属性。

另外,issetunset对带钩子的属性的行为也可以通过实现issetunset钩子来定义,不过大部分场景默认行为就够了。

何时用属性钩子,何时仍用传统方法

属性钩子适合那些单纯围绕某个字段的存取逻辑:类型过滤、自动转换、延迟加载、格式美化。而涉及多个字段协同、需要参数或者副作用很大的操作,还是应该用传统方法保持意图清晰。比如verifyPassword就需要明文参数,没必要去改写password的get钩子。

总结

getPasswordHash()setPassword()到天然的属性访问,PHP 8.4的Property Hooks把对象封装推到了一个更舒服的平衡点:既有直接操作属性的便利,又不丢失控制逻辑。在用户模型这个典型场景里,我们用几行钩子替换了几十个样板字符,代码的意图变得一目了然。

如果你的项目刚刚升级或者即将迁移到PHP 8.4,建议把那些被大量getset簇拥的实体类拿出来,用属性钩子重写一遍。你很快会发现,面向对象的封装性和代码的简洁性,终于可以同时拥有了。

PHP 8.4 Property Hooks实战:彻底告别getter与setter样板代码
收藏 (0) 打赏

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

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

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

淘吗网 php PHP 8.4 Property Hooks实战:彻底告别getter与setter样板代码 https://www.taomawang.com/server/php/2166.html

常见问题

相关文章

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

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