PHP 8.4属性钩子实战:彻底告别getter和setter的样板代码

2026-07-17 0 899

PHP 8.4 在 2024 年 11 月正式发布,这个版本带来的两个重磅特性——属性钩子非对称可见性——直接改变了 PHP 类的编写方式。过去定义一个稍微复杂的属性就得配上一对 getter 和 setter,属性多了之后类文件一大半都是这些样板代码。属性钩子让属性的读写行为直接挂在属性本身,非对称可见性让读和写的访问权限可以分开控制。这篇文章用一个完整的用户管理案例,把这两个特性从语法到实战都讲清楚,所有代码都可以在 PHP 8.4 环境下直接运行。

一、先看痛点:传统写法的样板代码到底有多少

假设你要定义一个用户实体,需求很简单:用户名不可为空、邮箱格式要校验、密码只能设置不能读取、创建时间自动生成且不可修改。用 PHP 8.3 以前的写法,这个类长这样:

class User
{
    private string $name;
    private string $email;
    private string $password;
    private DateTimeImmutable $createdAt;

    public function __construct(string $name, string $email, string $password)
    {
        $this->setName($name);
        $this->setEmail($email);
        $this->password = password_hash($password, PASSWORD_BCRYPT);
        $this->createdAt = new DateTimeImmutable();
    }

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

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

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

    public function setEmail(string $email): void
    {
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException('邮箱格式不正确');
        }
        $this->email = strtolower($email);
    }

    public function verifyPassword(string $input): bool
    {
        return password_verify($input, $this->password);
    }

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

三个业务属性,配了六个方法,外加一个构造函数。属性越多,这个比例越夸张。而且 getXxxsetXxx 的命名虽然约定俗成,但本质上是在用方法模拟属性的读写行为——属性本身是公开不了的,因为你需要校验逻辑,但又不想放弃访问控制的简洁性。

PHP 8.4 的属性钩子直接把这个矛盾化解了。你可以在属性上定义 getset 钩子,读属性时自动走 get 逻辑,写属性时自动走 set 逻辑。调用方用的还是 $user->name 这种直观的属性语法,而不是 $user->getName()

二、属性钩子语法速览

属性钩子是挂载在属性定义上的代码块,用 getset 关键字标识:

class Product
{
    // 带 get 钩子的属性:每次读取时触发
    public string $displayPrice {
        get => '¥' . number_format($this->price, 2);
    }

    // 带 set 钩子的属性:每次写入时触发
    public string $name {
        set(string $value) {
            if (trim($value) === '') {
                throw new InvalidArgumentException('名称不能为空');
            }
            $this->name = trim($value);
        }
    }

    // 同时有 get 和 set 的属性
    public float $price {
        get => $this->price;
        set(float $value) {
            if ($value price = $value;
        }
    }

    private float $price; // 幕后存储字段
}

几个关键规则需要记住:

  • 有钩子的属性不能和普通属性混用。如果一个属性定义了钩子,它就不能再在构造函数里直接赋值 $this->prop = value(除非 set 钩子本身会处理)。
  • get 钩子必须返回值,set 钩子不返回值。get 的简写形式用 => 箭头直接返回表达式,也可以写成完整的代码块 get { ... }
  • 幕后存储。如果属性的 get 和 set 都需要操作同一个存储位置,你需要在类里定义一个同名的私有属性作为实际存储。这是目前 PHP 属性钩子设计里的一个特点——钩子本身不自动提供存储。
  • 只读属性可以用 get 钩子配合 readonly 修饰符吗?不能。如果一个属性有钩子,就不能再加 readonly。但你可以只定义 get 钩子而不定义 set 钩子,这自然就是一个只读属性。

三、非对称可见性:读写权限分开控制

PHP 8.4 的另一个重要特性是非对称可见性。简单说就是你可以让一个属性的读权限和写权限不一样。语法是 public get protected setpublic get private set

class Order
{
    // 任何人都能读取,但只有子类和自身能修改
    public string $status {
        get => $this->status;
        protected set(string $value) {
            $allowed = ['pending', 'paid', 'shipped', 'completed'];
            if (!in_array($value, $allowed, true)) {
                throw new InvalidArgumentException("无效的订单状态: {$value}");
            }
            $this->status = $value;
        }
    }

    // 任何人都能读取,只有自身能修改
    public DateTimeImmutable $createdAt {
        get => $this->createdAt;
        private set => $this->createdAt;
    }

