PHP Conference Japan 2024

屬性鉤子

屬性鉤子,在其他某些語言中也稱為「屬性存取器」,是一種攔截並覆寫屬性的讀取和寫入行為的方式。此功能有兩個用途

  1. 它允許直接使用屬性,而無需使用 get 和 set 方法,同時保留未來新增額外行為的選項。即使不使用鉤子,這也會使大多數樣板 get/set 方法變得不必要。
  2. 它允許描述物件的屬性,而無需直接儲存值。

在非靜態屬性上有兩個可用的鉤子: getset。它們允許覆寫屬性的讀取和寫入行為。鉤子適用於已宣告型別和未宣告型別的屬性。

屬性可以是「支援的」或「虛擬的」。支援的屬性是實際儲存值的屬性。任何沒有鉤子的屬性都是支援的。虛擬屬性是具有鉤子的屬性,而這些鉤子不會與屬性本身互動。在這種情況下,鉤子實際上與方法相同,並且物件不會使用任何空間來儲存該屬性的值。

屬性鉤子與 readonly 屬性不相容。如果除了變更其行為之外,還需要限制對 getset 操作的存取,請使用非對稱屬性可見度

基本鉤子語法

宣告鉤子的一般語法如下。

範例 #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 屬性以 {} 結尾,而不是分號。這表示存在鉤子。已定義 getset 鉤子,但允許只定義其中一個。這兩個鉤子都有一個主體,以 {} 表示,其中可能包含任意程式碼。

set 鉤子還允許使用與方法相同的語法來指定傳入值的型別和名稱。型別必須與屬性的型別相同,或者逆變(更寬)。例如,string 型別的屬性可能具有接受 string|Stringableset 鉤子,但不能是只接受 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 等等都是有效的。

在後端屬性上,省略 getset hook 表示將使用預設的讀取或寫入行為。

虛擬屬性

虛擬屬性是沒有後端值的屬性。如果屬性的 getset 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 操作。
?>

也允許在虛擬屬性上定義 getset 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 是一種語法錯誤。

不允許在後端屬性上同時定義 &getset hook。如上所述,寫入由參考回傳的值會繞過 set hook。在虛擬屬性上,兩個 hook 之間沒有必要的共同值,因此允許同時定義兩者。

寫入陣列屬性的索引也涉及隱式參考。因此,只有在定義了 &get hook 的情況下,才允許寫入已定義 hook 的後端陣列屬性。在虛擬屬性上,寫入從 get&get 回傳的陣列是合法的,但這是否對物件產生任何影響取決於 hook 的實作。

覆寫整個陣列屬性沒問題,其行為與任何其他屬性相同。只有處理陣列的元素才需要特別小心。

繼承

最終 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

子類別中的 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 讀取或寫入屬性。

新增筆記

使用者貢獻的筆記

此頁面沒有使用者貢獻的筆記。
To Top