编程

带你深入了解 PHP 8.4 的属性钩子

204 2024-09-04 05:18:00

介绍

PHP 8.4 预计将于 2024 年 11 月发布,并将带来一个很酷的新功能:属性钩子。

本文将带你一起了解什么是属性钩子,以及如何在 PHP 8.4 项目中使用。

什么是 PHP 属性钩子?

属性挂钩允许你自定义类属性的 getter 和 setter 逻辑,而无需编写单独的 getter 和 setter 方法。这意味着你可以直接在属性声明中定义逻辑,这样你就可以直接访问属性(如 $user->firstName),而无需记住调用方法(如 $user->getFirstName()$user->setFirstName())。

你可以在以下网址查看此功能的 RFC:https://wiki.php.net/rfc/property-hooks

如果你是一名 Laravel 开发者,在阅读本文时,你可能会注意到钩子看起来与 Laravel 模型中的属性访问器和修改器非常相似。
要了解属性钩子是如何工作的,让我们来看一些示例用法。

"get" 钩子

你可以定义一个 get 钩子,每当尝试访问该属性时都会调用该钩子。

例如,假设你有一个简单的 User 类,它在构造函数中接受 firstNamelastName。你可能希望定义一个将名字和姓氏连接在一起的 fullName 属性。为此,你可以为 fullName 属性定义一个 get 钩子:

readonly class User
{
    public string $fullName {
        get {
            return $this->firstName.' '.$this->lastName;
        }
    }
 
    public function __construct(
        public readonly string $firstName,
        public readonly string $lastName
    ) {
        //
    }
}
 
$user = new User(firstName: 'ash', lastName: 'allen');
 
echo $user->firstName; // ash
echo $user->lastName; // allen
echo $user->fullName; // ash allen

在上例中,可以看到我们为 fullName 属性定义了一个 get 钩子,它返回一个值,该值是通过将 firstNamelastName 属性连接在一起计算出来的。我们也可以使用类似于箭头函数的语法来进一步让代码显得更为整洁:

readonly class User
{
    public string $fullName {
        get =>  $this->firstName.' '.$this->lastName;
    }
 
    public function __construct(
        public readonly string $firstName,
        public readonly string $lastName,
    ) {
        //
    }
}
 
$user = new User(firstName: 'ash', lastName: 'allen');
 
echo $user->firstName; // ash
echo $user->lastName; // allen
echo $user->fullName; // ash allen

类型兼容

值得注意的是,getter 返回的值必须与属性的类型兼容。

如果未启用严格类型,则该值将被类型转换为属性类型。例如,如果从声明为字符串的属性返回一个整数,则该整数将被转换为字符串:

class User
{
    public string $fullName {
        get {
            return 123;
        }
    }
 
    public function __construct(
        public readonly string $firstName,
        public readonly string $lastName,
    ) {
        //
    }
}
 
$user = new User(firstName: 'ash', lastName: 'allen');
 
echo $user->fullName; // "123"

上例中,即使我们将返回的 123 指定为整数,由于该属性是字符串,他也将以字符串 ”123“ 返回。

我们可以像这样在代码顶部添加  declare(strict_types=1); 来启用严格类型检查:

declare(strict_types=1);
 
class User
{
    public string $fullName {
        get {
            return 123;
        }
    }
 
    public function __construct(
        public readonly string $firstName,
        public readonly string $lastName,
    ) {
        //
    }
}

现在,因为返回类型为整数,而属性是字符串,它将会导致抛出错误:

Fatal error: Uncaught TypeError: User::$fullName::get(): Return value must be of type string, int returned

"set" 钩子

PHP 8.4 的属性钩子同样允许你定义一个 set 钩子。每当你尝试设置属性时,都会调用此选项。

你可以为 set 钩子选择两种不同的语法:

  • 显式定义要在属性上设置的值
  • 使用箭头函数返回要在属性上设置的值

让我们来看看这两种方法。假设当在 User 类上设置名字和姓氏时,我们想让它们的首字母为大写:

