PHP дженерики уже сегодня (ну, почти)

в 17:32, , рубрики: generics, php, дженерики, обобщённое программирование, Программирование

Если спросить 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 в том, что он гибок в выборе подходящего уровня сложности реализации в зависимости от того, что вы создаете. С одноразовым кодом не нужно думать о таких вещах, как тайпхинтинг. А вот в больших проектах стоит использовать такие возможности.

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

Автор: berezuev

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js