Property Hooks w PHP 8.4 - get, set, virtual properties i przykłady

Property Hooks w PHP 8.4 - get, set i właściwości obiektówProperty Hooks to jedna z najważniejszych zmian w modelu obiektowym PHP 8.4. Pozwalają przechwycić odczyt lub zapis właściwości bez tworzenia osobnych metod typu getName() i setName(). Właściwość nadal może wyglądać dla użytkownika klasy jak zwykłe $object->name, ale jej odczyt może obliczać wartość, a zapis wykonywać walidację, normalizację albo inną logikę. Nie jest to jednak tylko krótszy zapis getterów i setterów - hooks wprowadzają do PHP właściwości wirtualne, nowe możliwości definiowania kontraktów w interfejsach i kilka nieoczywistych konsekwencji związanych z referencjami, dziedziczeniem oraz serializacją.

Warto więc spojrzeć na property hooks nie jak na składniowy skrót, lecz jak na zmianę sposobu projektowania API klas. W tym artykule przeanalizujemy działanie hooków get i set, różnicę między właściwością backed i virtual, współpracę z asymetryczną widocznością, zachowanie przy dziedziczeniu oraz sytuacje, w których klasyczne metody nadal są lepszym rozwiązaniem.

Czym są Property Hooks w PHP 8.4?

Do PHP 8.3 publiczna właściwość klasy miała zasadniczo standardowe zachowanie. Odczyt zwracał zapisaną wartość, a przypisanie zmieniało ją na nową.

class User
{
    public string $name;
}

$user = new User();
$user->name = "Anna";

echo $user->name;

Jeżeli programista chciał wykonać dodatkową operację przy odczycie lub zapisie, zwykle ukrywał właściwość i tworzył odpowiednie metody.

class User
{
    private string $name;

```
public function getName(): string
{
    return $this->name;
}

public function setName(string $name): void
{
    $this->name = trim($name);
}
```

}

Property Hooks zmieniają ten model. Od PHP 8.4 dodatkową logikę można przypisać bezpośrednio do właściwości.

class User
{
    public string $name {
        get => $this->name;
        set => trim($value);
    }
}

Kod korzystający z klasy nadal wygląda naturalnie:

$user = new User();

$user->name = "  Anna Kowalska  ";

echo $user->name;

Podczas zapisu zostanie jednak wykonany hook set, dzięki czemu do właściwości trafi już wartość po zastosowaniu trim().

PHP 8.4 udostępnia dwa hooki dla niestatycznych właściwości:

  • get - wykonywany podczas odczytu,
  • set - wykonywany podczas zapisu.

Można zastosować oba albo tylko jeden z nich. Property hooks działają zarówno z właściwościami typowanymi, jak i bez jawnie określonego typu.

Hook get i set - podstawowa składnia

Pełna wersja hooka może zawierać blok instrukcji podobnie jak metoda.

