Почему Generics — это не просто модный тренд, а необходимость?
Если вы когда-нибудь писали на PHP код, который работал с коллекциями, репозиториями или сервисным слоем, вы наверняка сталкивались с ситуацией: функция принимает array, а что внутри — загадка. Может, там пользователи, а может, котики. Или, что ещё хуже, — и то, и другое. Именно здесь на помощь приходят Generics (дженерики) — мощный инструмент типизации, который делает ваш код предсказуемым, безопасным и самодокументируемым.
В этой статье мы разберём, как работают generics в PHP, зачем они нужны и как их использовать на реальных проектах. Вы узнаете, как избежать типичных ошибок и сделать ваш код профессиональным. Поехали!
Что такое Generics простыми словами?
Представьте, что вы — разработчик интернет-магазина. У вас есть класс Cart, который хранит товары. Без generics вы пишете:
class Cart {
private array $items;
public function addItem($item): void {
$this->items[] = $item;
}
public function getItems(): array {
return $this->items;
}
}
Проблема? $item может быть чем угодно — числом, строкой, объектом Product или даже null. IDE не подскажет, какой тип возвращается из getItems(). Придётся лезть в документацию или дебажить.
Generics решают эту проблему. Они позволяют указать: «Эй, этот класс работает только с товарами!». Выглядит это так:
class Cart {
/** @param Product[] $items */
private array $items;
public function addItem(Product $item): void {
$this->items[] = $item;
}
/** @return Product[] */
public function getItems(): array {
return $this->items;
}
}
Но это лишь имитация. Настоящие generics в PHP появились с PHP 8.0, а полноценная поддержка — в PHP 8.1+ с помощью атрибутов и статического анализа (например, PHPStan или Psalm).
Как работают Generics в PHP на самом деле?
В отличие от Java или C#, PHP не имеет нативной поддержки generics на уровне синтаксиса (пока). Однако благодаря аннотациям PHPDoc и инструментам статического анализа мы можем получить все преимущества типизации.
Шаблонные типы (Template Types)
Самый популярный подход — использовать @template в PHPDoc. Рассмотрим пример с коллекцией:
/**
* @template T
*/
class Collection {
/** @var array<int, T> */
private array $items = [];
/**
* @param T $item
*/
public function add(mixed $item): void {
$this->items[] = $item;
}
/**
* @return T
*/
public function get(int $index): mixed {
return $this->items[$index] ?? throw new \OutOfBoundsException();
}
}
Теперь при использовании этого класса вы указываете конкретный тип:
/** @var Collection<User> $userCollection */
$userCollection = new Collection();
$userCollection->add(new User('Alice'));
$user = $userCollection->get(0); // PHPStan знает: $user — это User
echo $user->name; // Безопасно!
Ограничения (Constraints) для Generics
Иногда нужно, чтобы тип T не был любым, а реализовывал определённый интерфейс. Это называется bound (ограничение):
/**
* @template T of \JsonSerializable
*/
class JsonCollection {
/** @var array<int, T> */
private array $items = [];
public function toJson(): string {
return json_encode($this->items);
}
}
Теперь вы не сможете добавить в коллекцию объект, который не реализует JsonSerializable. IDE и статический анализатор выдадут ошибку.
Практические примеры: от простого к сложному
Пример 1: Generic-репозиторий
Допустим, вы пишете приложение с несколькими сущностями: User, Product, Order. Вместо того чтобы писать отдельный репозиторий для каждой, используем generics:
/**
* @template T of object
*/
interface RepositoryInterface {
/**
* @param int $id
* @return T|null
*/
public function find(int $id): ?object;
/**
* @param T $entity
*/
public function save(object $entity): void;
}
/**
* @template T of object
* @implements RepositoryInterface<T>
*/
class BaseRepository implements RepositoryInterface {
/** @param class-string<T> $entityClass */
public function __construct(
private string $entityClass
) {}
public function find(int $id): ?object {
// Логика поиска
$data = $this->fetchFromDB($id);
return $data ? new ($this->entityClass)(...$data) : null;
}
public function save(object $entity): void {
// Сохраняем в БД
}
}
// Использование:
/** @var BaseRepository<User> $userRepo */
$userRepo = new BaseRepository(User::class);
$user = $userRepo->find(42); // PHPStan знает: $user — User|null
Пример 2: Generic-сервис с кэшированием
Представьте сервис, который получает данные из API и кэширует их:
/**
* @template T
*/
class CachedService {
/** @param callable(): T $fetcher */
public function __construct(
private mixed $fetcher,
private int $ttl = 3600
) {}
/**
* @return T
*/
public function get(): mixed {
$cacheKey = spl_object_hash($this->fetcher);
if ($cached = $this->getFromCache($cacheKey)) {
return $cached;
}
$result = ($this->fetcher)();
$this->saveToCache($cacheKey, $result);
return $result;
}
}
// Использование:
$weatherService = new CachedService(
fn() => $api->getWeather('Moscow')
);
$weather = $weatherService->get(); // Тип — array|null (зависит от API)
Продвинутые техники: ковариантность и контравариантность
В PHP 8.1+ появилась поддержка ковариантности (когда дочерний тип может быть более специфичным) и контравариантности (когда параметры могут быть менее специфичными). Это важно для generics.
Пример ковариантности с коллекциями:
class Animal {}
class Dog extends Animal {}
/** @template T */
class AnimalShelter {
/** @param T $animal */
public function __construct(
protected mixed $animal
) {}
}
/** @extends AnimalShelter<Dog> */
class DogShelter extends AnimalShelter {
public function __construct(Dog $dog) {
parent::__construct($dog);
}
}
Теперь DogShelter можно использовать там, где ожидается AnimalShelter<Animal> — благодаря ковариантности.
Частые ошибки и как их избежать
- Забыть указать @template — без этого generics не работают. Всегда начинайте с объявления шаблонного типа