    private string $status;
    private DateTimeImmutable $createdAt;
}

这个特性在实体类里特别好用。比如订单金额,外部任何代码都能读取,但只能通过专门的 markAsPaid() 方法来修改。以前这种需求要么把属性设成 private 然后写一堆 getter,要么靠文档和代码审查来约束。非对称可见性让这个约束在语法层面就落实了。

四、实战:用 PHP 8.4 重写用户管理系统

回到开头的用户实体。用属性钩子和非对称可见性重写之后,代码的变化会非常明显。

4.1 用户实体类

class User
{
    // 用户名:外部可读写,写入时自动 trim 并校验
    public string $name {
        get => $this->name;
        set(string $value) {
            $trimmed = trim($value);
            if ($trimmed === '') {
                throw new InvalidArgumentException('用户名不能为空');
            }
            $this->name = $trimmed;
        }
    }

    // 邮箱:外部可读写,写入时自动转小写并校验
    public string $email {
        get => $this->email;
        set(string $value) {
            $validated = filter_var($value, FILTER_VALIDATE_EMAIL);
            if (!$validated) {
                throw new InvalidArgumentException("邮箱格式不正确: {$value}");
            }
            $this->email = strtolower($validated);
        }
    }

    // 密码:外部不能读取不能写入,只能通过构造函数设置
    private string $password;

    // 创建时间:外部可读,但外部不能修改
    public DateTimeImmutable $createdAt {
        get => $this->createdAt;
        private set => $this->createdAt;
    }

    // 最后登录时间:外部可读,只能内部更新
    public ?DateTimeImmutable $lastLoginAt {
        get => $this->lastLoginAt;
        private set => $this->lastLoginAt;
    } = null;

    // 状态:外部可读,protected set 允许子类修改
    public string $status {
        get => $this->status;
        protected set(string $value) {
            $allowedValues = ['active', 'inactive', 'suspended'];
            if (!in_array($value, $allowedValues, true)) {
                throw new InvalidArgumentException("无效的用户状态: {$value}");
            }
            $this->status = $value;
        }
    } = 'active';

    // 幕后存储字段
    private string $name;
    private string $email;
    private ?DateTimeImmutable $lastLoginAt = null;
    private string $status = 'active';
    private DateTimeImmutable $createdAt;

    public function __construct(string $name, string $email, string $password)
    {
        $this->name = '';     // 先初始化幕后字段,否则 set 钩子里的 $this->name 未定义
        $this->email = '';
        $this->createdAt = new DateTimeImmutable();

        // 通过属性赋值触发 set 钩子进行校验
        $this->name = $name;
        $this->email = $email;
        $this->password = password_hash($password, PASSWORD_BCRYPT);
    }

    // 验证密码的方法保留,毕竟它是一段业务逻辑不是单纯的属性读写
    public function verifyPassword(string $input): bool
    {
        return password_verify($input, $this->password);
    }

    // 记录登录时间:内部修改
    public function recordLogin(): void
    {
        $this->lastLoginAt = new DateTimeImmutable();
    }

    // 封禁用户:通过 protected set 允许子类也能操作
    public function suspend(): void
    {
        $this->status = 'suspended';
    }

    public function activate(): void
    {
        $this->status = 'active';
    }
}

重写之后,这个类里不再有 getNamesetNamegetEmailsetEmail 这些方法,取而代之的是挂在属性上的 getset 钩子。调用方的写法从

$user->setName('张三');
echo $user->getEmail();

变成了

$user->name = '张三';
echo $user->email;

语义上更自然,而校验逻辑完全没有丢失——$user->name = '' 仍然会抛出异常,因为 set 钩子里的校验在起作用。

4.2 使用示例

// 创建用户
$user = new User('张三', 'Zhangsan@Example.com', 'secret123');

// 像操作普通属性一样读写,校验逻辑自动生效
echo $user->name;   // "张三"
echo $user->email;  // "zhangsan@example.com"(自动转小写)

// 读取只读属性
echo $user->createdAt->format('Y-m-d H:i:s');

// 通过业务方法修改状态
$user->recordLogin();
echo $user->lastLoginAt?->format('Y-m-d H:i:s'); // 输出当前时间

// 尝试非法赋值会抛出异常
// $user->name = '';    // InvalidArgumentException
// $user->email = 'abc'; // InvalidArgumentException
// $user->createdAt = new DateTimeImmutable(); // Error: private set

关键点在于,之前那些 getter 和 setter 里藏着的校验逻辑,现在全部内聚在属性定义里。你在看这个类的属性列表时,就能直接看到每个属性的校验规则和访问权限,不需要在方法列表里来回查找。

五、进阶用法:虚拟属性和计算属性

属性钩子不一定要对应一个幕后存储字段。有些属性本身是通过其他属性计算出来的,这在展示层里经常用到。

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

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

    // 全名:虚拟属性,没有幕后存储,实时计算
    public string $fullName {
        get => $this->firstName . ' ' . $this->lastName;
    }

    // 头像URL:依赖其他属性的计算属性
    public string $avatarUrl {
        get {
            $hash = md5(strtolower($this->email));
            return "https://gravatar.com/avatar/{$hash}?s=200";
        }
    }