declare(strict_types=1);
 
class User
{
    public string $firstName {
        // Explicitly set the property value
        set(string $name) {
            $this->firstName = ucfirst($name);
        }
    }
 
    public string $lastName {
        // Use an arrow function and return the value
        // you want to set on the property
        set(string $name) => ucfirst($name);
    }
 
    public function __construct(
        string $firstName,
        string $lastName
    ) {
        $this->firstName = $firstName;
        $this->lastName = $lastName;
    }
}
 
$user = new User(firstName: 'ash', lastName: 'allen');
 
echo $user->firstName; // Ash
echo $user->lastName; // Allen

如上例所示,我们为 firstName 属性定义了一个 set 钩子,在将其设置在属性上之前,先将名称的第一个字母改为大写。同时我们也为 lastName 属性定义了一个 set 钩子,该钩子使用箭头函数返回要在属性上设置的值。

类型兼容

如果属性有类型声明,那么 set 钩子也必需有可兼容的类型设置。以下示例将返回错误,因为 firstNameset 钩子没有类型声明,但属性本身有字符串类型声明:

class User
{
    public string $firstName {
        set($name) => ucfirst($name);
    }
 
    public string $lastName {
        set(string $name) => ucfirst($name);
    }
 
    public function __construct(
        string $firstName,
        string $lastName
    ) {
        $this->firstName = $firstName;
        $this->lastName = $lastName;
    }
}

尝试运行上述代码将导致抛出以下错误:

Fatal error: Type of parameter $name of hook User::$firstName::set must be compatible with property type

将 "get" 和 "set" 钩子一起使用

除了单独使用 getset 钩子外,你也可以在同一属性中同时使用它们。

举一个简单的例子。我们可以想象在 User 类上有一个 fullName 属性。设置属性时,我们会将全名拆分为名字和姓氏。我知道这是一种幼稚的方法,也有更好的解决方案,但这纯粹是为了举例来突出钩子的属性。

代码可能看起来像这样:

declare(strict_types=1);
 
class User
{
    public string $fullName {
        // Dynamically build up the full name from
        // the first and last name
        get => $this->firstName.' '.$this->lastName;
 
        // Split the full name into first and last name and
        // then set them on their respective properties
        set(string $name) {
            $splitName = explode(' ', $name);
            $this->firstName = $splitName[0];
            $this->lastName = $splitName[1];
        }
    }
 
    public string $firstName {
        set(string $name) => $this->firstName = ucfirst($name);
    }
 
    public string $lastName {
        set(string $name) => $this->lastName = ucfirst($name);
    }
 
    public function __construct(string $fullName) {
        $this->fullName = $fullName;
    }
}
 
$user = new User(fullName: 'ash allen');
 
echo $user->firstName; // Ash
echo $user->lastName; // Allen
echo $user->fullName; // Ash Allen

上述的代码中,我们定义了一个具有 getset 钩子的 fullName 属性。get 钩子通过将名字和姓氏连接在一起返回全名。set 钩子将全名拆分为名字和姓氏,并将它们设置在各自的属性上。

你可能还注意到,我们没有为 fullName 属性本身设置值。相反,如果我们需要读取 fullName 属性的值,将调用 get 钩子从名字和姓氏属性构建全名。我这样做是为了强调,你可以拥有一个没有直接设置值的属性,而是根据其他属性计算值。

在提升的属性中使用属性钩子

属性钩子的一个很酷的特性是,你还可以将它们与构造函数提升的属性一起使用。

让我们来看一个不使用提升属性的类的示例,然后看看使用提升属性时会是什么样子。

我们的 User 类可能看起来像这样:

readonly class User
{
    public string $fullName {
        get => $this->firstName.' '.$this->lastName;
    }
 
    public string $firstName {
        set(string $name) => ucfirst($name);
    }
 
    public string $lastName {
        set(string $name) => ucfirst($name);
    }
 
    public function __construct(
        string $firstName,
        string $lastName,
    ) {
        $this->firstName = $firstName;
        $this->lastName = $lastName;
    }
}

