使用属性钩子的一年

PHP教程 2025-08-20

差不多就在一年前,属性钩子(property hooks)被合并到了 PHP 内核中。如果你一直关注我在 Tempest 上的工作就会知道,此事一出,我们几乎立刻开始为 Tempest 代码库适配 PHP 8.4,并在所有可能的地方引入属性钩子。我自认为算是“早期 adopters(采用者)”,现在想回头看看过去一年里,我是如何使用属性钩子的。

我意识到很多人甚至还没开始使用 PHP 8.4,更不用说属性钩子了。所以现在正是时候来熟悉这个——我认为是——PHP 这十年来最具影响力的特性之一。

什么是属性钩子?

那么,属性钩子:它源自 PHP 历史上最庞大、最详尽的 RFC(请求评论)之一。要通读整个 RFC 得花不少功夫,我来为你总结一下。属性钩子允许你“挂钩到”属性的读取(get)和赋值(set)操作中。可以把它理解为魔术方法 __get() 和 __set(),但专门针对单个属性:

final class Book{    public function __construct(
        private array $authors,    ) {}    public string $credits {        get {            return implode(', ', array_map(
                fn (Author $author) => $author->name, 
                $this->authors,
            ));
        }
    }    
    public Author $mainAuthor {        set (Author $mainAuthor) {            $this->authors[] = $mainAuthor;            $this->mainAuthor = $mainAuthor;
        }        
        get => $this->mainAuthor;
    }
}

属性钩子在减少样板式的 getter 和 setter 方法方面效果显著,而且这种简化不仅体现在类内部,从类外部使用时也是如此。

原本你需要这样写:

$oldMainAuthor = $book->getMainAuthor();$book->setMainAuthor($newMainAuthor);echo $book->getCredits();

有了属性钩子后,你可以这样写:

$oldMainAuthor = $book->mainAuthor;$book->mainAuthor = $newMainAuthor;echo $book->credits;

尤其在模型(models)、值对象(value objects)和数据对象(data objects)的场景下,属性钩子的意义非常大,它能让类的公共 API 更简洁流畅。

我之前提到过,属性钩子的 RFC 内容非常多,其中包含很多细节,比如简写语法:

final class Book{    public string $credits {        get => implode(', ', array_map(
            fn (Author $author) => $author->name, 
            $this->authors,
        ));
    }    // …}

还有虚拟属性——即只支持读取(get)操作的属性:

final class Book{    public Author $mainAuthor {        get => $this->authors[0];
    }
}

关于引用(references)、继承(inheritance)和类型协变/逆变规则(type variance rules),也有很多值得了解的内容。

但属性钩子最重要、影响力远超其他功能的一点是:它们可以在接口(interface)中定义。听起来可能有点奇怪——“接口中的属性”?但实际上这非常合理,我来给你演示一下。

接口中的属性

属性钩子本质上是常规 getter 和 setter 方法的简写形式——而方法本就可以在接口中定义。从这个角度来看,属性钩子能在接口中定义是顺理成章的;否则,只要你想使用接口(我认为这是个好习惯),就仍需编写常规的 getter 和 setter。所以,不用再像这样写:

interface Book{    public function getChapters(): array;    public function getMainAuthor(): Author;    public function getCredits(): string;
}

你可以这样写:

interface Book{    public array $chapters { get; }    public Author $mainAuthor { get; }    public string $credits { get; }
}

任何实现了 Book 接口的类,现在都必须拥有这些可公开读取的属性。当然,你仍然可以将这些属性设为 readonly(只读)或仅内部可写(private(set))。这样既保留了封装对象的安全性,又省去了大量样板代码:

final class Ebook implements Book{    private(set) array $chapters;    public readonly Author $mainAuthor;    public readonly string $credits;
}

作为对比,如果想通过常规 getter 和 setter 实现同样的封装安全性,你的类会变成这样:

final class Ebook implements Book{    private array $chapters;    private Author $mainAuthor;    private string $credits;    
    public function getChapters(): array
    {        return $this->chapters;
    }    
    private function addChapter(Chapter $chapter): void
    {        $this->chapters[] = $chapter;
    }    
    public function getMainAuthor(): Author
    {        return $this->mainAuthor;
    }    
    public function getCredits(): string
    {        return $this->credits;
    }
}

说实话,光写这个例子我就已经觉得无聊了。我甚至无法想象,就在一年前,我们还不得不一直做这种事。

除了“属性钩子本质是伪装的方法”这一点外,它们能出现在接口中的另一个原因是:数据对象和值对象的需求。这些年来,PHP 一直在添加各种特性,让“仅通过属性表示数据的类”更容易编写:类型化属性(typed properties)、只读属性与只读类(readonly properties and classes)、构造函数属性提升(constructor property promotion)。

final readonly class GenericRequest{    public function __construct(
        public Method $method,        public string $uri,        public array $headers,        // …
    ) {}
}

然而,“属性无法包含在接口中”这一限制,让上述所有新增特性的作用都大打折扣——至少对于像我这样“偏好面向接口编程”的人来说是如此。所以可以说,属性钩子的加入,也立刻为 PHP 中其他许多现有特性赋予了更强的能力。这就是为什么我认为它是过去十年里最具影响力的变更。

一年后的使用感受

那么,我使用属性钩子的体验如何呢?我在接口中大量使用它们。即便这个 RFC 只包含“接口中的属性”这一个功能,我也会很满意:

interface Database{    public DatabaseDialect $dialect { get; }    // …}
interface DatabaseConfig extends HasTag{    public string $dsn { get; }    
    // …}
interface Request{    public Method $method { get; }    public string $uri { get; }    
    // …}

不过,“能挂钩到属性操作”这个功能本身也很实用。我确实经常使用 get 钩子。虚拟属性在某些场景下很有用,尤其是在模型和数据对象中:

final class PageVisited implements ShouldBeStored, HasCreatedAtDate{    // …
    
    public DateTimeImmutable $createdAt {        get => $this->visitedAt;
    }
}

我使用 set 钩子的场景并不多。事实上,在整个 Tempest 代码库中,我们只用过一次 set 钩子:

final class TestingCache implements Cache{    private Cache $cache;    
    public bool $enabled {        get => $this->cache->enabled;        set => $this->cache->enabled = $value;
    }    
    // …}

对于像“缓存测试包装器”这样的“代理对象”,set 钩子可能会有用,但说实话,这类场景真的不多。

属性钩子的语法本身……还算可以。我不太喜欢“同一件事有多种写法”:既有简写形式,set 钩子又有隐式的 $value 变量——这对我来说有点混乱。我非常支持“ opinion-driven design( opinion 驱动的设计)”,所以即便只有一种写法来定义钩子,我也完全能接受,不过这只是个小挑剔。

我还发现自己会在构造函数之后编写属性钩子。一开始会觉得有点奇怪,因为它们本质是属性;但当你把它们看作“伪装的方法”时,就会觉得这很合理了。

final class WelcomeEmail implements Email, HasAttachments{    public function __construct(
        private readonly User $user,    ) {}    public Envelope $envelope {        get => new Envelope(
            subject: 'Welcome',
            to: $this->user->email,
        );
    }    public string|View $html {        get => view('welcome.view.php', user: $this->user);
    }    
    public array $attachments {        get => [            Attachment::fromFilesystem(__DIR__ . '/welcome.pdf')
        ];
    }
}

总而言之,属性钩子真的很棒,它彻底改变了我编写 PHP 代码的方式。如果你想和我分享你对属性钩子的看法,可以在 Discord 上找到我!