Если спросить PHP-разработчиков, какую возможность они хотят увидеть в PHP, большинство назовет дженерики.


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


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


От переводчика: Я умышленно использую кальку с английского "дженерики", т.к. ни разу в общении не слышал, чтобы кто-то называл это "обобщенным программированием".

Содержание:


  • Что такое дженерики
  • Как внедрить дженерики без поддержки языка
  • Стандартизация
  • Поддержка инструментами
  • Поддержка стороннего кода
  • Дальнейшие шаги
  • Ограничения
  • Почему бы вам просто не добавить дженерики в язык?
  • Что, если мне не нужны дженерики?

Что такое дженерики


Данный раздел покрывает краткое введение в дженерики.


Ссылки для чтения:


  • RFC на добавление PHP дженериков
  • Поддержка дженериков в Phan
  • Дженерики и шаблоны в Psalm

Простейший пример


Так как на данный момент невозможно определить дженерики на уровне языка, нам придется воспользоваться другой прекрасной возможностью — определить их в докблоках.


Мы уже используем этот вариант во множестве проектов. Взгляните на этот пример:


/**
 * @param string[] $names
 * @return User[]
 */
function createUsers(iterable $names): array { ... }

В коде выше мы делаем то, что возможно на уровне языка. Мы определили параметр $names как нечто, что может быть перечислено. Также мы указали, что функция вернет массив. PHP выбросит TypeError, если типы параметров и возвращаемое значение не соответствуют.


Докблок улучшает понимание кода. $names должны быть строками, а функция обязана вернуть массив объектов User. Сам по себе PHP не делает таких проверок. А вот IDE, такие как PhpStorm, понимают эту нотацию и предупреждают разработчика о том, что дополнительный контракт не соблюден. В добавок к этому, инструменты статического анализа, такие как Psalm, PHPStan и Phan могут валидировать корректность переданных данных в функцию и из неё.


Дженерики для определения ключей и значений перечисляемых типов


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


/**
 * @return array<string, User>
 */
function getUsers(): array { ... }

Здесь сказано, что массив возвращаемых функцией getUsers имеет строковые ключи и значения типа User.


Статические анализаторы, такие как Psalm, PHPStan и Phan понимают данную аннотацию и учтут ее при проверке.


Рассмотрим следующий код:


/**
 * @return array<string, User>
 */
function getUsers(): array { ... }

function showAge(int $age): void { ... }

foreach(getUsers() as $name => $user) {
  showAge($name);
}

Статические анализаторы выбросят предупреждение на вызове showAge с ошибкой, наподобие такой: Argument 1 of showAge expects int, string provided.


К сожалению, на момент написания статьи PhpStorm этого не умеет.


Более сложные дженерики


Продолжим углубляться в тему дженериков. Рассмотрим объект, представляющий собой стек :


class Stack
{
    public function push($item): void { ... }

    public function pop() { ... }
}

Стек может принимать любой тип объекта. Но что, если мы хотим ограничить стек только объектами типа User?


Psalm и Phan поддерживают следующие аннотации:


/**
 * @template T
 */
class Stack
{
    /**
     * @param T $item
     */
    public function push($item): void;

    /**
     * @return T
     */
    public function pop();
}

Докблок используется для передачи дополнительной информации о типах, например:


/** @var Stack<User> $userStack */
$stack = new Stack();
Means that $userStack must only contain Users.

Psalm, при анализе следующего кода:


$userStack->push(new User());
$userStack->push("hello");

Будет жаловаться на 2 строку с ошибкой Argument 1 of Stack::push expects User, string(hello) provided.


На данный момент PhpStorm не поддерживает данную аннотацию.


На самом деле, мы покрыли только часть информации о дженериках, но на данный момент этого достаточно.


Как внедрить дженерики без поддержки языка


Необходимо выполнить следующие действия:


  • На уровне сообщества определите стандарты дженериков в докблоках (например, новый PSR, либо возврат назад, к PSR-5)
  • Добавьте докблок-аннотации в код
  • Используйте IDE, понимающие эти обозначения, чтобы проводить статический анализ в режиме реального времени, с целью поиска несоответствий.
  • Используйте инструменты статического анализа (такие как Psalm) как один из шагов CI, чтобы отловить ошибки.
  • Определите метод для передачи информации о типах в сторонних библиотеках.

Стандартизация


На данный момент, сообщество PHP уже неофициально приняло данный формат дженериков (они поддерживаются большинством инструментов и их значение понятно большинству):


/**
 * @return User[]
 */
function getUsers(): array { ... }

Тем не менее, у нас есть проблемы с простыми примерами, вроде такого:


/**
 * @return array<string, User>
 */
function getUsers(): array { ... }

Psalm его понимает, и знает, какой тип у ключа и значения возвращаемого массива.


На момент написания статьи, PhpStorm этого не понимает. Используя данную запись я упускаю мощь статического анализа в реальном времени, предлагаемую PhpStorm-ом.


Рассмотрим код ниже. PhpStorm не понимает, что $user имеет тип User, а $name — строковой:


foreach(getUsers() as $name => $user) {
    ...
}

Если бы я выбрал Psalm как инструмент статического анализа, я бы мог написать следующее:


/**
 * @return User[]
 * @psalm-return array<string, User>
 */
function getUsers(): array { ... }

Psalm все это понимает.


PhpStorm знает, что переменная $user относится к типу User. Но, он все еще не понимает, что ключ массива относится к строке. Phan и PHPStan не понимают специфичные аннотации psalm. Максимум, который они понимают в данном коде такой же, как в PhpStorm: the type of $user


Вы можете утверждать, что PhpStorm'у просто стоит принять соглашение array<keyType, valueType>. Я с вами не соглашусь, т.к. считаю, что это диктование стандартов — задача языка и сообщества, а инструменты лишь должны им следовать.


Я предполагаю, что описанное выше соглашение будет тепло встречено большей частью PHP-сообщества. Той, которую интересуют дженерики. Тем не менее, все становится гораздо сложнее, когда речь идет о шаблонах. В настоящее время ни PHPStan, ни PhpStorm не поддерживают шаблоны. В отличие от Psalm и Phan. Их назначение схоже, но если вы копнете глубже, то поймете, что реализации немного отличаются.


Каждый из представленных вариантов является своего рода компромиссом.


Проще говоря, есть потребность в соглашении о формате записи дженериков:


  • Они улучшают жизнь разработчиков. Разработчики могут добавить дженерики в свой код и получить от этого пользу.
  • Разработчики могут использовать инструменты, которые им больше нравятся и переключаться между ними (инструментами) по мере необходимости.
  • Создатели инструментов могут создавать эти самые инструменты, понимая пользу для сообщества и не опасаясь того, что что-то изменится, или что их обвинят в "неправильном подходе".

Поддержка инструментами


Psalm имеет всю необходимую функциональность для проверки дженериков. Phan вроде как, тоже.


Я уверен, что PhpStorm внедрит дженерики как только в сообществе появится соглашении о едином формате.


Поддержка стороннего кода


Завершающая часть головоломки дженериков — это добавление поддержки сторонних библиотек.


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


Что произойдет, если ваш проект будет опираться на работу сторонних библиотек, не имеющих поддержку дженериков?


К счастью, данная проблема уже решена, и решением этим являются функции-заглушки. Psalm, Phan и PhpStorm поддерживают заглушки.


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


class Stack
{
    public function push($item)
    {
         /* some implementation */
    }

    public function pop()
    {
         /* some implementation */
    }
}

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


/**
 * @template T
 */
class Stack
{
    /**
     * @param T $item
     * @return void
     */
    public function push($item);

    /**
     * @return T
     */
    public function pop();
}

Когда статический анализатор видит класс стека, он берет информацию о типах из заглушки, а не из реального кода.


Возможность просто делиться кодом заглушек (например, через composer) была бы крайне полезна, т.к. позволяла бы делиться проделанной работой.


Дальнейшие шаги


Сообществу нужно отойти от соглашений и определить стандарты.


Может быть, лучшим вариантом будет PSR про дженерики?


Или, может быть, создатели основных статических анализаторов, PhpStorm, других IDE и кто-либо из людей, причастных к разработке PHP (для контроля) могли бы разработать стандарт, которым бы пользовались все.


Как только стандарт появится, все смогут помочь с добавлением дженериков в существующие библиотеки и проекты, создавая Pull Request'ы. А там, где это невозможно, разработчики могут писать и обмениваться заглушками.


Когда все будет сделано, мы сможем пользоваться инструментами вроде PhpStorm для проверки дженериков в режиме реального времени, пока пишем код. Мы можем использовать инструменты статического анализа как часть нашего CI в качестве гарантии безопасности.


Кроме того, дженерики могут быть реализованы и в PHP (ну, почти).


Ограничения


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


Тем не менее, основное применение подобного анализа — проверка вашей бизнес-логики. Если вы пишете чистый код, то не стоит использовать слишком много магии.


Почему бы вам просто не добавить дженерики в язык?


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


Что, если мне не нужны дженерики?


