PHP 8.2 ma być wydane pod koniec 2022 roku, konkretna data zostanie ogłoszona w bliżej nieokreślonej przyszłości. W tym poście przyjrzę się wszystkim poprawkom i wprowadzonym oraz wycofanym funkcjonalnościom. Postaram się utrzymywać ten artykuł na bieżąco wraz z kolejnymi RFC, zaakceptowanymi już po publikacji.

System typów w PHP nie jest idealny, ale jest sukcesywnie wzbogacany i rozwijany. Tym razem zmiana jest typową ewolucją już istniejących typów, czy raczej sposobów na typowanie — będzie można łączyć ze sobą typy unijne (logiczne LUB, czyli |) oraz typy krzyżowe (logiczne I, czyli &), dzięki czemu możliwe będzie bardzo szczegółowe, ale jednocześnie mocno elastyczne zamodelowanie oczekiwanych/zwracanych typów.

Ciężko tu mówić o jednoznacznym zysku, ponieważ ta zmiana niesie ze sobą nieskończoną ilość zastosowań. Jednocześnie niesie sporo wyzwań dla twórców narzędzi operujących na Abstract Syntax Tree, takich jak #PHPStan czy #Rector - wszystkie mają przed sobą trudne zadanie dostosowania się do zmian w składni (choć duża część zostanie oczywiście wykonana w parserze) oraz dodania reguł weryfikujących poprawność kodu czy też umożliwiających jego refaktoryzację.

Warto podkreślić, że RFC wnosi typy DNF, których składnia jest jasno określona: to zbiór LUB, w którym poszczególne elementy mogą przyjmować postać zbioru I. Czyli poprawny typ to A|(B&C), podczas gdy zapis A&(B|C) nie jest poprawny i wywoła błąd parsowania.

Bardzo się cieszę, że PHP idzie za ciosem i wprowadza możliwość użycia readonly na poziomie klasy. Dzięki temu modelowanie obiektów przeznaczonych do transferowania danych (DTO) będzie jeszcze prostsze. W PHP 8.1 użycie było następujące:

class Foo
{
    public function __construct(
        readonly public string $foo,
        readonly public string $bar,
        readonly public string $baz
    ) {}
}

Od wersji 8.2, aby osiągnąć całkowitą niemutowalność obiektu będzie trzeba użyć readonly tylko raz:

readonly class Foo
{
    public function __construct(
        public string $foo,
        public string $bar,
        public string $baz
    ) {}
}

Nie ma tutaj najmniejszych wątpliwości — to świetny dodatek, który na pewno znajdzie szerokie zastosowanie w standardowej bibliotece PHP oraz projektach open source. Warto się oczywiście zapoznać z pełnym RFC, a zwłaszcza z sekcją opisującą ograniczenia, by wiedzieć jakiego rodzaju użycie readonly jest niedozwolone.

Ciekawą perspektywę przedstawił Frank de Jonge:

Chodzi o to, że readonly uniemożliwia zmianę wartości właściwości po ich zainicjowaniu, a zatem dużo ciężej jest tworzyć płynne API zwracające niemutowalne obiekty utworzone na podstawie innych niemutowalnych obiektów (np. (new \DateTimeImmutable())->modify('+1 day')) w oparciu o pola tylko do odczytu. Osobiście jednak uważam, że zwyczajnie nie do tego przeznaczone jest readonly - doskonale nadaje się do tworzenia prostych, niemutowalnych struktur danych (DTO), a do obiektów zawierających logikę i API należy po prostu użyć prywatnych właściwości i metod (modyfikatorów i tzw. getterów).