我们可以将 firstNamelastName 属性提升到构造器中,在其属性上直接定义 set 逻辑:

readonly class User
{
    public string $fullName {
        get => $this->firstName.' '.$this->lastName;
    }
 
    public function __construct(
        public string $firstName {
            set (string $name) => ucfirst($name);
        },
        public string $lastName {
            set (string $name) => ucfirst($name);
        }
    ) {
        //
    }
}

仅写入钩子属性

如果你用一个 setter 定义了一个钩子属性,而这个 setter 实际上并没有为该属性设置值,那么该属性将是只写的。这意味着你无法读取属性的值,只能对其进行设置。

让我们使用上例中的 User 类,通过删除 get 钩子将 fullName 属性修改为只写:

declare(strict_types=1);
 
class User
{
    public string $fullName {
        // Define a setter that doesn't set a value
        // on the "fullName" property. This will
        // make it a write-only property.
        set(string $name) {
            $splitName = explode(' ', $name);
            $this->firstName = $splitName[0];
            $this->lastName = $splitName[1];
        }
    }
 
    public string $firstName {
        set(string $name) => $this->firstName = ucfirst($name);
    }
 
    public string $lastName {
        set(string $name) => $this->lastName = ucfirst($name);
    }
 
    public function __construct(
        string $fullName,
    ) {
        $this->fullName = $fullName;
    }
}
 
$user = new User('ash allen');
 
echo $user->fullName; // Will trigger an error!

运行上述代码,我们看到,当尝试访问 fullName 属性时,会抛出如下错误:

Fatal error: Uncaught Error: Property User::$fullName is write-only

只读钩子属性

同样,属性也可以是只读的。

例如,假设我们只希望从 firstNamelastName 属性生成 fullName 属性。我们不希望直接设置 fullName 属性。我们可以通过从fullName 属性中删除 set 钩子来实现这一点:

class User
{
    public string $fullName {
        get {
            return $this->firstName.' '.$this->lastName;
        }
    }
 
    public function __construct(
        public readonly string $firstName,
        public readonly string $lastName,
    ) {
        $this->fullName = 'Invalid'; // Will trigger an error!
    }
}

运行上述代码会出现如下错误,因为我们尝试直接设置 fullName 属性:

Uncaught Error: Property User::$fullName is read-only

使用 "readonly" 关键字

即使我们的 PHP 类具有挂钩属性,你仍然可以将其设置为只读。例如,我们可能希望将 User 类设置为只读:

readonly class User
{
    public string $firstName {
        set(string $name) => ucfirst($name);
    }
 
    public string $lastName {
        set(string $name) => ucfirst($name);
    }
 
    public function __construct(
        string $firstName,
        string $lastName,
    ) {
        $this->firstName = $firstName;
        $this->lastName = $lastName;
    }
}

不过,钩子属性不能直接使用 readonly 关键字。比如,下例的类是无效的:

class User
{
    public readonly string $fullName {
        get => $this->firstName.' '.$this->lastName;
    }
 
    public function __construct(
        string $firstName,
        string $lastName,
    ) {
        $this->firstName = $firstName;
        $this->lastName = $lastName;
    }
}

上述代码将抛出如下错误:

Fatal error: Hooked properties cannot be readonly

"PROPERTY" 魔术常量

PHP 8.4 中引入了一个新的魔术常量:__PROPERTY__。该常量客用于在属性钩子中引用属性名。 

比如:

class User
{
    // ...
 
    public string $lastName {
        set(string $name) {
            echo __PROPERTY__; // lastName
            $this->{__PROPERTY__} = ucfirst($name); // Will trigger an error!
        }
    }
 
    public function __construct(
        string $firstName,
        string $lastName,
    ) {
        $this->firstName = $firstName;
        $this->lastName = $lastName;
    }
}

上例代码中,在 lastName 属性 setter 中使用了 __PROPERTY__,将会输出属性名 lastName。同样值得注意的是,试图使用此常量来设置属性值将触发错误:

