Введение: Почему PHP-разработчики мечтают о дженериках
Если вы когда-нибудь писали на Java, C# или TypeScript, а потом возвращались к PHP, вы наверняка испытывали лёгкое разочарование. В тех языках есть элегантная система обобщённых типов (Generics), которая позволяет создавать гибкие, но при этом безопасные с точки зрения типов структуры данных. В PHP же долгое время царила "типизационная анархия": массивы могли содержать что угодно, а IDE подсвечивала только самые очевидные ошибки.
Но времена меняются. Начиная с PHP 7.0 и особенно в эпоху PHP 8.0/8.1/8.2, сообщество нашло способ использовать Generics в PHP. Да, на уровне синтаксиса языка их до сих пор нет (и, скорее всего, не будет в ближайшие годы), но это не значит, что вы не можете пользоваться всеми преимуществами обобщённого программирования уже сегодня.
В этой статье мы разберём, как работает типизация в PHP с Generics, какие инструменты для этого существуют (PHPStan, Psalm), и как писать код, который будет одновременно гибким и безопасным. Пристегнитесь — будет жарко!
Что такое Generics и зачем они нужны в PHP?
Generics (обобщённые типы) — это механизм, который позволяет параметризовать классы, интерфейсы и функции типами. Вместо того чтобы писать отдельную реализацию для каждого типа данных, вы создаёте одну универсальную, а конкретный тип подставляете при использовании.
Представьте, что вы пишете класс для очереди (Queue). Без дженериков вы можете хранить в ней только один тип данных или использовать mixed (что убивает всю типизацию). С Generics вы можете сказать: "Это очередь целых чисел" или "Это очередь объектов User".
Проблема "сырой" типизации
Посмотрите на этот типичный PHP-код:
<?php
class Collection {
private array $items = [];
public function add($item): void {
$this->items[] = $item;
}
public function get(int $index) {
return $this->items[$index] ?? null;
}
}
$users = new Collection();
$users->add(new User('Alice'));
$users->add(new User('Bob'));
$users->add('это строка, а не пользователь'); // Ошибка! Но PHP промолчит
$user = $users->get(2); // Вернёт строку, IDE ничего не заподозрит
$user->getName(); // Фатальная ошибка во время выполнения
Это классическая проблема PHP-разработчика: отсутствие контроля типов на этапе написания кода. Вы узнаёте об ошибке только в рантайме, когда пользователь уже нажал на кнопку. Generics решают эту проблему на корню.
Generics в PHP: Как это работает на практике
Поскольку нативный синтаксис Generics в PHP отсутствует, мы используем аннотации в PHPDoc, которые обрабатываются статическими анализаторами — PHPStan и Psalm. Эти инструменты проверяют код на соответствие объявленным типам, фактически эмулируя работу дженериков.
Базовый синтаксис: шаблонные типы
Предположим, мы хотим сделать нашу коллекцию типобезопасной. Вот как это выглядит с использованием Generics через PHPDoc:
<?php
/**
* @template T
*/
class GenericCollection {
/** @var array<int, T> */
private array $items = [];
/**
* @param T $item
*/
public function add($item): void {
$this->items[] = $item;
}
/**
* @param int $index
* @return T|null
*/
public function get(int $index) {
return $this->items[$index] ?? null;
}
}
// Теперь используем:
/** @var GenericCollection<User> $users */
$users = new GenericCollection();
$users->add(new User('Alice'));
$users->add('строка'); // PHPStan выдаст ошибку: Expected type User, got string
$user = $users->get(0); // PHPStan знает, что это User
echo $user->getName(); // IDE подсказывает метод
Магия в том, что анализатор "запоминает": если вы создали GenericCollection<User>, то все методы add принимают только User, а get возвращает User. Ключевые слова @template и @param/@return с указанием типа T — это основа Generics в PHP.
Ограничения типов (Type Constraints)
Иногда нужно, чтобы T был не любым типом, а, например, только объектами, реализующими интерфейс EntityInterface. Для этого используется конструкция @template T of SomeType:
<?php
interface EntityInterface {
public function getId(): int;
}
/**
* @template T of EntityInterface
*/
class EntityRepository {
/** @var array<int, T> */
private array $entities = [];
/**
* @param T $entity
*/
public function save($entity): void {
$this->entities[$entity->getId()] = $entity;
}
/**
* @param int $id
* @return T|null
*/
public function find(int $id) {
return $this->entities[$id] ?? null;
}
}
class User implements EntityInterface {
public function getId(): int { return 1; }
public function getName(): string { return 'Alice'; }
}
/** @var EntityRepository<User> $userRepo */
$userRepo = new EntityRepository();
$userRepo->save(new User());
$user = $userRepo->find(1); // Тип — User, можно вызывать getName()
Это мощный инструмент: вы говорите анализатору: "T может быть только EntityInterface", и он строго следит за этим.
Продвинутые техники: Ковариантность и контравариантность
Когда вы начинаете активно использовать Generics в PHP, рано или поздно столкнётесь с вопросами наследования типов. Можно ли передать Collection<Dog> туда, где ожидается Collection<Animal>? Ответ зависит от того, как вы объявите ваш generic.
Инвариантность (по умолчанию)
По умолчанию PHPStan и Psalm считают дженерики инвариантными. Это значит, что Collection<Dog> — это совсем не то же самое, что Collection<Animal>. И это правильно: если бы вы могли положить Cat в Collection<Dog> через Collection<Animal>, была бы катастрофа.
Ковариантность (out)
Если ваш generic-класс только отдаёт значения типа T (например, итератор или провайдер), вы можете пометить его как ковариантный с помощью @template-covariant T. Тогда Provider<Dog> будет считаться подтипом Provider<Animal>.
<?php
/**
* @template-covariant T
*/
class Result {
/**
* @param T $value
*/
public function __construct(private $value) {}
/**
* @return T
*/
public function get