属性钩子,在某些其他语言中也称为“属性访问器”,是一种拦截和覆盖属性读写行为的方法。此功能有两个用途
非静态属性上有两个可用的钩子:get
和 set
。它们分别允许覆盖属性的读写行为。钩子可用于类型化和非类型化属性。
属性可以是“支持的”或“虚拟的”。支持的属性是指实际存储值的属性。任何没有钩子的属性都是支持的。虚拟属性是指具有钩子且这些钩子不与属性本身交互的属性。在这种情况下,钩子实际上与方法相同,并且对象不使用任何空间来存储该属性的值。
属性钩子与 readonly
属性不兼容。如果需要除了更改其行为之外还需要限制对 get
或 set
操作的访问,请使用非对称属性可见性。
声明钩子的一般语法如下所示。
示例 #1 属性钩子(完整版本)
<?php
class Example
{
private bool $modified = false;
public string $foo = 'default value' {
get {
if ($this->modified) {
return $this->foo . ' (modified)';
}
return $this->foo;
}
set(string $value) {
$this->foo = strtolower($value);
$this->modified = true;
}
}
}
$example = new Example();
$example->foo = 'changed';
print $example->foo;
?>
$foo 属性以 {}
结尾,而不是分号。这表示存在钩子。定义了 get
和 set
钩子,尽管只定义其中一个或另一个也是允许的。这两个钩子都有一个主体,由 {}
表示,主体可能包含任意代码。
set
钩子还允许使用与方法相同的语法指定传入值的类型和名称。该类型必须与属性的类型相同,或者逆变(更宽)于它。例如,类型为 string 的属性可以有一个接受 string|Stringable 的 set
钩子,但不能有一个只接受 array 的钩子。
至少一个钩子引用了 $this->foo
,即属性本身。这意味着该属性将被“支持”。当调用 $example->foo = 'changed'
时,提供的字符串将首先转换为小写,然后保存到支持值中。从属性读取时,之前保存的值可能会根据条件附加其他文本。
还有一些简写语法变体来处理常见情况。
如果 get
钩子是一个单一表达式,则可以省略 {}
并替换为箭头表达式。
示例 #2 属性 get 表达式
此示例等效于上一个示例。
<?php
class Example
{
private bool $modified = false;
public string $foo = 'default value' {
get => $this->foo . ($this->modified ? ' (modified)' : '');
set(string $value) {
$this->foo = strtolower($value);
$this->modified = true;
}
}
}
?>
如果 set
钩子的参数类型与属性类型相同(这很常见),则可以省略它。在这种情况下,要设置的值将自动被赋予名称 $value。
示例 #3 属性 set 默认值
此示例等效于上一个示例。
<?php
class Example
{
private bool $modified = false;
public string $foo = 'default value' {
get => $this->foo . ($this->modified ? ' (modified)' : '');
set {
$this->foo = strtolower($value);
$this->modified = true;
}
}
}
?>
如果 set
钩子只设置传入值的修改版本,那么它也可以简化为箭头表达式。表达式计算得到的值将被设置到后备值上。
示例 #4 属性设置表达式
<?php
class Example
{
public string $foo = 'default value' {
get => $this->foo . ($this->modified ? ' (modified)' : '');
set => strtolower($value);
}
}
?>
此示例与前一个示例并不完全等价,因为它也没有修改 $this->modified
。如果在 set 钩子体中需要多个语句,请使用大括号版本。
属性可以根据需要实现零个、一个或两个钩子。所有简写版本都是相互独立的。也就是说,使用短 get 与长 set,或使用短 set 与显式类型,等等都是有效的。
在后备属性上,省略 get
或 set
钩子意味着将使用默认的读或写行为。
虚拟属性是没有后备值的属性。如果属性的 get
或 set
钩子都不使用精确语法引用属性本身,则该属性为虚拟属性。也就是说,名为 $foo
的属性,其钩子包含 $this->foo
将被后备。但以下不是后备属性,并且会出错
示例 #5 无效的虚拟属性
<?php
class Example
{
public string $foo {
get {
$temp = __PROPERTY__;
return $this->$temp; // 没有引用 $this->foo,因此它不算。
}
}
}
?>
对于虚拟属性,如果省略了钩子,则该操作不存在,尝试使用它将产生错误。虚拟属性在对象中不占用任何内存空间。虚拟属性适用于“派生”属性,例如两个其他属性的组合。
示例 #6 虚拟属性
<?php
readonly class Rectangle
{
// 一个虚拟属性。
public int $area {
get => $this->h * $this->w;
}
public function __construct(public int $h, public int $w) {}
}
$s = new Rectangle(4, 5);
print $s->area; // 打印 20
$s->area = 30; // 错误,因为没有定义 set 操作。
?>
在虚拟属性上同时定义 get
和 set
钩子也是允许的。
所有钩子都在被修改的对象的作用域内操作。这意味着它们可以访问对象的全部公共、私有或受保护的方法,以及任何公共、私有或受保护的属性,包括可能具有自身属性钩子的属性。从钩子内部访问另一个属性不会绕过该属性上定义的钩子。
这方面最显著的影响是,如果需要,非平凡的钩子可以子调用任意复杂的方法。
示例 #7 从钩子中调用方法
<?php
class Person {
public string $phone {
set => $this->sanitizePhone($value);
}
private function sanitizePhone(string $value): string {
$value = ltrim($value, '+');
$value = ltrim($value, '1');
if (!preg_match('/\d\d\d\-\d\d\d\-\d\d\d\d/', $value)) {
throw new \InvalidArgumentException();
}
return $value;
}
}
?>
由于钩子的存在会拦截属性的读写过程,因此在获取属性的引用或间接修改(例如 $this->arrayProp['key'] = 'value';
)时会导致问题。这是因为任何尝试通过引用修改值的尝试都会绕过 set 钩子(如果已定义)。
在需要获取具有已定义钩子的属性的引用的罕见情况下,可以使用 &
前缀 get
钩子以使其通过引用返回。在同一属性上同时定义 get
和 &get
是语法错误。
在后备属性上同时定义 &get
和 set
钩子是不允许的。如上所述,写入通过引用返回的值将绕过 set
钩子。在虚拟属性上,这两个钩子之间没有必要共享的公共值,因此允许同时定义。
写入数组属性的索引也涉及隐式引用。因此,如果仅定义了 &get
钩子,则允许写入具有已定义钩子的后备数组属性。在虚拟属性上,写入从 get
或 &get
返回的数组是合法的,但它对对象是否有任何影响取决于钩子的实现。
覆盖整个数组属性是可以的,并且与任何其他属性的行为相同。只有处理数组的元素需要特别注意。
钩子也可以声明为 final,在这种情况下,它们不能被覆盖。
示例 #8 最终钩子
<?php
class User
{
public string $username {
final set => strtolower($value);
}
}
class Manager extends User
{
public string $username {
// 这是允许的
get => strtoupper($this->username);
// 但这不是允许的,因为 set 在父类中是 final。
set => strtoupper($value);
}
}
?>
属性也可以声明为 final。final 属性不能以任何方式被子类重新声明,这排除了更改钩子或扩大其访问权限。
在声明为 final 的属性上声明钩子为 final 是多余的,并且会被静默忽略。这与 final 方法的行为相同。
子类可以通过重新定义属性及其想要覆盖的钩子来定义或重新定义属性上的各个钩子。子类还可以向没有钩子的属性添加钩子。这与钩子是方法的情况基本相同。
示例 #9 钩子继承
<?php
class Point
{
public int $x;
public int $y;
}
class PositivePoint extends Point
{
public int $x {
set {
if ($value < 0) {
throw new \InvalidArgumentException('Too small');
}
$this->x = $value;
}
}
}
?>
每个钩子都独立于其他钩子覆盖父类的实现。如果子类添加了钩子,则属性上设置的任何默认值都会被移除,并且必须重新声明。这与钩子无关的属性的继承方式一致。
子类中的钩子可以使用 parent::$prop
关键字后跟所需的钩子来访问父类的属性。例如,parent::$propName::get()
。可以将其解读为“访问在父类中定义的 prop,然后运行其 get 操作”(或 set 操作,视情况而定)。
如果不以这种方式访问,则父类的钩子将被忽略。此行为与所有方法的工作方式一致。这也提供了一种访问父类的存储(如果有)的方法。如果父属性上没有钩子,则将使用其默认的 get/set 行为。钩子不得访问其自身属性上除其自身父级以外的任何其他钩子。
上面的示例可以更有效地重写如下。
示例 #10 父级钩子访问(set)
<?php
class Point
{
public int $x;
public int $y;
}
class PositivePoint extends Point
{
public int $x {
set {
if ($value < 0) {
throw new \InvalidArgumentException('Too small');
}
$this->x = $value;
}
}
}
?>
仅覆盖 get 钩子的示例可以是
示例 #11 父级钩子访问(get)
<?php
class Strings
{
public string $val;
}
class CaseFoldingStrings extends Strings
{
public bool $uppercase = true;
public string $val {
get => $this->uppercase
? strtoupper(parent::$val::get())
: strtolower(parent::$val::get());
}
}
?>
PHP 有多种不同的方法可以序列化对象,无论是供公众使用还是用于调试目的。钩子的行为因用例而异。在某些情况下,将使用属性的原始备份值,绕过任何钩子。在其他情况下,将像任何其他正常的读写操作一样,“通过”钩子读取或写入属性。