class User
{
    public string $email {
        get {
            return $this->email;
        }

```
    set {
        $email = strtolower(trim($value));

        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException(
                "Nieprawidłowy adres e-mail"
            );
        }

        $this->email = $email;
    }
}
```

}

W set wartość przekazywana do właściwości jest domyślnie dostępna w zmiennej $value. Jeśli parametr hooka ma ten sam typ co właściwość, nie trzeba deklarować go ponownie.

Przy prostych operacjach można użyć krótszej składni wyrażeniowej.

class User
{
    public string $name {
        set => trim($value);
    }
}

Wartość zwrócona przez wyrażenie hooka set zostanie zapisana jako wartość właściwości.

Analogicznie może wyglądać prosty get:

class Product
{
    public float $netPrice = 100.00;

```
public float $grossPrice {
    get => $this->netPrice * 1.23;
}
```

}

W tym przykładzie pojawia się jednak istotna różnica: $grossPrice nie przechowuje własnej wartości. Jest ona wyliczana przy każdym odczycie.

Backed property i virtual property - najważniejsza różnica

Property Hooks wprowadzają bardzo ważne rozróżnienie pomiędzy właściwością backed a virtual.

Backed property - właściwość z własnym magazynem wartości

Jeżeli hook odwołuje się bezpośrednio do tej samej właściwości, PHP traktuje ją jako backed property. Oznacza to, że obiekt rzeczywiście przechowuje jej wartość.

class User
{
    public string $name {
        set => trim($value);
    }
}

Hook set przetwarza dane, a wynik jest zapisywany jako backing value właściwości $name.

Możemy także jawnie odwołać się do zapisanej wartości z hooka get:

class User
{
    public string $name {
        get => strtoupper($this->name);
        set => trim($value);
    }
}

Zapis może więc wyglądać tak:

$user->name = "Anna Kowalska";

a odczyt:

echo $user->name;

uruchomi hook get i zwróci przekształconą wartość.

Virtual property - właściwość bez zapisanej wartości

Właściwość wirtualna działa inaczej. Jeżeli hooki nie odwołują się do samej właściwości, PHP nie musi przechowywać dla niej osobnej wartości.

class Person
{
    public string $firstName;
    public string $lastName;

```
public string $fullName {
    get => $this->firstName . " " . $this->lastName;
}
```

}

$fullName istnieje z punktu widzenia API obiektu:

echo $person->fullName;

ale nie posiada własnego miejsca na wartość. Jest obliczane na podstawie $firstName i $lastName. PHP Manual określa takie właściwości jako virtual properties. Nie zajmują one w obiekcie przestrzeni potrzebnej do przechowania osobnej wartości.

Jeżeli właściwość wirtualna ma tylko get, próba zapisu zakończy się błędem:

$person->fullName = "Jan Nowak";

Nie istnieje bowiem operacja set, która mogłaby taki zapis obsłużyć.

Walidacja i normalizacja danych w set

Jednym z najbardziej oczywistych zastosowań Property Hooks jest utrzymywanie niezmienników obiektu. Zamiast pozwalać na zapis dowolnej wartości i sprawdzać ją później, można walidować dane dokładnie w momencie zmiany właściwości.

class Product
{
    public float $price {
        set {
            if ($value < 0) {
                throw new InvalidArgumentException(
                    "Cena nie może być ujemna"
                );
            }

```
        $this->price = round($value, 2);
    }
}
```

}

Kod korzystający z klasy pozostaje prosty:

$product->price = 129.999;

ale obiekt nie pozwoli zapisać wartości sprzecznej z jego regułami.

Hook set dobrze nadaje się między innymi do:

  • walidacji zakresów liczbowych,
  • normalizacji adresów e-mail,
  • usuwania zbędnych odstępów,
  • normalizacji numerów telefonu,
  • konwersji danych wejściowych,
  • wymuszania spójnego formatu wartości.

Nie oznacza to jednak, że każdą logikę biznesową należy przenosić do setterów. Jeżeli zmiana wartości oznacza złożoną operację domenową, metoda o nazwie opisującej tę operację jest zwykle czytelniejsza.

Property Hooks a klasyczne gettery i settery

Property Hooks bardzo mocno zmniejszają potrzebę tworzenia metod, których jedynym zadaniem jest mechaniczne udostępnienie właściwości.

Klasyczny kod:

class Customer
{
    private string $email;

```
public function getEmail(): string
{
    return $this->email;
}

public function setEmail(string $email): void
{
    $this->email = strtolower(trim($email));
}
```

}

można w PHP 8.4 przedstawić znacznie krócej:

class Customer
{
    public string $email {
        set => strtolower(trim($value));
    }
}

Różnica nie sprowadza się tylko do liczby linii. Zmienia się API obiektu.

Operacja Getter / setter Property Hook
Odczyt $user->getEmail() $user->email
Zapis $user->setEmail($email) $user->email = $email
Walidacja w metodzie w set
Wartość obliczana metoda get... virtual property z get
Semantyka wywołania operacja/metoda dostęp do właściwości

Ostatni wiersz jest najważniejszy. Metoda komunikuje wykonanie operacji. Właściwość sugeruje dostęp do stanu obiektu.

Jeżeli odczyt $invoice->total wykonuje prostą kalkulację, API jest naturalne. Jeżeli ten sam odczyt wysyła żądanie HTTP do zewnętrznego systemu, wykonuje ciężkie zapytanie SQL i zapisuje coś w bazie, Property Hook zaczyna ukrywać zbyt dużo zachowania.

Property Hooks a __get() i __set()

PHP od dawna pozwala przechwytywać dostęp do niedostępnych lub nieistniejących właściwości za pomocą magicznych metod __get() i __set().

class User
{
    private array $data = [];

```
public function __get(string $name): mixed
{
    return $this->data[$name] ?? null;
}

public function __set(string $name, mixed $value): void
{
    $this->data[$name] = $value;
}
```

}

Mechanizm jest elastyczny, ale cała logika trafia do jednej metody i opiera się na nazwie przekazanej jako tekst. Dla narzędzi analizujących kod właściwości obsługiwane w ten sposób są również mniej jawne.

Property Hooks deklarują zachowanie bezpośrednio przy konkretnej właściwości:

class User
{
    public string $email {
        set => strtolower(trim($value));
    }

```
public string $displayName {
    get => strtoupper($this->email);
}
```

}

Silnik PHP, IDE i narzędzia wykorzystujące Reflection mają więc do czynienia z rzeczywistymi zadeklarowanymi właściwościami, a nie z umowną strukturą ukrytą za jedną metodą magiczną.

Nie oznacza to, że __get() i __set() tracą sens. Nadal są przydatne przy prawdziwie dynamicznych strukturach, proxy, adapterach czy warstwach kompatybilności. Dla z góry znanego zestawu właściwości Property Hooks są jednak znacznie bardziej jawne.

Asymetryczna widoczność i Property Hooks

PHP 8.4 wprowadziło równolegle drugą ważną zmianę w modelu właściwości - asymmetric property visibility. Pozwala ona określić inną widoczność dla odczytu i zapisu.

Przykładowo:

class User
{
    public private(set) string $id;
}

Właściwość może być publicznie odczytywana, ale zapis z zewnątrz klasy zostanie zablokowany.

Mechanizm można połączyć z Property Hooks:

class Account
{
    public private(set) string $email {
        set => strtolower(trim($value));
    }

```
public function changeEmail(string $email): void
{
    $this->email = $email;
}
```

}

Kod zewnętrzny może wykonać:

echo $account->email;

ale nie może bezpośrednio zrobić:

$account->email = "
 Adres poczty elektronicznej jest chroniony przed robotami spamującymi. W przeglądarce musi być włączona obsługa JavaScript, żeby go zobaczyć.
 ";

Zmiana musi przejść przez metodę changeEmail(), a sam zapis wykonywany wewnątrz klasy nadal uruchomi hook set.

To interesujące połączenie: visibility decyduje kto może wykonać operację, natomiast hook określa co wydarzy się podczas tej operacji.

W PHP 8.4 osobną widoczność set można stosować tylko dla właściwości typowanych, a widoczność zapisu nie może być szersza niż widoczność odczytu. Poprawne jest więc:

public protected(set) string $name;

natomiast konstrukcja, w której publiczny zapis próbowałby rozszerzyć bardziej restrykcyjny odczyt, jest niedozwolona.

Warto również pamiętać, że właściwość z private(set) jest automatycznie traktowana jako final z punktu widzenia ponownego deklarowania jej w klasie potomnej.

Właściwości w interfejsach PHP 8.4

Property Hooks pociągnęły za sobą jeszcze jedną zmianę, która może mieć duże znaczenie dla projektowania bibliotek. Od PHP 8.4 interfejs może deklarować właściwości i określać, czy mają być publicznie odczytywalne, zapisywalne czy obsługiwać oba kierunki.

interface Named
{
    public string $name { get; }
}

Interfejs nie mówi w tym przypadku, w jaki sposób wartość ma być przechowywana. Klasa może spełnić kontrakt za pomocą zwykłej publicznej właściwości:

class User implements Named
{
    public string $name;
}

albo za pomocą właściwości wirtualnej:

class Person implements Named
{
    public string $firstName;
    public string $lastName;

```
public string $name {
    get => $this->firstName . " " . $this->lastName;
}
```

}

Z punktu widzenia kodu korzystającego z interfejsu oba obiekty spełniają ten sam kontrakt:

function printName(Named $object): void
{
    echo $object->name;
}

Można też wymagać możliwości zapisu:

interface EditableName
{
    public string $name { get; set; }
}

To istotna różnica względem wcześniejszych wersji PHP. Kontrakt obiektu nie musi już opisywać jego publicznego stanu wyłącznie za pomocą metod.

Dziedziczenie i final hooks

Hooki uczestniczą również w dziedziczeniu. Klasa potomna może dodać hook do odziedziczonej właściwości lub zastąpić wybrany hook własną implementacją.

class User
{
    public string $username;
}

class NormalizedUser extends User
{
public string $username {
set => strtolower(trim($value));
}
}

Możliwe jest również zablokowanie dziedziczenia konkretnego hooka za pomocą final.

class User
{
    public string $username {
        final set => strtolower(trim($value));
    }
}

Klasa potomna może nadal w określonych warunkach dodać własne zachowanie get, ale nie może zastąpić finalnego set.

PHP pozwala także jawnie odwołać się w hooku do zachowania właściwości z klasy nadrzędnej za pomocą składni parent::$property::get() lub parent::$property::set(). Mechanizm przypomina więc pod tym względem wywoływanie metod klasy bazowej.

Referencje i tablice - ważna pułapka

Property Hooks nie są całkowicie przezroczyste dla wszystkich operacji wykonywanych na właściwościach. Szczególnie ważnym przypadkiem są referencje i pośrednia modyfikacja wartości.

Wyobraźmy sobie właściwość tablicową:

class Cart
{
    public array $items {
        set => array_values($value);
    }
}

Nadpisanie całej tablicy jest zwykłą operacją set:

$cart->items = ["A", "B", "C"];

Inaczej wygląda modyfikowanie pojedynczego elementu:

$cart->items[] = "D";

Taka operacja wymaga pośredniego dostępu do przechowywanej wartości. PHP Manual zwraca uwagę, że dostęp przez referencję mógłby ominąć set, dlatego istnieją w tym obszarze dodatkowe ograniczenia.

Hook get może zostać zadeklarowany jako zwracający referencję:

public array $items {
    &get => $this->items;
}

Nie można jednak bezrefleksyjnie łączyć takiego podejścia z hookiem set dla backed property, ponieważ modyfikacja otrzymanej referencji pozwalałaby zmienić wartość bez uruchomienia logiki zapisu.

To dobry przykład sytuacji, w której Property Hooks nie są tylko syntactic sugar. Wpływają na semantykę dostępu do właściwości i dlatego kod intensywnie wykorzystujący referencje albo modyfikowanie elementów tablic wymaga szczególnej uwagi.

Serializacja, JSON i Reflection

Kolejny mniej oczywisty aspekt dotyczy serializacji. Nie wszystkie mechanizmy PHP traktują hooki tak samo.

Według PHP Manual między innymi serialize() oraz var_dump() korzystają z surowej backing value, natomiast json_encode(), var_export() i get_object_vars() odczytują właściwość przez hook get.

Może to prowadzić do sytuacji, w której wartość widoczna w JSON różni się od surowej wartości zapisanej w obiekcie.

class Product
{
    public float $price {
        get => round($this->price, 2);
    }
}

Jeżeli hook get modyfikuje sposób prezentacji danych, trzeba więc świadomie sprawdzić, z którego mechanizmu serializacji korzysta aplikacja.

Property Hooks zostały również uwzględnione w Reflection API. W PHP 8.4 ReflectionProperty udostępnia między innymi:

  • hasHooks(),
  • hasHook(),
  • getHooks(),
  • getHook(),
  • isVirtual().

Można więc programowo sprawdzić, czy dana właściwość posiada hooki:

$reflection = new ReflectionProperty(
    User::class,
    "name"
);

if ($reflection->hasHooks()) {
foreach ($reflection->getHooks() as $type => $hook) {
echo $type . ": " . $hook->getName();
}
}

Ma to szczególne znaczenie dla frameworków, hydratorów, serializerów, bibliotek ORM oraz narzędzi generujących kod na podstawie struktury klas.

Kiedy używać Property Hooks, a kiedy metod?

Property Hooks mogą bardzo mocno uprościć klasy, ale nie powinny stać się miejscem do ukrywania dowolnej logiki. Najlepiej sprawdzają się wtedy, gdy wykonywana operacja nadal semantycznie przypomina odczyt lub zmianę właściwości.

Przypadek Property Hook Metoda
Trimowanie napisu Tak Zwykle zbędna
Normalizacja e-maila Tak Możliwa
Prosta walidacja zakresu Tak Możliwa
Wartość wyliczana z innych pól Tak - get Również poprawna
Zmiana statusu zamówienia Raczej nie Tak
Wysłanie wiadomości e-mail Nie Tak
Operacja bazodanowa Najczęściej nie Tak
Zewnętrzne wywołanie HTTP Nie Tak

Przykładowo:

$order->status = "paid";

wygląda jak zwykłe przypisanie. Jeżeli jednak w hooku set ukryjemy aktualizację magazynu, utworzenie faktury, wysłanie e-maila i wywołanie zewnętrznego API, zachowanie kodu przestaje być oczywiste.

Znacznie czytelniejsza jest wtedy metoda:

$order->markAsPaid();

Nazwa sygnalizuje, że wykonywana jest operacja domenowa, a nie zwykła zmiana pola.

Property Hooks nie oznaczają więc końca getterów i setterów ani metod jako takich. Usuwają przede wszystkim dużą część boilerplate'u, który w wielu klasach istniał tylko dlatego, że programista chciał zachować możliwość kontrolowania dostępu do właściwości.

Największa zmiana w PHP 8.4 polega na tym, że publiczna właściwość przestała oznaczać "bezpośredni dostęp do pola". Może być pełnoprawnym elementem API obiektu, z własną logiką odczytu i zapisu, kontrolą widoczności, kontraktem interfejsu i wsparciem Reflection. To daje PHP bardziej ekspresyjny model obiektowy, ale jednocześnie zwiększa odpowiedzialność programisty za to, aby pozornie prosty dostęp do właściwości nie ukrywał zaskakująco kosztownych lub nieprzewidywalnych operacji.

Komentarze