    private string $firstName;
    private string $lastName;
    private string $email;

    public function __construct(string $firstName, string $lastName, string $email)
    {
        $this->firstName = $firstName;
        $this->lastName = $lastName;
        $this->email = $email;
    }
}

// 使用
$profile = new UserProfile('张', '伟', 'zhang@example.com');
echo $profile->fullName;    // "张 伟"
echo $profile->avatarUrl;   // Gravatar URL

fullNameavatarUrl 没有自己的幕后存储,每次访问时实时计算。对于调用方来说,它和普通属性没有任何区别——都是 $obj->prop 的方式访问。这种模式在数据展示、DTO 转换、API 响应格式化时非常顺手。

六、属性钩子与构造函数的关系

这是 PHP 8.4 属性钩子里一个容易踩的坑。构造函数里给一个带钩子的属性赋值时,必须先确保幕后存储字段已经初始化,否则 set 钩子里引用 $this->prop 时会触发”未初始化属性”的错误。

正确的做法是在构造函数里先给幕后字段一个默认值,再通过属性赋值触发钩子。你也可以在属性定义时直接给默认值:

class Config
{
    public string $appName {
        get => $this->appName;
        set(string $value) {
            if (strlen($value) > 50) {
                throw new InvalidArgumentException('应用名称不能超过50个字符');
            }
            $this->appName = $value;
        }
    } = 'My Application'; // 属性默认值

    private string $appName;
}

这样即使构造函数里没有显式给 $appName 赋值,属性也已经有了合法的初始值。

七、属性钩子的局限

新特性虽好,但有几点目前还做不到,需要心里有数:

  • 钩子里不能调用其他带钩子的属性。如果 getA 里访问了 getB,可能会触发循环引用或者初始化顺序问题。PHP 目前的实现不保证钩子之间的调用顺序。
  • 属性钩子不能被单独覆盖。如果子类想复用父类属性的 get 钩子但修改 set 钩子,目前没有直接的办法,只能整体重写属性。
  • 接口不能声明属性钩子。接口里仍然只能声明方法,不能声明带钩子的属性。这是一个被社区讨论很多的话题,可能在后续版本里放开。
  • 有些 IDE 的自动补全还不够智能。属性钩子是全新的语法,IDE 支持在初期可能不完全,比如对钩子内部 $this->prop 的类型推断有时候会失效。

这些问题不影响日常使用,在你决定在项目里全面采用属性钩子之前,在本地环境充分测试即可。

八、迁移策略:从传统实体类逐步过渡

如果你手上有一个运行中的 PHP 8.3 项目,升级到 8.4 后不需要一次性把所有类都改成属性钩子。推荐的迁移顺序是:

先改 DTO 和数据对象。那些纯粹用来承载数据的类,getter 和 setter 最多,改写收益也最大。把 getName() / setName() 替换成带钩子的 public string $name,对外接口从方法调用变成属性访问,语义更清晰。如果外部代码已经大量使用了 getter 和 setter,可以暂时保留方法作为别名,内部委托给属性钩子。

再改值对象。像 Email、PhoneNumber 这种包装类,原先的校验逻辑在构造函数里,现在可以放在属性的 set 钩子里,让校验和赋值更内聚。

实体类最后改。实体类里的状态变更往往伴随着领域事件或其他副作用,set 钩子虽然能处理校验,但不应该包含过多的业务逻辑。如果某个属性的赋值需要触发事件、更新其他聚合、或者操作外部服务,这些逻辑还是应该留在显式的业务方法里,属性的 set 钩子只负责数据校验。

九、总结

属性钩子和非对称可见性不是那种”有了就能写出完全不同的架构”的特性,它们是”删代码”的特性。它们减少的是那些你写了无数遍、每次 CR 都懒得多看一眼的 getter 和 setter。一个类少十几个方法,整个项目的可读性和维护效率会有可感知的提升。

更有价值的是,属性的访问控制语义终于和属性定义放在一起了。以前你需要在一个地方定义 private string $email,在另一个地方写 getEmail()setEmail(),再在别的地方加校验逻辑。现在所有和 $email 相关的规则——能不能读、能不能写、写的时候要过什么校验——都在属性定义的那个代码块里。对后来读代码的人来说,信息密度大幅提高。

文中的所有代码都需要 PHP 8.4 及以上版本。如果你用的是 Docker 或 Laravel Herd,切换版本几分钟就能搞定。把文中的用户实体类复制进去跑一遍,感受一下没有 getter 和 setter 的类到底清爽到什么程度。

PHP 8.4属性钩子实战:彻底告别getter和setter的样板代码
收藏 (0) 打赏

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

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

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

淘吗网 php PHP 8.4属性钩子实战:彻底告别getter和setter的样板代码 https://www.taomawang.com/server/php/2378.html

常见问题

相关文章

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

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