Trait jest bytem umożliwiającym wielokrotne użycie tego samego kodu w wielu miejscach bez ingerencji w drzewo dziedziczenia czy zastosowaną kompozycję. Klasa może dziedziczyć po innej klasie, implementować interfejsy, a jednocześnie może zupełnie niezależnie używać traitów (oczywiście pojawia się kwestia konfliktów nazw, ale te można rozwiązać na poziomie importu). Niestety, brak wsparcia dla stałych skutkował tym, że deweloper stawał przed wyborem:

  • nagiąć zasadę enkapsulacji i używać w traicie stałych zdeklarowanych poza nim (w miejscach, gdzie trait jest użyty)
  • nie stosować traitów i zastąpić je innym rozwiązaniem architektonicznym

Pierwsza z opcji jest dla mnie osobiście nieakceptowalna. Osobiście restrykcyjnie trzymam się tego, by trait był zamkniętym zbiorem właściwości i metod, tak by nie używał niczego pochodzącego spoza traitu. Jeśli istnieje potrzeba, trait może definiować metody abstrakcyjne, które muszą być zaimplementowane w klasie, która używa traitu.

Zatem, jak się można domyślić, zaakceptowany RFC niesie zmianę, którą popieram i z której zapewne prędzej czy później skorzystam 🙂 Od wersji 8.2 poniższy kod będzie poprawny:

trait Example {
    public const ONE = 1;
    protected const TWO = 2;
    private const THREE = 3;

    public function count(): string
    {
        return implode(', ', [self::ONE, self::TWO, self::THREE]);
    }
}

Pozwoli to na lepsze modelowanie symboli i choć traity nie należą do moich ulubieńców, to i tak cieszę się, że ilość potencjalnych złych użyć zostanie ograniczona. Reszta jest w rękach developerów 😉.

Wstęp do tego RFC jest dość enigmatyczny, nieprawdaż? Nie inaczej jest z enumami, których użycie — jak się okazuje — nie jest dla wszystkich oczywiste. Problemem jest fakt, że enumy są obiektami — ich użycie operuje na instancjach danego enuma, co umożliwia lepsze typowanie oczekiwanych argumentów lub wartości zwracanych z funkcji. Nie jest możliwe jednak odwoływanie się do właściwości enumów w wyrażeniach opartych o statyczne użycia. Poniżej widać przykłady pokazujące zastosowania, jakie obecnie nie są możliwe, ale będą dozwolone w PHP 8.2:

enum E: string {
    case Foo = 'foo';
}

const C = E::Foo->name;

function f() {
    static $v = E::Foo->value;
}

#[Attr(E::Foo->name)]
class C {}

function f(
    $p = E::Foo->value,
) {}

class C {
    public string $p = E::Foo->name;
}

// The rhs of -> allows other constant expressions
const VALUE = 'value';
class C {
     const C = E::Foo->{VALUE};
}

Głosowanie zakończyło się wynikiem 24:11, więc decyzja bynajmniej nie była jednoznaczna. Dyskusji natomiast praktycznie nie było, a przynajmniej nie w oficjalnym kanale 😉. Głównymi argumentami przeciw były niespójności w zachowaniu języka, ale osobiście uważam, że sensowne odstępstwa od konwencji, które rozwiązują realne problemy społeczności PHP, są jak najbardziej akceptowalne.

Myślę jednak, że lepszym kierunkiem byłoby takie wsparcie na poziomie języka, by możliwe było użycie enumów jak zwykłych constów, czyli Some::thing, zamiast Some::thing->value. Niestety, wydaje się, że byłoby to nie w pełni kompatybilne z declare(strict_types=1); (a przynajmniej nie bez jakiegoś wewnętrznego haka). Zaakceptowany format wymusza dodatkowy kod, ale przynajmniej rozwiązuje problem 🤷.

Prawdopodobnie każdy z nas, wędrując sobie po Internecie, natknął się na błąd połączenia do bazy w stylu:

PDOException: SQLSTATE[HY000] [2002] No such file or directory in /var/www/html/test.php:3
Stack trace:
#0 /var/www/html/test.php(3): PDO->__construct('mysql:host=loca...', 'root', 'password')
#1 {main}

