Każdorazowo kolejne wydania języka PHP wprowadzają szereg większych i mniejszych zmian. Zazwyczaj nie są to rewolucje, bo PHP to ugruntowana i stabilna technologia. Zawsze jednak pojawia się nowość, która budzi dużo emocji i o której mówi się najwięcej. Taka flagowa funkcjonalność. W wersji PHP 8.4 to Property Hooks odegrają tę główną rolę.
Co do tej zmiany, ogólny wydźwięk na mieście jest pozytywny. Samo RFC przegłosowano aż 42 do 2. Przyznam, że ja nie jestem aż tak wielkim entuzjastą tego pomysłu. W obliczu tych wszystkich pochwał i zachwytów znowu wyjdę na starego zgreda, który narzeka na każdą kolejną zmianę. No cóż, taka moja rola żeby dać sobie i Wam do myślenia. Takie krytyczne podejście pozwala używać technologii bardziej świadomie. A w tym wypadku, nie do końca widzę sens wprowadzenia tego mechanizmu. Być może się mylę – czas pokaże.
Świat bez property hooks
W PHP każde pole klasy, w zależności od zdefiniowanej widoczności, może być ustawione lub odczytane. Własności publiczne można zmieniać i odczytywać do woli. Własności prywatne są niedostępne z zewnątrz. Steruje się nimi za pomocą metod. W takim najprostszym wydaniu, gdzie metody pozwalają na odczyt stanu i jego zmianę, są to najzwyklejsze gettery i settery.
final class Student
{
private string $id;
private string $firstName;
private string $lastName;
public function __construct(
string $id,
string $firstName,
string $lastName
) {
$this->id = $id;
$this->firstName = $firstName;
$this->lastName = $lastName;
}
public function getId(): string
{
return $this->id;
}
public function getFirstName(): string
{
return $this->firstName;
}
public function getLastName(): string
{
return $this->lastName;
}
}
Do nie dawna, z reguły, wyglądało to jak powyżej. Kolejne wersje języka pozwoliły na małe usprawnienia. Najpierw w wersji 8.0 pojawił się skrótowy zapis deklaracji pól klasy (constructor property promotion), który w przypadku tak prostych klas całkiem nieźle się wpasował.
final class Student
{
public function __construct(
private string $id,
private string $firstName,
private string $lastName
) {
$this->id = $id;
$this->firstName = $firstName;
$this->lastName = $lastName;
}
public function getId(): string
{
return $this->id;
}
public function getFirstName(): string
{
return $this->firstName;
}
public function getLastName(): string
{
return $this->lastName;
}
}
Zauważcie, że ten obiekt jest prostym opakowaniem na dane (DTO). Strukturą danych, która nie ma żadnych zachowań. Skoro tak, to dlaczego jego pola nie są publicznie dostępne? Na ten moment obiekt jest niemutowalny. Raz ustawione wartości nie powinny zostać zmodyfikowane. Z kolei ten problem został zaadresowany w wersji 8.1, od której pola mogą być oznaczone jako tylko do odczytu (readonly). W wersji 8.2 cała klasa może być zdefiniowana w ten sposób.
final readonly class Student
{
public function __construct(
public string $id,
public string $fistName,
public string $lastName
) {
}
}
Czytelność kodu to kwestia mocno subiektywna, ale chyba zgodzicie się, że powyższy kod jest przejrzysty. A co, gdyby imię i nazwisko miało być modyfikowalne? Przed wersją 8. wyglądało by to tak jak poniżej.
final class Student
{
private string $id;
private string $firstName;
private string $lastName;
public function __construct(
string $id,
string $firstName,
string $lastName
) {
$this->id = $id;
$this->firstName = $firstName;
$this->lastName = $lastName;
}
public function getId(): string
{
return $this->id;
}
public function getFirstName(): string
{
return $this->firstName;
}
public function setId(string $id): void
{
$this->id = $id;
}
public function setFirstName(string $firstName): void
{
$this->firstName = $firstName;
}
public function getLastName(): string
{
return $this->lastName;
}
public function setLastName(string $lastName): void
{
$this->lastName = $lastName;
}
}
Za to wersja nowsza jest zdecdowanie krótsza i przyjemniejsza dla oka. Skupiam się tu głównie na aspekcie czytelnośći, bo w kwestii wytrwarzania oprogramowania różnice są znikome. W samym IDE, bez większego zastanowienia, każdą z tych opcji generuje się w sekundę. Same pola readonly mają kilka niewielkich ograniczeń. Ciężko przypisać im domyślną wartość, a modyfikować można je tylko za pomocą metod. Bezpośrednie przypisanie nie zadziała: $student->firstName = 'Krystian'
.
final class Student
{
public function __construct(
public readonly string $id,
public string $fistName,
public string $lastName
) {
}
}
Tak wyglądały proste DTO w PHP do tej pory. Tak naprawdę chodzi mi o wszystkie klasy będące tylko workiem na dane. W żaden sposób nie są one sterowane zachowaniami. Cała logika odpowiedzialna za wykonywanie programu jest gdzieś indziej, a powyższych obiektów używa się wyłącznie do przechowywania stanu. Jest to tak zwany model anemiczny. Celowo wziąłem na warsztat tego rodzaju reprezentację, bo jak zaraz zobaczycie, tytułowe Property Hooks będą właśnie celowały w tę kategorię. Przynajmniej ja nie wyobrażam sobie bogatego modelu domenowego reprezentowanego za pomocą property hooks.
Czym są haki własności?
Haki własności (property hooks) pozwalają wprowadzać dodatkowe zachowania w sposób specyficzny dla pojedynczego pola, respektując jednocześnie wszystkie inne istniejące aspekty języka. Są alternatywą dla prostych metod typu gettery i settery lub rozszerzeniem dla bezpośredniego odwołania do pola publicznego. Umożliwiają krótszy zapis i redukcję ilości kodu. W świecie PHP są nowością, ale w podobnej formie istnieją w innych językach programowania. Przykład z wcześniej, zapisany za pomocą nowej składni, wygląda jak poniżej.
final class Student
{
public string $id {
get {
return $this->id;
}
}
public string $firstName {
get {
return $this->firstName;
}
set (string $firstName) {
$this->firstName = $firstName;
}
}
public string $lastName {
get {
return $this->lastName;
}
set (string $lastName) {
$this->lastName = $lastName;
}
}
public function __construct(
string $id,
string $firstName,
string $lastName
) {
$this->id = $id;
$this->firstName = $firstName;
$this->lastName = $lastName;
}
}
Mam nadzieję, że zauważyliście, że powyższy kod działałby przecież dokładnie w taki sam sposób bez haków, bo własności są publiczne. Ale to punkt wyjściowy, bo do haków można dodać znacznie więcej, chociażby transformację danych czy walidację.
final class Student
{
public string $id;
public string $firstName {
get {
return ucwords(mb_strtolower($this->firstName));
}
set (string $firstName) {
if (mb_strlen($firstName) <= 3) {
throw new InvalidArgumentException();
}
$this->firstName = $firstName;
}
}
public string $lastName {
get {
return ucwords(mb_strtolower($this->lastName));
}
set (string $lastName) {
if (mb_strlen($lastName) <= 3) {
throw new InvalidArgumentException();
}
$this->lastName = $lastName;
}
}
public function __construct(
string $id,
string $firstName,
string $lastName
) {
$this->id = $id;
$this->firstName = $firstName;
$this->lastName = $lastName;
}
}
Dla jednolinijkowców, zarówno dla get
jak i set
dozwolona jest także skrótowa wersja strzałkowa. Analogicznie jak pokazuje to przykład poniżej. W przypadku set
można też pominać typ i nazwę przekazywanej wartości. Wówczas typ jest tożsamy z definicją pola, a wartość przyjmuje domyślną nazwę $value
. Tak samo dla pełnej i skrótowej wersji.
final class Student
{
public string $id;
public string $firstName {
get => ucwords(mb_strtolower($this->firstName));
set {
if (mb_strlen($value) <= 3) {
throw new InvalidArgumentException();
}
$this->firstName = $value;
}
}
public string $lastName {
get => ucwords(mb_strtolower($this->lastName));
set {
if (mb_strlen($value) <= 3) {
throw new InvalidArgumentException();
}
$this->lastName = $value;
}
}
public function __construct(
string $id,
string $firstName,
string $lastName
) {
$this->id = $id;
$this->firstName = $firstName;
$this->lastName = $lastName;
}
}
Ograniczenia property hooks w PHP
Property hooks działają mocno średnio z tablicami. Nie wchodząc w szczegóły techniczne, chyba lepiej od razu założyć dedykowane metody do modyfikacji tablic. Chociaż publiczna tablica w obiekcie zachowuje się w przewidywalny sposób, co prezentuje poniższy kawałek kodu, to własne haki już nie pozwolą na takie zachowanie. W sumie to słusznie, bo ta droga może prowadzić do nieoczekiwanych błędów. Z tablicami trzeba obchodzić się ostrożnie. Dwie sensowne drogi to: oznaczenie readonly
dla publicznej tablicy jednorazowo ustawianej w całości lub prywatna tablica z dedykowanymi metodami.
final class Student
{
public function __construct(public array $emails) {}
}
$student = new Student([]);
$student->emails[] = 'john.doe@koddlo.pl';
$student->emails[] = 'jane.dawson@koddlo.pl';
Haki mogą być normalnie dziedziczone, nadpisywane w klasach potomnych, czy oznaczane jako finalne. Nie ma tu żadnego odstępstwa od reguły. Nie mogą być używane do pól statycznych, co jest logiczne. Ciekawsze wydają się interfejsy, które do tej pory nie pozwalały na definiowanie pól klasy. I słusznie, bo interfejsy mają definiować stabilną abstrakcję, a nie wchodzić w szczegóły implementacyjne. Trochę inaczej jest jednak w tym wypadku, gdzie z jednej strony narzuca się konkretne pole, ale z drugiej mowa w końcu o wymuszeniu metod get
i set
. Jest to dość dziwne, ale możliwe.
W pewnym sensie rozwiązuje to istniejący problem z polami tylko do odczytu, których w żaden sposób wymusić się nie dało. Trzeba było sięgać po stare dobre gettery nawet dla publicznych własności. W moim odczuciu to rzadka potrzeba, żeby tak proste obiekty opakowywać interfejsami, ale nie twierdzę, że całkowicie zbędna. Przyznam, że odkąd używam własności tylko do odczytu, kilka razy zdarzyło mi się zrobić getter dla takiego pola.
interface FullName
{
public string $fullName { get; }
}
Naturalnie, jak to dla interfejsów – mowa tylko o publicznych własnościach, czy metodach. Nie ma szans na ustawienie innej widoczności. Podobnie zresztą mają się pola abstrakcyjne, choć te mogą być również chronione (protected). Jeżeli chodzi o traity to wygląda to dokładnie tak samo jak przy bezpośredniej deklaracji w klasie.
Inne ograniczenie dotyczy wspomnianych juz w tym wpisie pól readonly
. Nie można łączyć tych dwóch mechanizmów. Pola tylko do odczytu nie mogą mieć zdefiniowanych hakow. Chociaż jak wspomniałem wcześniej, nowa funkcjonalność umożliwi wymuszenie interfejsem wymagalności pola tylko do odczytu przy jego domyślnym zachowaniu. Poniższy kod jest całkowicie poprawny.
interface FullName
{
public string $fullName { get; }
}
final class Student implements FullName
{
public function __construct(
public readonly string $fullName
) {
}
}
Co z funkcjami do pobrania stanu
W PHP jest kilka opcji na odczytanie stanu obiektu. Co ciekawe, niektóre z nich zwrócą faktyczną wartość pola, a niektóre użyją haka. Poniżej kilka najbardziej używanych funkcji.
var_dump(): surowa wartość;
serialize(): surowa wartość;
unserialize(): surowa wartość;
rzutowanie do tablicy (array): surowa wartość;
__serialize() i __unserialize(): wartość z haka;
var_export(): wartość z haka;
json_encode(): wartość z haka;
interfejs JsonSerializable: wartość z haka;
get_object_vars(): wartość z haka;
Opinia o wprowadzeniu property hooks
Można powiedzieć, że property hooks to kolejny lukier składniowy. Tak naprawdę, sam PHP ma już coś podobnego, choć strasznie topornego. Dwie metody magiczne __get
i __set
, których chyba nigdy nie użyłem. Ale gdybym musiał to faktycznie haki przemawiają do mnie bardziej. Są znacznie czystszym i bezpieczniejszym konceptem.
Wprowadzenie tej funkcjonalności daje pewien komfort. Może zaistnieć potrzeba dekoracji zwykłego odwołonia do własności obiektu. Jako, że wspomniane wyżej magiczne metody sprawdzają się tak sobie, z reguły jednak dodaje się dedykowaną metodę. Tyle tylko, że to zmienia kontrakt. Trzeba znaleźć wszystkie wywołania w kodzie i je dostosować. Haki adresują dokładnie ten problem. Według mnie, ale też autorów RFC, haki nie powinny być domyślnym wyborem. Są raczej możliwością zrobienia tego w przyszłości, gdyby zaszła taka potrzeba.
Haki mają pewien niewdzięczny interfejs. Przy odwoływaniu się do konkretnego pola nie jestem w stanie ocenić, czy jest to zwykłe wywołanie, czy dzieje się coś więcej. Trudno powiedzieć, czy haki poprawiają czytelność. Według mnie, o ile te proste jednolinijkowce pewnie tak, o tyle przy dużej liczbie bardziej złożonych funkcji już niekoniecznie. Już od dawna, w PHP widoczny jest pewien trend redukcji linii kodu na rzecz skrótowych zapisów. Tylko w pewnym momencie dzieje się tak dużo magii pod spodem, że ciężko się połapać.
Mniejsza ilość kodu nie jest gwarancją wzrostu czytelności.
Zauważcie, że w tym artykule haki ustawiające spełniają rolę walidacyjną. To jeden z motywów wprowadzenia property hooks do języka PHP. Nie jestem przekonany, czy jest to odpowiednie miejsce na tego rodzaju logikę. Naturalnie, pamiętając o moim założeniu, że haki będą wykorzystywane do prostych obiektów służących jako worki na dane. Chyba, że ktoś chce wykorzystać je w swoim anemicznym modelu domenowym, gdzie taka bazowa logika walidacyjna może być w porządku. Mimo wszystko, ja widzę znacznie większe korzyści w modelu sterowanym zachowaniami z bardziej opisowymi metodami i pełną enkapsulacją. Jeżeli model jest tylko naiwną reprezentacją tabeli w bazie danych to możliwe że to dla prostych funkcjonalności będzie to wystarczające.
Ale czy haki byłyby w ogóle potrzebne, gdyby pozostać przy starych, dobrych własnościach prywantych z metodami typu getter i setter. Nie wydaję mi się. I to jest mój główny zarzut do tego rodzaju nowych mechanizmów języka. To nie jest tak, że wszyscy muszą programować w jeden określony sposób. Tyle, że pewna unifikacja powoduje, że łatwiej jest się odnaleźć. Każdy z nas pisze kod, ale też czyta kod napisany przez innych. Mnogość rozwiązań daje pewną elastyczność, ale niestety podnosi próg wejścia. Ile mechanizmów trzeba rozumieć, żeby ogarnąć najbardziej prymitywny obiekt z kilkoma polami.
I jasne, nikt nie nakazuje mi używania haków własności. Jest nawet duża szansa, że nie będę po nie sięgał. Problem jest taki, że programiści rzucają się na każdą nowość, bo każdy z nas chce pisać nowoczesny kod. Tak jak nie byłem, i nie stałem się, fanem constructor property promotion to mimo wszystko używam ich na co dzień. Tworzę kod z innymi programistami, a takie pierdoły nie są czymś o co warto walczyć. Każdy ma swój styl, ale trzeba go trochę nagiąć do standardów w projekcie. Być może podobnie będzie z property hooks, choć publiczne pola tylko do odczytu wydają się być wystarczające dla niemutowalnych obiektów, a prywatne pola z metodami dla mutowalnych.
Wspomniałem już, że ten mechanizm istnieje też w wielu innych językach. Skoro tak to podejrzewam, że w PHP też znajdzie swoje miejsce i świat się nie skończy. Jednak obserwując pewien zachwyt tego rodzaju nowością w ekosystemie PHP musiałem podzielić się moim zgoła innym odczuciem. Zresztą ustawienie się w kontrze to moja naturalna pozycja. Niezależnie od tego, czy będziecie używać haków własności, myślę że poza moimi opiniami, ten materiał przede wszystkim przybliżył Wam cały ten mechanizm.
Na koniec kombinacja wszystkich rzeczy opisanych w tym artykule. Mimo, że kod w pełni działający to mam nadzieję że się zgodzicie, że jest to pewna patologia. Jak dużo czasu musicie poświęcić, żeby zrozumieć tak prosty z natury obiekt? Którym sposobem opisać nowododane pole? Czy naprawdę warto sięgać po tego rodzaju składnie tylko dlatego, że jest czymś nowym?
#[Entity]
class Student
{
#[Column(type: Types::STRING, length: 64)]
public string $lastName {
get {
return ucwords(mb_strtolower($this->lastName));
}
set (string $lastName) {
if (mb_strlen($lastName) <= 3) {
throw new InvalidArgumentException();
}
$this->lastName = $lastName;
}
}
public function __construct(
#[Id]
#[Column(type: Types::GUID)]
public readonly string $id,
#[Column(type: Types::STRING, length: 64)]
public string $firstName {
set (string $firstName) {
if (mb_strlen($firstName) <= 3) {
throw new InvalidArgumentException();
}
$this->firstName = $firstName;
}
get => ucwords(mb_strtolower($this->firstName));
},
string $lastName
) {
$this->lastName = $lastName;
}
public function getId(): string
{
return $this->id;
}
}
Hej Krystian, świetny artykuł!
> Haki mają pewien niewdzięczny interfejs. Przy odwoływaniu się do konkretnego pola nie jestem w stanie ocenić, czy jest to zwykłe wywołanie, czy dzieje się coś więcej.
Zgadzam się tutaj w pełni – przejmujemy rolę i może zadziać się magia.
Przyznam, że brak możliwości określenia pól jako wyłącznie „getterów” sprawił mi spory zawód… Gdyby składnia pozwoliła na readonly, albo po prostu ograniczenie setów – wtedy rozwiązanie to byłoby moim zdaniem świetne, bo owych getterów pisać by nie trzeba.
Weźmy jednak pod uwagę, że dzięki takim wpisom jak Twoje i całej społeczności PHP – jest szansa, że tego typu „ficzery” też się kiedyś pojawią…
Słuszna uwaga, a w sumie w jasny sposób tego nie opisałem, że nie można samego gettera zrobić, więc dorzucę tutaj jeszcze w ograniczeniach.
Z mojej perspektywy funkcjonalność zbędna i niepotrzebna, za chwilę w projektach jedni będą tego używali (bo nowe, więc trzeba) a inni nie.
Wg mnie to jest bardzo mało czytelne, czyli z natury złe.
Wydaje mi się, że patrzysz na hooki od złej strony. One nie mają służyć temu, by od teraz spamować wszędzie walidacją i transformacją tych pól. Przy setkach DTO nikt przecież nie będzie w każdej klasie duplikować nawet tak drobnej logiki. To nie ma zastąpić ValueObjectów, ale umożliwić coś na wzór akcesorów/mutatorów (albo właściwości typu „computed” z js-owych frameworków), czyli dynamicznie tworzonych właściwości na bazie tych dostępnych. Jasne, dziś też można to zrobić getterem, ale umówmy się: składniowo gettery i settery nigdy nie wyglądały ładnie i odkąd pojawił się „readonly” zaprzestałem w ogóle ich używania. I właśnie dlatego dla mnie dużo ważniejszy feature od tych hooków to możliwość definiowania właściwości z interfejsie.