Обобщения в PHP с использованием PHPDocs

Два года назад я написал впечатляющую статью о типах объединений и пересечений. Это помогло PHP-сообществу ознакомиться с этими концепциями, что в конечном итоге привело к поддержке типов пересечений в PhpStorm.

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

Сегодня у меня аналогичная цель. Дженерики появятся в PHPStan 0.12 позже на этой неделе, я хочу объяснить, что они собой представляют, и заинтересовать всех.

Бесконечное количество подписей

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

/**
 * @param string $param
 * @return string
 */
function foo($param)
{
    ...
}

Эти типы высечены в камне. Если у вас есть функция, которая возвращает разные типы на основе типов аргументов, переданных при вызове функции, вам придется прибегнуть к возврату типа объединения или более общего типа, такого как объект или смешанный:

function findEntity(string $className, int $id): ?object
{
    // finds and returns an entity object based on $className
    // and primary key in $id
}

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

Переменные типа

Эти правила определены с использованием переменных типа. Этот термин также используется в других языках, в которых есть родовые типы. В PHPDocs мы помечаем их тегом @template. Рассмотрим функцию, возвращающую тот же тип, который она принимает:

/**
 * @template T
 * @param T $a
 * @return T
 */
function foo($a)
{
    return $a;
}

Имя переменной типа может быть любым, если вы не используете существующее имя класса.

Вы также можете ограничить, какие типы могут использоваться вместо переменной типа с верхней границей, используя ключевое слово of:

/**
 * @template T of \Exception
 * @param T $exception
 * @return T
 */
function foo($exception)
{
    ...
}

Эта функция будет принимать и возвращать только объекты классов, расширяющих Exception.

Имена классов

Если вы хотите включить имя класса в разрешение типа, вы можете использовать для этого псевдотип class-string:

/**
 * @template T
 * @param class-string<T> $className
 * @param int $id
 * @return T|null
 */
function findEntity(string $className, int $id)
{
    // ...
}

Если вы затем вызовете findEntity(Article::class, 1), PHPStan узнает, что вы получаете объект Article или null!

Пометка возвращаемого типа как T[] (например, для findAll()function) приведет к выводу, что возвращаемый тип представляет собой массив статей.

Дженерики уровня класса

До этого момента я писал только о дженериках на уровне функций или методов. Мы также можем поместить @template над классом или интерфейсом:

/**
 * @template T
 */
interface Collection
{
}

Затем укажите переменную типа над свойствами и методами:

/**
 * @param T $item
 */
public function add($item): void;
/**
 * @return T
 */
public function get(int $index);

Типы Коллекции можно указать, когда вы набираете ее где-нибудь еще:

/**
 * @param Collection<Dog> $dogs
 */
function foo(Collection $dogs)
{
    // Dog expected, Cat given    
    $dogs->add(new Cat());
}

При реализации универсального интерфейса или расширении универсального класса у вас есть два варианта:

  • Сохраните универсальность родительского класса, дочерний класс также будет универсальным
  • Укажите переменную типа интерфейса / родительского класса. Дочерний класс не будет универсальным.

Чтобы сохранить универсальность, необходимо повторить те же теги @template над дочерним классом и передать их тегам @extends и @implements:

/**
 * @template T
 * @implements Collection<T>
 */
class PersistentCollection implements Collection
{
}

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

/**
 * @implements Collection<Dog>
 */
class DogCollection implements Collection
{
}

Ковариация и контравариантность

Есть еще один вариант использования, который решает универсальный вариант, но сначала мне нужно объяснить эти два термина. Ковариация и контравариантность описывают отношения между родственными типами.

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

Тип контравариантен, если он более общий по отношению к своему дочернему классу или реализации.

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

Тип параметра должен быть контравариантным.

Допустим, у нас есть интерфейс под названием DogFeeder, и везде, где вводится DogFeeder, код может передавать любую Dog методу подачи:

interface DogFeeder
{
    function feed(Dog $dog);
}
function feedChihuahua(DogFeeder $feeder)
{
    $feeder->feed(new Chihuahua()); // this code is OK
}

Если мы реализуем BulldogFeeder, который сужает тип параметра (он ковариантен, а не контравариантен!), У нас возникает проблема. Если мы передадим BulldogFeeder в feedChihuahua()функцию, код выйдет из строя, потому что BulldogFeeder::feed() не принимает чихуахуа:

class BulldogFeeder implements DogFeeder
{
    function feed(Bulldog $dog) { ... }
}
feedChihuahua(new BulldogFeeder()); // 💥

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

С другой стороны, если мы реализуем DogFeeder с более общим типом, чем Dog, скажем, Animal, у нас все в порядке:

class AnimalFeeder implements DogFeeder
{
    public function feed(Animal $animal) { ... }
}

Этот класс принимает всех собак, а также всех животных. Животное контравариантно Собаке.

Тип возврата должен быть ковариантным

С возвращаемыми типами дело обстоит иначе. Типы возврата могут быть более конкретными в дочерних классах. Допустим, у нас есть интерфейс DogShelter:

interface DogShelter
{
    function get(): Dog;
}

Когда класс реализует этот интерфейс, мы должны убедиться, что все, что он возвращает, все еще может bark(). Было бы неправильно возвращать что-то менее конкретное, например, животное, но нормально вернуть чихуахуа.

Эти правила полезны, но иногда ограничивают

Иногда у меня возникает соблазн использовать ковариантный тип параметра, даже если он запрещен. Допустим, у нас есть потребительский интерфейс для приема сообщений RabbitMQ:

interface Consumer
{
    function consume(Message $message);
}

Когда мы реализуем интерфейс для приема сообщений определенного типа, у нас возникает соблазн указать его в типе параметра:

class SendMailMessageConsumer implements Consumer
{
    function consume(SendMailMessage $message) { ... }
}

Что недопустимо, потому что тип не контравариантен. Но мы * знаем *, что этот потребитель не будет вызываться с сообщениями другого типа, благодаря тому, как мы реализовали наш код инфраструктуры.

Что мы можем с этим поделать?

Один из вариантов - закомментировать метод в интерфейсе и игнорировать тот факт, что мы будем вызывать неопределенный метод:

interface Consumer
{
    // function consume(Message $message);
}

Но это опасная территория.

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

/**
 * @template T of Message
 */
interface Consumer
{
  /**
   * @param T $message
   */
  function consume(Message $message);
}

Реализация потребителя указывает тип сообщения с помощью тега @implements:

/**
 * @implements Consumer<SendMailMessage>
 */
class SendMailMessageConsumer implements Consumer
{
    function consume(Message $message) { ... }
}

Мы можем опустить метод PHPDoc, и PHPStan все равно будет знать, что $message может быть только SendMailMessage. Он также проверит все вызовы SendMailMessageConsumer, чтобы сообщить, передан ли в метод только SendMailMessagetype.

Если вы используете IDE и хотите воспользоваться преимуществами автозаполнения, вы можете добавить @param SendMailMessage $message над методом.

Этот способ полностью безопасен по типу. PHPStan сообщит о любых нарушениях, которые не соответствуют системе типов. Даже Барбара Лискова довольна.

Совместимость IDE

К сожалению, IDE текущего поколения не понимают @template и связанные с ним теги. Вы можете использовать переменные типа только внутри тегов с префиксом @phpstan- и оставить теги без префикса с типами, которые сегодня понимают IDE и другие инструменты:

/**
 * @phpstan-template T of \Exception
 *
 * @param \Exception $param
 * @return \Exception
 *
 * @phpstan-param T $param
 * @phpstan-return T
 */
function foo($param) { ... }

Типобезопасные итераторы и генераторы

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

iterable<Value>
iterable<Key, Value>
Traversable<Value>
Traversable<Key, Value>
Iterator<Value>
Iterator<Key, Value>
IteratorAggregate<Value>
IteratorAggregate<Key, Value>

Генератор - сложная функция PHP. Помимо перебора генератора и получения его ключей и значений, вы также можете отправлять ему значения и даже использовать ключевое слово return помимо yield в том же теле метода. Вот почему ему нужна более сложная универсальная подпись:

Generator<TKey, TValue, TSend, TReturn>

И PHPStan может все это проверить. Попробуйте это на онлайн-площадке PHPStan:

Твоя очередь!

Теперь, когда вы понимаете, для чего нужны дженерики, вам нужно придумать возможные варианты использования внутри кодовых баз, с которыми вы работаете. Они позволяют описывать более конкретные типы, поступающие в функции и методы и из них. Так что везде, где вы в настоящее время используете mixed и object, но могли бы воспользоваться преимуществами более точных типов, обобщения могут пригодиться. Они обеспечивают безопасность типов в недоступных для других местах местах.

На этой неделе выходит PHPStan 0.12 с поддержкой дженериков (и многое другое!).

Вы любите PHPStan и пользуетесь им каждый день? Рассмотрите возможность поддержки дальнейшего развития PHPStan на GitHub Sponsors. Я был бы очень признателен!

Если вы разработчик PHP, попробуйте PHPStan. Если вас интересуют различные идеи о разработке программного обеспечения, подписывайтесь на меня в Twitter.