Przy odrobinie szczęścia (lub rozumu), właściciel strony użył na tyle długiego hasła, że w wyświetlonym błędzie było ono przycięte i nie otwierało dostępu do bazy każdemu odwiedzającemu (no, przynajmniej utrudniało). Problem nie dotyczy oczywiście tylko baz danych oraz błędów wyświetlanych jawnie użytkownikowi — proponowany atrybut ukrywa wartości w stack trace‘ach, a te również mogą być przesyłane do zewnętrznych usług, np. #Sentry, które również potrafi anonimizować różne dane, ale po pierwsze musi być odpowiednio skonfigurowane, a po drugie w tym przypadku raczej ten mechanizm się po prostu nie sprawdzi. Dobrze zatem mieć możliwość zadbania o ukrycie wrażliwych danych jeszcze po stronie aplikacji.

Nowy atrybut działa następująco:

<?php

function test(
    $foo,
    #[\SensitiveParameter] $bar,
    $baz
) {
    throw new \Exception('Error');
}

test('foo', 'bar', 'baz');

/*
Fatal error: Uncaught Exception: Error in test.php:8
Stack trace:
#0 test.php(11): test('foo', Object(SensitiveParameterValue), 'baz')
#1 {main}
  thrown in test.php on line 8
*/

Uważam, że to przydatna funkcjonalność, która może podnieść #bezpieczeństwo aplikacji. Nie do końca podoba mi się sposób prezentowania Object(SensitiveParameterValue), dlatego, że taka forma stosowana jest również dla skalarnych wartości, co wprowadza pewną rozbieżność między faktycznym typem a tym wyświetlanym w stack trace. Wolałbym coś w rodzaju SensitiveParameter<string>, SensitiveParameter<Foo> itd., by nie tracić istotnych informacji o przekazanym parametrze. Jest to natomiast detal, który nie obniża przydatności samego atrybutu.

Kto pracował z systemami klasy legacy (lub po prostu w zespołach, w których jakość kodu nie była priorytetem…) ten wie, że dynamicznie zdefiniowane właściwości obiektu to prawdziwa zmora. Jasne, nowoczesne IDE potrafią nas ostrzec i wskazać takie miejsca, ale bez wsparcia narzędzi trudno śledzi się tego typu właściwości. Potrafią one wprowadzać niezłe zamieszanie, gdyż patrząc na kod, w którym jakieś właściwości są używane oraz na definicję klasy możemy odnieść wrażenie, że kod odwołuje się do nieistniejących danych, a one po prostu zostały gdzieś dynamicznie zdeklarowane. Z drugiej strony fakt, że PHP niejawnie tworzy nowe właściwości, sprawia, że nie jesteśmy chronieni przed zwykłymi literówkami czy pomyłkami, które jak wiemy zdarzają się nawet najlepszym 😉

Pozwolę sobie zademonstrować przykład wprost z RFC:

class User {
    public $name;
}

$user = new User();

// Assigns declared property User::$name.
$user->name = 'foo';

// Oops, a typo:
$user->nane = 'foo';

