差不多就在一年前,属性钩子(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 上找到我!