Обобщения в 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, чтобы сообщить, передан ли в метод только SendMailMessage
type.
Если вы используете 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.