Просто игнорируйте все вышесказанное. Одно из главных преимуществ PHP в том, что он гибок в выборе подходящего уровня сложности реализации в зависимости от того, что вы создаете. С одноразовым кодом не нужно думать о таких вещах, как тайпхинтинг. А вот в больших проектах стоит использовать такие возможности.


Спасибо всем дочитавшим до этого места. Буду рад вашим замечаниям в ЛС.

Комментарии (17)


  1. m0rtis
    17.06.2019 20:44
    +1

    Утром просмотрел по дороге на работу PHP-дайджест, добавил оригинальную статью в закладки вечером почитать, а вечером уже и перевод готов:))
    Спасибо, автор!


  1. Maksclub
    17.06.2019 23:40

    К сожалению, на момент написания статьи PhpStorm этого не умеет.

    К сожалению на момент написания этой статьи PhpStorm это умеет. Да-да перевод, все дела, но дата у статьи на Хабре сегодняшняя, а в статье аж 11 упоминаний про бесполезность Шторма


    1. berezuev Автор
      18.06.2019 00:34

      PhpStorm это умеет.

      Не поленился и скачал EAP версию шторма.
      Увы, но нет, не умеет


      1. vp_arth
        18.06.2019 08:43

        Видимо со скалярными типами пока беда. Пример попроще тоже не ругает:

        function showAge(int $age){}
        showAge('s');
        


        1. berezuev Автор
          18.06.2019 09:11

          В данном случае, все верно. На ваш пример Шторм будет ругаться, если добавить

          declare(strict_types=1);


          1. vp_arth
            18.06.2019 10:58

            Действительно. Спасибо, всё время про него забываю.
            Когда его уже сделают по умолчанию, в php8 не планируют?


      1. Maksclub
        18.06.2019 09:38

        хм, я точно помню, что простые на работе делал Object[] и подсвечивало
        сейчас проверил — ничего не светит

        тогда приношу извинения m0rtis, мой комментарий не верный


        1. berezuev Автор
          18.06.2019 09:54

          По ходу дела вы сами запутались. В статье сказано, что Шторм не понимает запись вида array<string,User>. И вы приложили цитату из статьи, относящуюся к этой аннотации.


          Аннотации вида Object[] прекрасно обрабатываются, и об этом прямо сказано в начале статьи.


          1. Maksclub
            18.06.2019 10:13

            все так, запутался


      1. Compolomus
        18.06.2019 16:21

        Такой фокус можно провернуть через map указав типы в аргументах


  1. Blurayman
    17.06.2019 23:54

    Автор оригинала приезжал в Москву на PHPRussia2019 и рассказывал про статические анализаторы, которые упоминаются в статье.


    Холиварный вопрос: мне кажется, или это оверинжиниринг, указывать в дженерики произвольный тип значений в стеке из примера в статье? PHP же и так динамически типизирован. Или это имеется ввиду типизированный стек?


    1. Iv38
      18.06.2019 00:49

      Имеется ввиду типизированный стек. Переменная стека при объявлении содержит аннотацию, указывающую тип данных хранящихся в конкретном стеке:

      /** @var Stack<User> $userStack */
      $userStack = new Stack();

      Анализатор использует эту информацию для контроля типов при использовании данного стека.


  1. Compolomus
    18.06.2019 16:15

    Ох уж это программирование аннотациями


  1. AxisPod
    18.06.2019 17:19

    PHP всё больше и больше похож на Франкенштейна. Синтаксис просто вырви мозг. Больше похоже на развитие в сторону Objective-C. Почему нельзя было сделать неймспейсы как в других языках, учитывая, что PHP С подобный, а теперь ещё дженерики в комментах. Вообще впечатление такое, что развитие PHP отдали Гомеру Симпсону (серия про то, как он конструировал автомобиль).


  1. vav180480_2
    19.06.2019 11:40
    -2

    Дженерики это ВНЕЗАПНО генераторы:) кода в строго типизованных компилируемых языках по шаблонам с некими обобщенными типами. PHP интерпретируемый язык с динамической типизацией, т.е. все что вы на нем напишете — уже дженерик в вышеописанных терминах:) Введение т.н. «дженериков» в PHP выглядит как попытка примазаться к хайповому термину как у взрослых дядей. Работать я так понимаю, это будет на уровне IDE, и никак не будет если я правлю код в блокнотике, меня там ничто не ограничит и не остановит. Так то это просто некая продвинутая проверка типа на уровне IDE, и у нее должен быть свой термин, а не дженерик, который уже есть по дефолту, нет?


    1. berezuev Автор
      19.06.2019 11:42
      +1

      Чушь какую-то написали, не подумав


      1. vav180480_2
        19.06.2019 11:51
        -1

        Чушь какую-то написали, не подумав


        image

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