屬性鉤子,在其他某些語言中也稱為「屬性存取器」,是一種攔截並覆寫屬性的讀取和寫入行為的方式。此功能有兩個用途
在非靜態屬性上有兩個可用的鉤子: get
和 set
。它們允許覆寫屬性的讀取和寫入行為。鉤子適用於已宣告型別和未宣告型別的屬性。
屬性可以是「支援的」或「虛擬的」。支援的屬性是實際儲存值的屬性。任何沒有鉤子的屬性都是支援的。虛擬屬性是具有鉤子的屬性,而這些鉤子不會與屬性本身互動。在這種情況下,鉤子實際上與方法相同,並且物件不會使用任何空間來儲存該屬性的值。
屬性鉤子與 readonly
屬性不相容。如果除了變更其行為之外,還需要限制對 get
或 set
操作的存取,請使用非對稱屬性可見度。
宣告鉤子的一般語法如下。
範例 #1 屬性鉤子(完整版本)
<?php
class Example
{
private bool $modified = false;
public string $foo = '預設值' {
get {
if ($this->modified) {
return $this->foo . ' (已修改)';
}
return $this->foo;
}
set(string $value) {
$this->foo = strtolower($value);
$this->modified = true;
}
}
}
$example = new Example();
$example->foo = '已變更';
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 = '預設值' {
get => $this->foo . ($this->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 = '預設值' {
get => $this->foo . ($this->modified ? ' (已修改)' : '');
set {
$this->foo = strtolower($value);
$this->modified = true;
}
}
}
?>
如果 set
hook 只是設定傳入值的修改版本,那麼它也可以簡化為箭頭運算式。運算式評估的值將設定在後端值上。
範例 #4 屬性設定運算式
<?php
class Example
{
public string $foo = 'default value' {
get => $this->foo . ($this->modified ? ' (modified)' : '');
set => strtolower($value);
}
}
?>
這個範例與之前的範例不太相同,因為它沒有修改 $this->modified
。如果 set hook 主體中需要多個語句,請使用大括號版本。
屬性可以根據情況需要實作零個、一個或兩個 hook。所有速記版本都是相互獨立的。也就是說,使用帶有長 set 的短 get,或帶有明確類型的短 set 等等都是有效的。
在後端屬性上,省略 get
或 set
hook 表示將使用預設的讀取或寫入行為。
虛擬屬性是沒有後端值的屬性。如果屬性的 get
或 set
hook 都沒有使用精確的語法參考該屬性本身,則該屬性是虛擬的。也就是說,名稱為 $foo
的屬性,其 hook 包含 $this->foo
,將被後端化。但以下不是後端屬性,並且會出錯
範例 #5 無效的虛擬屬性
<?php
class Example
{
public string $foo {
get {
$temp = __PROPERTY__;
return $this->$temp; // 沒有參考 $this->foo,所以不算數。
}
}
}
?>
對於虛擬屬性,如果省略 hook,則該操作不存在,嘗試使用它會產生錯誤。虛擬屬性不會在物件中佔用記憶體空間。虛擬屬性適用於「衍生」屬性,例如那些是其他兩個屬性組合的屬性。
範例 #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
hook。
所有 hook 都在被修改的物件的範圍內運作。這表示它們可以存取物件的所有 public、private 或 protected 方法,以及任何 public、private 或 protected 屬性,包括可能具有自己的屬性 hook 的屬性。從 hook 內部存取另一個屬性不會繞過該屬性上定義的 hook。
最值得注意的含義是,如果需要,非簡單的 hook 可以子呼叫到任意複雜的方法。
範例 #7 從 hook 呼叫方法
<?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;
}
}
?>
由於 hook 的存在會攔截屬性的讀寫過程,因此在取得屬性的參考或使用間接修改時會產生問題,例如 $this->arrayProp['key'] = 'value';
。這是因為任何嘗試通過參考修改值的操作都會繞過 set hook(如果已定義)。
在極少數情況下,如果需要取得已定義 hook 的屬性的參考,則 get
hook 可以加上 &
前綴,使其透過參考回傳。在同一個屬性上定義 get
和 &get
是一種語法錯誤。
不允許在後端屬性上同時定義 &get
和 set
hook。如上所述,寫入由參考回傳的值會繞過 set
hook。在虛擬屬性上,兩個 hook 之間沒有必要的共同值,因此允許同時定義兩者。
寫入陣列屬性的索引也涉及隱式參考。因此,只有在定義了 &get
hook 的情況下,才允許寫入已定義 hook 的後端陣列屬性。在虛擬屬性上,寫入從 get
或 &get
回傳的陣列是合法的,但這是否對物件產生任何影響取決於 hook 的實作。
覆寫整個陣列屬性沒問題,其行為與任何其他屬性相同。只有處理陣列的元素才需要特別小心。
hook 也可以宣告為 final,在這種情況下,它們不能被覆寫。
範例 #8 最終 hook
<?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 屬性不能以任何方式被子類別重新宣告,這會排除更改 hook 或擴大其存取權限。
在宣告為 final 的屬性上宣告 hook 為 final 是多餘的,並且會被靜默忽略。這與 final 方法的行為相同。
子類別可以透過重新定義屬性以及它想要覆寫的 hook 來定義或重新定義屬性上的個別 hook。子類別也可以為沒有 hook 的屬性新增 hook。這基本上與 hook 是方法時的情況相同。
範例 #9 Hook 繼承
<?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;
}
}
}
?>
每個 Hook 都會獨立覆寫父類別的實作。如果子類別新增 Hook,則任何在屬性上設定的預設值都會被移除,並且必須重新宣告。這與繼承在沒有 Hook 的屬性上的運作方式一致。
子類別中的 Hook 可以使用 parent::$prop
關鍵字,後接所需的 Hook 來存取父類別的屬性。例如,parent::$propName::get()
。它可以被理解為「存取父類別上定義的 prop,然後執行其 get 操作」(或適當的 set 操作)。
如果沒有以這種方式存取,父類別的 Hook 會被忽略。此行為與所有方法運作的方式一致。這也提供了一種存取父類別儲存的方式(如果有的話)。如果父類別屬性上沒有 Hook,則會使用其預設的 get/set 行為。Hook 不能存取任何其他 Hook,只能存取自身屬性上的父類別 Hook。
上面的範例可以更有效率地改寫如下。
範例 #10 父類別 Hook 存取 (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 Hook 的範例可能是
範例 #11 父類別 Hook 存取 (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 有許多不同的方式可以序列化物件,無論是為了公開使用還是為了除錯目的。Hook 的行為會根據使用案例而異。在某些情況下,將使用屬性的原始後備值,繞過任何 Hook。在其他情況下,將像任何其他正常的讀/寫操作一樣「通過」Hook 讀取或寫入屬性。