Погружаемся в дженерики

PHP Дженерики в PHP

1 апреля 2022 г.

Погружаемся в дженерики
Перевод статьи «Generics in depth»

Погружаемся в дженерики

В предыдущей статье мы рассмотрели довольно скучный пример дженериков, в этой статье давайте рассмотрим примеры получше.

<?php

$users = new Collection<User>();

$slugs = new Collection<string>();

Коллекции — пожалуй, самый простой способ объяснить, что такое дженерики, когда обсуждают дженерики, коллекции часто приводятся в пример. На самом деле нередко люди думают, что "дженерики" и "коллекции с указанием типа" — одно и то же. Это определённо не так.

Итак, давайте рассмотрим ещё два примера.

Функция app — если вы работаете с фреймворком Laravel, она может показаться вам знакомой: функция принимает имя класса и возвращает экземпляр этого класса, используя контейнер зависимостей:

<?php

function app(string $className): mixed
{
    return Container::get($className);
}

Вам не нужно знать, как работает контейнер, важно то, что функция возвращает экземпляр класса, который вы запросили.

Так что, по сути, это дженериковая функция, у которой возвращаемый тип зависит от передаваемого имени класса.

Было бы здорово, если бы IDE и другие статические анализаторы также понимали, что при передаче функции имени класса UserRepository, мы ожидаем возвращения экземпляра UserRepository и ничего больше:

<?php

function app(string $className): mixed
{ /* … */ }

app(UserRepository::class); // ?

Дженерики позволяют нам это сделать.

Думаю, сейчас самое время упомянуть, что я немного слукавил: ранее я говорил, что в PHP дженериков не существуют, но это не совсем так.

Все статические анализаторы — инструменты, которые читают код, не запуская, инструменты вроде IDE — согласились использовать для дженериков Docblock-аннотацию:

/**
 * @template Type
 * @param class-string<Type> $className
 * @return Type
 */
function app(string $className): mixed
{ /* … */ }

Согласен, это не самый красивый синтаксис и все статические анализаторы полагаются на простое соглашение — официальной спецификации нет; тем не менее, это работает.

PhpStorm, Psalm и PhpStan — три крупнейших статических анализатора в мире PHP — в той или иной степени понимают этот синтаксис.

IDE, такие как PhpStorm, используют его для обратной связи с программистом во время написания кода, а инструменты, такие как Psalm и PhpStan, используют его для массового анализа вашей кодовой базы и обнаружения потенциальных ошибок, преимущественно на основе объявления типов.

Так что на самом деле мы можем реализовать эту функцию, чтобы наши инструменты больше не работали вслепую.

Непосредственно PHP не гарантирует, что возвращаемый тип будет правильным — PHP делает никаких проверок типа во время выполнения этой функции, но если мы можем доверять нашим статическим анализаторам, то вероятность того, что этот код сломается во время выполнения, очень мала, а то и вовсе отсутствует.

В этом заключается невероятная сила статического анализа: мы можем быть уверены, что, не запуская код, большая его часть будет работать так, как задумано.

И всё это благодаря типам, включая дженерики.

Давайте рассмотрим более сложный пример:

<?php

Attributes::in(MyController::class)
    ->filter(RouteAttribute::class)
    ->newInstance()
    ->

У нас есть класс, который может "запрашивать" атрибуты и создавать их на лету.

Если вы уже работали с атрибутами, то знаете, что Reflection API довольно многословен, поэтому такой класс-помощник будет весьма полезен.

Как вы уже догадались, дженерики позволяют нам сделать это:

При использовании метода filter, задаётся имя класса атрибута и после вызова метода newInstance, результатом будет экземпляр отфильтрованного класса. И снова, было бы неплохо, если бы наша IDE понимала, о чём мы говорим.

Как вы уже догадались, дженерики позволяют нам это сделать:

<?php

/** @template AttributeType */
class Attributes
{
    /**
     * @template InputType
     * @param class-string<InputType> $className
     * @return self<InputType>
     */
    public function filter(string $className): self
    { /* … */ }

    /**
     * @return AttributeType 
     */   
    public function newInstance(): mixed
    { /* … */ }

    // …
}

Надеюсь, вы начинаете понимать, насколько мощной может быть простая информация о типе. Пару лет назад мне потребовался бы плагин для IDE, чтобы подобные вещи заработали, теперь же мне просто нужно добавить информацию о типе.

Однако в последнем примере показаны не только на дженерики, есть ещё одна не менее важная составляющая.

Вывод типа: способность статического анализатора «угадывать» или надёжно определять тип без указания его пользователем.

Именно это происходит с аннотацией class-string. IDE способна распознать входные данные, которые передаются функции, как имя класса и вывести этот тип в качестве типа с дженериком.

Итак, подытожим: дженерики доступны в PHP и все основные статические анализаторы знают, как с ними работать. Но... есть пара оговорок.

Во-первых, не существует официальной спецификации того, как должны выглядеть дженерики, прямо сейчас каждый статический анализатор может использовать свой собственный синтаксис; на данный момент они пришли к единому соглашению, но нет никаких гарантий, что так и останется в будущем.

Во-вторых, Docblock-аннотации, на мой взгляд, неоптимальные. Они ощущаются как незначительная часть кодовой базы. И да, аннотации дженериков предоставляют только статическую информацию, без проверки типов во время выполнения, но мы увидели, насколько мощным может быть статический анализ, даже без такой проверки.

Я думаю, что несправедливо относиться к информации о типах как к «doc-комментариям» — они не передают важности типов в нашем коде. Именно поэтому в PHP 8 появились атрибуты: все возможности, которые открывают атрибуты, уже были возможны с помощью Docblock-аннотаций, но эта реализация была недостаточно хороша. То же самое относится и к дженерикам.

И наконец, без соответствующей спецификации у всех трёх основных статических анализаторов есть различия в реализации дженериков. В идеале, должна быть официальная спецификация, исходящая от внутренних разработчиков PHP. Сейчас её нет.

Это основные причины, по которым я считаю, что стоит инвестировать время в более постоянное и устойчивое решение.

Так почему же в PHP до сих пор нет подходящих дженериков? Почему мы полагаемся на Docblock-аннотации без чёткой спецификации?

Об этом в следующей статье.