Fatal error: Uncaught Error: Must not write to virtual property User::$lastName

__PROPERTY__ 魔术常量用例可以在 Github 上查看: https://github.com/Crell/php-rfcs/blob/master/property-hooks/examples.md

接口中的钩子属性

PHP 8.4 也允许在接口中定义可公开访问的钩子属性。如果你希望强制类通过钩子实现特定的属性,这很有用。

下例是使用钩子属性的接口声明:

interface Nameable
{
    // Expects a public gettable 'fullName' property
    public string $fullName { get; }
 
    // Expects a public gettable 'firstName' property
    public string $firstName { get; }
 
    // Expects a public settable 'lastName' property
    public string $lastName { set; }
}

上述的接口,我们定义了实现 Nameable 接口的类必需:

  • fullName 属性至少要能公开获取。这可以通过定义 get 钩子或完全不定义钩子实现。
  • firstName 属性至少要能公开获取。
  • lastName 属性至少要能公开获取。这可以通过定义有 set 钩子的属性或完全不定义钩子实现。但是,如果类是只读的,那么该属性必需有一个 set 钩子:

以下是实现 Nameable 接口的类:

class User implements Nameable
{
    public string $fullName {
        get => $this->firstName.' '.$this->lastName;
    }
 
    public string $firstName {
        set(string $name) => ucfirst($name);
    }
 
    public string $lastName;
 
    public function __construct(
        string $firstName,
        string $lastName,
    ) {
        $this->firstName = $firstName;
        $this->lastName = $lastName;
    }
}

上面的类是有效的,因为 fullName 属性有一个 get 钩子来匹配接口定义。firstName 属性只有一个 set 挂钩,但仍然可以公开访问,因此它满足条件。lastName 属性没有 get 挂钩,但可以公开设置,因此它符合条件。

让我们更新 User 类,为 fullName 属性强制执行 getset 钩子:

interface Nameable
{
    public string $fullName { get; set; }
 
    public string $firstName { get; }
 
    public string $lastName { set; }
}

User 类将不再满足 fullName 属性的条件,因为它没有定义 set 钩子。这将导致抛出以下错误:

Fatal error: Class User contains 1 abstract methods and must therefore be declared abstract or implement the remaining methods (Nameable::$fullName::set)

抽象类中的钩子属性

与接口类似,你也可以在抽象类中定义钩子属性。如果你想提供一个基类来定义子类必须实现的钩子属性,这可能很有用。你还可以在抽象类中定义钩子,并在子类中重写它们。

比如,我们来定义一个 Model 抽象类,它定义了一个子类必需实现的 name 属性:

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

上述的抽象,我们定义了继承 Model 抽象类必需:

  • fullName 属性至少要能公开获取及公开设置。这可以通过定义 getset 钩子或完全不定义钩子实现。我们还为抽象类中的 fullName 属性定义了 get 钩子,所以我们不需要在子类中定义它,但如果需要,可以重写它。
  • firstName 属性至少要能公开获取。这可以通过定义 get 钩子或完全不定义钩子实现
  • lastName 属性至少要能公开获取。这可以通过定义有 set 钩子的属性或完全不定义钩子实现。但是,如果类是只读的,那么该属性必需有一个 set 钩子:

接下来我们将创建一个继承 Model 类的 User 类:

class User extends Model
{
    public string $fullName;
 
    public string $firstName {
        set(string $name) => ucfirst($name);
    }
 
    public string $lastName;
 
    public function __construct(
        string $firstName,
        string $lastName,
    ) {
        $this->firstName = $firstName;
        $this->lastName = $lastName;
    }
}

总结

希望本文能让你深入了解 PHP 8.4 属性钩子的工作原理,以及如何在 PHP 项目中使用它们。

如果这个功能一开始看起来可能有点令人困惑,不用太担心。一旦你开始使用它们,很快就会掌握窍门。

我很高兴看到这个功能将如何在野外使用,我期待着在PHP 8.4发布时在我的项目中使用它。