Tego typu dynamiczne tworzenie właściwości aż do wersji 8.1 włącznie po prostu tworzyło nową właściwość, co mogło powodować szereg problemów (zwłaszcza w projektach niekorzystających z dobrodziejstw #statycznej analizy). Od wersji 8.2 emitowane będzie ostrzeżenie E_DEPRECATED, a w wersji 9.0 tego rodzaju kod wywoła wyjątek typu Error.

Jednocześnie ten RFC wprowadza nowy atrybut #[AllowDynamicProperties], który umożliwia utrzymanie dotychczasowego zachowania (brak ostrzeżenia w 8.2 oraz wyjątku w PHP9), dzięki czemu możliwe będzie migrowanie istniejących projektów do nowszych wersji PHP bez konieczności znacznego modyfikowania kodu. Warto jednak zwrócić uwagę na to, że nie będzie możliwe użycie atrybutu #[AllowDynamicProperties] w klasach oznaczonych jako readonly - takie połączenie wywoła wyjątek.

Tego rodzaju usprawnienia na poziomie języka poprawiają jakość kodu i pomagają wypracowywać dobre wzorce. Mogę tylko przyklasnąć 👏

Obecnie w PHP istnieje meta-typ callable, który można używać do typowania argumentów oraz zwrotek z funkcji. Niestety, nie wszystkie dane akceptowane przez typ callable faktycznie można wywołać jako $callable(), co wprowadza dużą niespójność i stwarza ryzyko wystąpienia poważnych błędów. Celem tego RFC jest wycofania wsparcia dla tych callable, które w rzeczywistości nie mogą zostać wywołane — od PHP 8.2 funkcje wykorzystujące callable (np. call_user_func()) będą emitować ostrzeżenie E_DEPRECATED z informacją o wycofanym użyciu. Docelowo, w wersji 9.0, takie wsparcie zostanie całkowicie usunięte i typ callable nie będzie akceptował następujących formatów:

"self::method"
"parent::method"
"static::method"
["self", "method"]
["parent", "method"]
["static", "method"]
["Foo", "Bar::method"]
[new Foo, "Bar::method"]

Większość z nich ma swoje zamienniki (szczegóły w RFC), podejrzewam również, że wcześniej czy później pojawią się reguły w #Rectorze, które umożliwią automatyczną refaktoryzację kodu.

Cieszę się, że PHP sprząta tego typu rzeczy, ponieważ każda taka zmiana podnosi jakość języka i zwiększa jego stabilność z perspektywy dewelopera. Więcej zabezpieczeń na warstwie języka to też mniej potrzeb po stronie narzędzi takich jak #PHPStan.

Celem tego RFC jest wczesne ostrzeżenie ludzi, że ich kod w PHP 9 przestanie działać. Jako, że niektóre formaty callable zostały porzucone już we wcześniejszym RFC, ale niektóre specyficzne ścieżki użycia nie zostały wzięte pod uwagę w kontekście generowania błędów E_DEPRECATED, mogło to doprowadzić do sytuacji, w której kod działający poprawnie na PHP 8.2 i nie generujący żadnych ostrzeżeń o porzuconych formatach, mógł przestać działać w PHP 9.0.

Cieszę się, że twórcy PHP dbają o użytkowników języka i zdecydowali się zmienić wcześniejszą decyzję oraz wprowadzić ostrzeżenia w tych miejscach. Wizerunkowo to na pewno dobra decyzja, ponieważ wszystkie potencjalne problemy po wydaniu wersji 9.0 źle wpłynęłyby na postrzeganie PHP.

Ta zmiana powinna zainteresować wszystkich, którzy w swoich systemach stosują wielojęzyczność i wykonują operacje na ciągach znaków zawierających znaki spora standardowego zakresu A-Z - w PHP 8.2 funkcje operujące na stringach będą poprawnie obsługiwać znaki diakrytyczne. Oznacza to, że strtoupper('ą') w końcu zwróci Ą, a nie ą jak do tej pory. Funkcje związane z sortowaniem również zaczną zachowywać się zgodnie z oczekiwaniem.

Jak doskonale wiemy, PHP ciągnie za sobą dłuuuuugi ogon kompatybilności wstecznej, co bywa powodem żartów i w pewnym sensie daje zły obraz języka. W każdym razie wiele wbudowanych w PHP funkcji historycznie posiada sygnatury, które nie przystoją nowoczesnym technologiom. Przykładowo strpos() zwraca wartość liczbową wskazującą miejsce wystąpienia wskazanego ciągu znaków LUB właśnie false, gdy wskazany ciąg znaków nie występuje w innym ciągu znaków. Problem z tą sygnaturą jest taki, że aż do wersji 8.0 nie dało się jej opisać inaczej niż poprzez @return int|false w phpDoc, co nie miało tak naprawdę dużej wartości. PHP8 umożliwiło stosowanie false w tzw. union type, czyli sygnatury w stylu strpos(/* ... */): int|false są w pełni poprawne. Ten RFC idzie krok dalej i umożliwia stosowanie false oraz null jako samodzielnych typów.

Na pierwszy rzut oka ta zmiana jest totalnie zbędna (kto by chciał używać false jako zwracanego typu 🤔?). Jednak czytając RFC, możemy zauważyć przykłady opisujące kowariancję i kontrawariancję, i wtedy ta zmiana nabiera sensu (niewielkiego, ale jednak). Spójrzmy na przykład:

class User {}

interface UserFinder
{
    function findUserByEmail(): User|null;
}

class AlwaysNullUserFinder implements UserFinder
{
    function findUserByEmail(): null
    {
        return null;
    }
}

Dzięki zmianie z tego RFC możliwe jest zaimplementowanie interfejsu i zachowanie zgodności z LSP ograniczając oryginalną sygnaturę zwracanego typu User|null do samego null. Nie jest to w moim odczuciu coś, co znajdzie szerokie zastosowanie — osobiście widzę tu pole do popisu w testach czy środowiskach deweloperskich, gdzie pewne części systemu będą po prostu podmieniane na implementacje-wydmuszki. Choć może po prostu nie napotkałem jeszcze scenariuszy, gdzie ta zmiana rozwiązałaby realny problem 😉

Niewiele można na ten temat napisać, po prostu w PHP 8.2 true stanie się samodzielnym typem, tak jak wcześniej zostały nim false oraz null. Podobnie jak w kontekście poprzedniej zmiany, tak i tutaj osobiście nie widzę realnego zastosowania. Podejrzewam, że ma to znaczenie w standardowej bibliotece PHP oraz może mieć zastosowanie w specyficznych bibliotekach open source. Natomiast nawet jeśli ja nie widzę zastosowania, to nie znaczy, że ta zmiana nie jest potrzebna — dobrze jest mieć większe pole manewru.

Obecnie PHP ma cztery sposoby na dynamiczne wstrzykiwanie wartości do ciągów znaków: "$foo", "{$foo}", "${foo}" oraz "${$foo}" (ostatni przypadek to tzw. variable variable, czyli dynamiczne odwołanie do zmiennej poprzez wartość przypisaną do innej zmiennej — składnia dość niszowa i raczej rzadko stosowana). Ten RFC wycofuje z użycia dwie ostatnie metody, a wsparcie dla nich zostanie całkowicie usunięte w PHP9.

Przyczyną wycofania interpolacji ${} jest fakt, że obie miały praktycznie identyczną składnię, a jednocześnie zupełnie odmienne działanie. Wprawdzie wydaje się, że tego typu konflikty to brzegowe przypadki, mimo to dobrze, że PHP robi w tej kwestii porządek i zmniejsza ilość wspieranych składni interpolacji. W przyszłości może zostaną dodane nowe rodzaje interpolacji (jak np. sugerowane w RFC "{$:func()}"), ale zanim to nastąpi, dobrze by było ujednolicić to, co już jest dostępne w języku.

Ta zmiana jest na tyle niskopoziomowa, że większość użytkowników PHP nigdy nie odczuje różnicy. Jest jednak istotny aspekt tej zmiany powodujący niekompatybilność wsteczną - mysqlnd nie wspiera i nie będzie wspierał automatycznego ponawiania połączeń. Biblioteki czy systemy, które korzystały z tej funkcjonalności, będą musiały znaleźć inne sposoby na osiągnięcie takiego zachowania.

Motywacją tego RFC jest fakt, że wspomniane funkcje nie są wystarczająco dobrze określone. Mają one ograniczone działanie, podczas gdy ich nazwa może sugerować, że są bardziej uniwersalne. Generalnie formatów kodowania jest dużo i jest to skomplikowany temat, więc ludzie często popełniają błędy korzystając z tych funkcji. Zostaną one zatem oznaczone jako wycofane w PHP 8.2, a następnie usunięte w PHP 9.0.