Typy generyczne są szablonami, umożliwiającymi pisanie kodu bez wskazywania konkretnego typu danych, na których kod będzie pracował. Dzięki nim unikamy redundancji, a obiekty operują na wcześniej zadeklarowanych typach. Dobrym przykładem są tu różnego rodzaju kolekcje. jeżeli chcemy być pewni, iż kolekcja składa się z danego typu danych, możemy albo stworzyć osobną klasę do przechowywania tych obiektów, albo używać różnego rodzaju asercji, albo właśnie zastosować typy generyczne.
W przypadku osobnych klas kolekcji występuje redundancja — powielamy klasy, które różnią się tylko typem przechowywanego obiektu. W przypadku asercji IDE nie będzie w stanie podpowiedzieć nam składni. Jedynie typy generyczne pozwolą na stworzenie jednej klasy, która zagwarantuje nam spójność danych i prawidłowe podpowiadanie ze strony IDE.
Jak to ugryźć w PHP
W teorii wszystko wygląda ładnie. Jest tylko jeden mały problem — PHP nie posiada wbudowanych typów generycznych. Co prawda kilka lat temu pojawiło się RFC, jednak nie doczekało się ono wdrożenia. Na szczęście istnieją narzędzia do statycznej analizy kodu, takie jak Psalm czy PHPStan, które odczytując odpowiednie adnotacje w kodzie, są w stanie imitować działanie typów generycznych.
Możemy je łatwo zintegrować z popularnym IDE, jakim jest PHPStorm, dzięki czemu, będą nas informować na bieżąco o przypadkach błędnego przekazania czy zwracania danych z innych obiektów. Dodatkowo umożliwi to PHPStormowi wnioskowanie, jakie typy będą zwracane przez poszczególne metody, przez co zadziała nam funkcjonalność podpowiedzi.
Oczywiście może zdarzyć się, iż nie wszyscy posiadają odpowiednio skonfigurowane narzędzia, przez co, mimo najlepszych chęci, ktoś przekaże typ inny niż zadeklarowany, a IDE tego nie wyłapie. Aby uniknąć takich sytuacji, możemy również podpiąć statyczną analizę kodu pod proces CI/CD. Dzięki temu wszelkie naruszenia zasad będą wyłapywane na etapie budowania aplikacji i nie trafią na produkcję.
Przykłady
Jeśli chcemy, aby nasz klasa operowała na typach generycznych, musimy dodać do niej adnotację @template wraz z nazwą parametru, który ma być generyczny. Następnie, zamiast używać wbudowanych typów parametrów, używamy typu zadeklarowanego w adnotacji @template. Tak wygląda przykład interfejsu Collection używającego typów generycznych:
<?php interface Entity { } class Car implements Entity { public function __construct( public readonly int $id, public readonly float $engineCapacity ) {} } /** * @template T */ interface Collection { /** * @param T $entity */ public function add($entity); /** * @param int $id * @return T */ public function find(int $id); }Gdy taką kolekcję przekażemy jako parametr, IDE będzie w stanie rozpoznać jakiego obiektu oczekujemy i podpowiedzieć jego metody lub adekwatności.
Zawsze jednak mogą zdarzyć się pewne przeoczenia i czasem możemy omyłkowo próbować przekazać obiekt innego typu niż zadeklarowany. Wtedy IDE dzięki statycznej analizie kodu wykryje problem i poinformuje nas o naruszeniu typów.
class Animal {}Typy możemy również ograniczać do podklas danej klasy bądź interfejsu. Możemy to zrobić dopisując do wyrażenia określającego typ of SomeClass. Wówczas poniższa kolekcja będzie mogła składać się z klas, które rozszerzają bądź implementują Entity.
/** * @template T of Entity */ interface Collection { //... }Rozszerzanie szablonów
Powyżej stworzyliśmy interfejs. Teraz tworzymy klasę, która ten interfejs implementuje. W tym celu oprócz adnotacji @template dodajemy @template-implements Collection<T>
/** * @template T of Entity * @template-implements Collection<T> */ class ArrayCollection implements Collection { /** * @var array<int, T> */ private array $values = []; /** * @param T $entity */ public function add($entity) { $this->values[] = $entity; } /** * @param int $id * @return T */ public function find($id) { return $this->values[$position]; } }Analogicznie rzecz ma się do rozszerzania klas - używamy adnotacji @template-extends ArrayCollection<T>
/** * @template T of Entity * @template-extends ArrayCollection<T> */ class SpecificArrayCollection extends ArrayCollection {}Gdybyśmy, np. w klasie SpecificArrayCollection próbowali nadpisać metodę add tak, aby przyjmowała inny typ, np. int, wtedy Psalm pokaże nam błąd.
Możemy też rozszerzyć klasę generyczną i zadeklarować, aby przyjmowała konkretny typ danych, w tym przypadku Car.
/** * @template-extends ArrayCollection<Car> */ class CarArrayCollection extends ArrayCollection {}Klasa CarArrayCollection będzie mogła przyjmować już tylko elementy klasy Car.
Class-string
Psalm umożliwia stosowanie specjalnych adnotacji dla stałych typu AClass::class. Może to być użyteczne w serwisach typu Container czy ServiceLocator, gdzie na podstawie nazwy klasy chcemy otrzymać jej instancję. Wtedy adnotację @template podajemy nad metodą, która ma przyjmować nazwę klasy. Do określenia typu parametru używamy w tym przypadku @param class-string<T> $className. jeżeli jako typ zwracany podamy T, IDE będzie wiedziało, iż mamy doczynienia z instancją tej klasy i podpowie nam dostępne metody, a także wskaże błędne ich wykorzystanie.
class ServiceA { public string $serviceAProperty; } class ServiceB { public string $serviceBProperty; } class ServiceLocator { /** * @template T * @param class-string<T> $className * @return T */ public function getService(string $className) { return new $className; } }Kilka typów generycznych
Powyższe przykłady pokazują jak użyć pojedynczego typu generycznego w poszczególnych klasach. Jednak często potrzeba ich kilka. Wówczas po prostu dodajemy kolejne adnotacje @template z nazwami kolejnych typów generycznych. Poniższy przykład pokazuje prostą implementację HashMapy, której klucz to dopuszczalny klucz tablicy, zaś wartość to element generyczny.
/** * @template TKey of array-key * @template T */ class HashMap { /** * @var array<TKey, T> */ private array $values = []; /** * @param TKey $key * @param T $entity */ public function set($key, $entity) { $this->values[$key] = $entity; } /** * @param TKey $key * @return T */ public function get($key) { return $this->values[$key]; } }Typy generyczne w callbackach
Wyżej opisanych adnotacji możemy również używać w stosunku do funkcji zwrotnych. Dzięki temu unikniemy przypadkowego przekazania funkcji, która będzie przyjmować lub zwracać wartości inne niż zadeklarowane.
/** * @template T * @template R */ class CallbackExecutor { /** * @param callable(T):R $callable * @param T $value * @return R */ public function execute(callable $callable, mixed $value) { return $callable($value); } }Analiza kodu z poziomu wiersza poleceń
Podane wyżej przykłady wykrywania nieprawidłowości w typach dotyczą IDE, jakim jest w tym przypadku PHPStorm. Zostało ono specjalnie do tego skonfigurowane, aby na bieżąco sprawdzać pisany kod. Oczywiście każdy może używać innego IDE do pracy, a nie wszystkie umożliwiają taką integrację. Wtedy dobrym sposobem może okazać się tradycyjne sprawdzenie kodu wykonane poprzez uruchomienie np. Psalm'a z linii poleceń. Wynik takiego polecenia może wyglądać mniej więcej tak:
root@2fde6041ad38:/app# vendor/bin/psalm public/test.php Target PHP version: 8.1 (inferred from current PHP version) Scanning files... Analyzing files... E ERROR: UndefinedPropertyFetch - public/test.php:29:1 - Instance property ServiceB::$serviceAProperty is not defined (see https://psalm.dev/039) $serviceLocator->getService(ServiceB::class)->serviceAProperty; ERROR: InvalidScalarArgument - public/test.php:88:28 - Argument 1 of CallbackExecutor::execute expects callable(int):string, pure-Closure(string):float provided (see https://psalm.dev/012) $callbackExecutor->execute(fn(string $value) => (float) $value, 1); ------------------------------ 2 errors found ------------------------------ 3 other issues found. You can display them with --show-info=true ------------------------------ Psalm can automatically fix 1 of these issues. Run Psalm again with --alter --issues=MissingReturnType --dry-run to see what it can fix. ------------------------------ Checks took 0.49 seconds and used 75.842MB of memory Psalm was able to infer types for 98.6486% of the codebasePodsumowanie
Pomimo, iż język PHP rozwija się dość dynamicznie, nie dorobił się jeszcze natywnych typów generycznych, które są szeroko używane w takich językach jak Java czy C#. Na szczęście istnieje wiele narzędzi, które umożliwiają imitowanie tych typów, a co za tym idzie, niejako rozszerzenie możliwości języka.
Dzięki nim możemy zmniejszyć powielanie kodu w aplikacji, a także mieć pewność, iż wartości, które przekazujemy, będą typu, którego oczekujemy. Rozwój najpopularniejszego IDE, jakim jest PHPStorm umożliwia integrację z narzędziami typu Psalm, PHPStan, co umożliwia autopodpowiedzi, a także wykrywanie błędów na poziomie edytora.
Integracja narzędzi do statycznej analizy kodu z systemami wdrożeniowymi pomoże nam wykryć nieprawidłowości na etapie budowania aplikacji i uniemożliwi ich wrzucenie na produkcję.