Genéricos y tipos paramétricos en PHP: guía detallada
Что это такое и зачем? (аналогия из реальной жизни)
Представь, что ты — владелец небольшого склада. Ты получаешь коробки с разными вещами: игрушки, книги, инструменты. Чтобы не запутаться, ты ставишь коробки на стеллажи. Но у тебя есть одна проблема: для каждого типа вещей ты заводишь отдельный стеллаж. Стеллаж для игрушек, стеллаж для книг, стеллаж для инструментов. А если завтра привезут одежду? Придётся строить новый стеллаж.
А теперь представь, что у тебя есть универсальный стеллаж. Он может хранить любые вещи. Ты просто говоришь: «Этот стеллаж — для игрушек», а завтра: «Этот стеллаж — для книг». Ты не строишь новый стеллаж каждый раз — ты используешь один и тот же, но говоришь ему, что именно хранить. Вот это и есть дженерики (обобщённые типы) в программировании.
В PHP дженерики позволяют создавать классы, интерфейсы и функции, которые работают с разными типами данных, но при этом сохраняют строгую типизацию. Раньше, если ты хотел сделать коллекцию для чисел, а потом для строк, тебе приходилось писать два отдельных класса. С дженериками ты пишешь один класс, а тип данных передаёшь как параметр.
💡 Важно: Дженерики в PHP появились только в версии 8.0 (2020 год) и были значительно улучшены в версии 8.1. Если ты используешь старый PHP (7.4 и ниже), дженерики работать не будут.
Зачем это нужно? Представь, что ты пишешь функцию, которая сортирует массив. Без дженериков ты можешь передать массив чисел, строк или даже объектов — и всё будет работать. Но если ты случайно передашь число вместо массива, PHP выдаст ошибку только во время выполнения программы. С дженериками ты можешь сказать: «Эта функция принимает только массив целых чисел» — и ошибка будет найдена ещё до запуска, на этапе написания кода.
Теория: как это работает изнутри
Чтобы понять дженерики, нужно разобраться с двумя концепциями: параметрический полиморфизм и стирание типов.
Параметрический полиморфизм — это умный термин, который означает: «один и тот же код работает с разными типами данных». В PHP это реализуется через параметры типа. Ты пишешь класс Collection<T>, где T — это placeholder (заполнитель), который заменяется реальным типом при использовании: Collection<int>, Collection<string>.
Стирание типов — это особенность PHP (и многих других языков, например Java). Когда твой код компилируется (превращается в байт-код), информация о дженериках стирается. То есть во время выполнения программы PHP не знает, что ты использовал Collection<int> — он видит просто Collection. Это сделано для обратной совместимости и производительности.
⚠️ Подводный камень: Из-за стирания типов ты не можешь написатьnew T()илиinstanceof Tвнутри дженерик-класса. PHP просто не знает, что такое T во время выполнения.
Как это работает на уровне кода? Когда ты пишешь:
class Box {
public function __construct(
private mixed $value
) {}
public function get(): mixed {
return $this->value;
}
}
Ты можешь положить в Box что угодно: число, строку, объект. Но когда ты достаёшь значение, ты не знаешь, какой у него тип. Приходится проверять: if (is_int($box->get())). Это неудобно и ведёт к ошибкам.
С дженериками ты пишешь:
/**
* @template T
*/
class Box {
public function __construct(
private T $value
) {}
public function get(): T {
return $this->value;
}
}
Теперь, когда ты создаёшь new Box<int>(42), PHP знает, что get() вернёт int. Если ты попытаешься передать строку в Box<int>, среда разработки (IDE) покажет ошибку.
Важно понимать: дженерики в PHP — это синтаксический сахар для статического анализа. Они не меняют поведение программы во время выполнения, но делают код безопаснее и понятнее.
Синтаксис в PHP: все формы и варианты
В PHP дженерики реализуются через аннотации PHPDoc (теги в комментариях) и атрибуты (начиная с PHP 8.0). Полноценного синтаксиса, как в Java (List<String>), в PHP пока нет, но есть несколько способов.
1. Базовый синтаксис с @template
Самый распространённый способ — использовать тег @template в PHPDoc:
/**
* @template T
*/
class Repository {
/**
* @param T $item
*/
public function save($item): void {
// сохраняем элемент
}
/**
* @return T|null
*/
public function find(int $id): mixed {
// ищем элемент
}
}
Здесь T — это параметр типа. При использовании ты указываешь конкретный тип:
/** @var Repository<User> $userRepo */
$userRepo = new Repository();
$userRepo->save(new User()); // OK
$userRepo->save("строка"); // Ошибка в IDE
2. Множественные параметры типа
Можно использовать несколько параметров:
/**
* @template K
* @template V
*/
class Map {
/**
* @param K $key
* @param V $value
*/
public function set($key, $value): void {}
/**
* @param K $key
* @return V|null
*/
public function get($key): mixed {}
}
Использование:
/** @var Map<string, int> $scores */
$scores = new Map();
$scores->set("Alice", 100); // OK
$scores->set(123, "test"); // Ошибка
3. Ограничения типов (extends)
Можно ограничить, какие типы можно использовать:
/**
* @template T of JsonSerializable
*/
class JsonCollection {
/** @param T[] $items */
public function __construct(
private array $items
) {}
public function toJson(): string {
return json_encode($this->items);
}
}
Теперь T может быть только классом, реализующим JsonSerializable.
4. Ковариантность и контравариантность
Это сложные термины, но суть проста:
- Ковариантность — если у тебя есть
Box<Cat>, его можно использовать какBox<Animal>(если Cat — подтип Animal). - Контравариантность — наоборот:
Box<Animal>можно использовать какBox<Cat>.
В PHP ковариантность работает для возвращаемых типов, контравариантность — для параметров:
/**
* @template-covariant T
*/
interface Producer {
/** @return T */
public function produce(): mixed;
}
/**
* @template-contravariant T
*/
interface Consumer {
/** @param T $item */
public function consume($item): void;
}
5. Встроенные дженерики PHP
Некоторые классы PHP уже используют дженерики:
| Класс | Описание | Пример |
|---|---|---|
ArrayIterator |
Итератор для массивов | ArrayIterator<int, string> |
Generator |
Генератор | Generator<int, string> |
Closure |
Замыкание | Closure(int): string |
Примеры кода — от простого к сложному
Пример 1: Простая коробка (Box)
/**
* @template T
*/
class Box {
/** @var T */
private mixed $value;
/**
* @param T $value
*/
public function __construct(mixed $value) {
$this->value = $value;
}
/**
* @return T
*/
public function get(): mixed {
return $this->value;
}
}
// Использование:
/** @var Box<int> $intBox */
$intBox = new Box(42);
$number = $intBox->get(); // $number — int
/** @var Box<string> $stringBox */
$stringBox = new Box("Привет");
$text = $stringBox->get(); // $text — string
// Ошибка: нельзя передать строку в Box<int>
// $intBox = new Box("строка"); // IDE покажет предупреждение
Объяснение: Мы создали класс Box, который может хранить значение любого типа. Параметр T указывает, какой именно тип будет храниться. При создании объекта мы явно указываем тип в комментарии (@var Box<int>), и IDE проверяет соответствие.
Пример 2: Пара ключ-значение (Pair)
/**
* @template K
* @template V
*/
class Pair {
/** @var K */
private mixed $key;
/** @var V */
private mixed $value;
/**
* @param K $key
* @param V $value
*/
public function __construct(mixed $key, mixed $value) {
$this->key = $key;
$this->value = $value;
}
/** @return K */
public function getKey(): mixed {
return $this->key;
}
/** @return V */
public function getValue(): mixed {
return $this->value;
}
}
// Использование:
/** @var Pair<string, int> $pair */
$pair = new Pair("возраст", 25);
echo $pair->getKey(); // "возраст" (string)
echo $pair->getValue(); // 25 (int)
Объяснение: Здесь два параметра типа: K для ключа и V для значения. Это позволяет создать типобезопасную пару, где ключ всегда строка, а значение — число.
Пример 3: Коллекция с фильтрацией
/**
* @template T
*/
class Collection {
/** @var T[] */
private array $items;
/** @param T[] $items */
public function __construct(array $items = []) {
$this->items = $items;
}
/** @param T $item */
public function add(mixed $item): void {
$this->items[] = $item;
}
/**
* @param callable(T): bool $callback
* @return Collection<T>
*/
public function filter(callable $callback): Collection {
$filtered = array_filter($this->items, $callback);
return new Collection($filtered);
}
/** @return T[] */
public function toArray(): array {
return $this->items;
}
}
// Использование:
/** @var Collection<int> $numbers */
$numbers = new Collection([1, 2, 3, 4, 5]);
$even = $numbers->filter(function(int $n): bool {
return $n % 2 === 0;
});
print_r($even->toArray()); // [2, 4]
Объяснение: Метод filter принимает callback, который должен вернуть bool. Благодаря дженерикам, callback получает элемент типа T (в нашем случае int), и возвращает новую коллекцию того же типа.
Пример 4: Репозиторий с ограничением типа
/**
* @template T of JsonSerializable
*/
class JsonRepository {
/** @var T[] */
private array $items = [];
/** @param T $item */
public function add(mixed $item): void {
$this->items[] = $item;
}
/** @return string */
public function exportAll(): string {
return json_encode($this->items);
}
}
class User implements JsonSerializable {
public function __construct(
public string $name,
public int $age
) {}
public function jsonSerialize(): mixed {
return [
'name' => $this->name,
'age' => $this->age
];
}
}
// Использование:
/** @var JsonRepository<User> $repo */
$repo = new JsonRepository();
$repo->add(new User("Алиса", 30));
echo $repo->exportAll(); // [{"name":"Алиса","age":30}]
Объяснение: T of JsonSerializable означает, что T должен быть классом, реализующим интерфейс JsonSerializable. Это гарантирует, что метод exportAll() будет работать корректно.
Пример 5: Стек с типизацией
/**
* @template T
*/
class Stack {
/** @var T[] */
private array $elements = [];
/** @param T $element */
public function push(mixed $element): void {
$this->elements[] = $element;
}
/** @return T|null */
public function pop(): mixed {
return array_pop($this->elements);
}
/** @return T|null */
public function peek(): mixed {
return $this->elements[count($this->elements) - 1] ?? null;
}
public function isEmpty(): bool {
return empty($this->elements);
}
}
// Использование:
/** @var Stack<string> $stringStack */
$stringStack = new Stack();
$stringStack->push("первый");
$stringStack->push("второй");
echo $stringStack->pop(); // "второй"
echo $stringStack->pop(); // "первый"
Объяснение: Стек — это структура данных "последним пришёл — первым ушёл" (LIFO). Дженерики гарантируют, что в стеке будут только строки, и pop() вернёт строку (или null).
Пример 6: Функция-обёртка с дженериками
/**
* @template T
* @param T $value
* @return T
*/
function identity(mixed $value): mixed {
return $value;
}
// Использование:
/** @var int $result */
$result = identity(42); // $result — int
/** @var string $text */
$text = identity("hello"); // $text — string
// Без дженериков пришлось бы писать:
// function identityInt(int $value): int { return $value; }
// function identityString(string $value): string { return $value; }
Объяснение: Функция identity просто возвращает то, что получила. С дженериками она работает с любым типом, но сохраняет информацию о типе. Это избавляет от необходимости писать отдельные функции для каждого типа.
Пример 7: Сложный пример — EventDispatcher
/**
* @template T
*/
interface Event {
/** @return T */
public function getData(): mixed;
}
/**
* @template T
*/
class UserRegisteredEvent implements Event {
/** @param T $data */
public function __construct(
private mixed $data
) {}
/** @return T */
public function getData(): mixed {
return $this->data;
}
}
/**
* @template T
* @param class-string<Event<T>> $eventClass
* @param callable(T): void $handler
*/
function on(string $eventClass, callable $handler): void {
// Регистрируем обработчик
}
// Использование:
/** @param UserData $data */
on(UserRegisteredEvent::class, function(UserData $data): void {
echo "Пользователь {$data->name} зарегистрирован!";
});
Объяснение: Здесь мы создаём систему событий, где каждое событие содержит данные определённого типа. Обработчик получает данные правильного типа без необходимости проверять их вручную.
Сравнение с Python: чем похоже и чем отличается
| Характеристика | PHP | Python |
|---|---|---|
| Поддержка дженериков | Через PHPDoc (с PHP 8.0) | Через модуль typing (с Python 3.5) |
| Синтаксис | @template T в комментариях |
def func(x: T) -> T в коде |
| Проверка типов | Только статическая (IDE, PHPStan) | Статическая (mypy, Pyright) + runtime (Pydantic) |
| Стирание типов | Да (во время выполнения типов нет) | Нет (типы доступны через __annotations__) |
| Ограничения | @template T of SomeClass |
T = TypeVar('T', bound=SomeClass) |
| Встроенные коллекции | Только через сторонние библиотеки | list[T], dict[K, V], set[T] |
| Производительность | Без накладных расходов (стирание) | Небольшие накладные расходы |
Основные отличия:
- Синтаксис: В Python дженерики — часть языка (
list[int]), в PHP — аннотации в комментариях. - Runtime: В Python можно получить тип дженерика во время выполнения (
get_type_hints()), в PHP — нет. - Экосистема: Python имеет встроенные дженерик-коллекции, PHP требует ручного создания.
💡 Совет: Если ты знаешь Python, представь, что PHP-дженерики — это как аннотации типов в Python, но записанные в комментариях. Они не влияют на выполнение, но помогают писать правильный код.
Типичные ошибки и подводные камни
Ошибка 1: Попытка использовать T как реальный тип
/**
* @template T
*/
class BadExample {
public function create(): mixed {
return new T(); // ОШИБКА! T не существует во время выполнения
}
}
Решение: Передавай фабрику или класс как параметр:
/**
* @template T
*/
class GoodExample {
/** @param class-string<T> $className */
public function __construct(
private string $className
) {}
/** @return T */
public function create(): mixed {
return new ($this->className)();
}
}
Ошибка 2: Игнорирование стирания типов
/**
* @template T
*/
class Container {
/** @param T $value */
public function __construct(
private mixed $value
) {}
public function isString(): bool {
return $this->value instanceof T; // ОШИБКА! T стёрт
}
}
Решение: Используй проверки конкретных типов или передавай проверяющую функцию:
/** @param callable(mixed): bool $checker */
public function __construct(
private mixed $value,
private callable $checker
) {}
public function isValid(): bool {
return ($this->checker)($this->value);
}
Ошибка 3: Путаница с ковариантностью
/**
* @template T
*/
class Holder {
/** @param T $value */
public function set(mixed $value): void {} // Проблема!
/** @return T */
public function get(): mixed {}
}
Если T — Animal, а ты передаёшь Cat, то get() вернёт Animal, а не Cat. Это может привести к ошибкам.
Решение: Используй @template-covariant только для неизменяемых классов (где нет методов set).
Ошибка 4: Неправильное использование в массивах
/** @param int[] $numbers */ // Это не дженерик!
function sum(array $numbers): int {
return array_sum($numbers);
}
int[] — это просто массив целых чисел, без дженериков. Настоящий дженерик-массив выглядит так:
/** @param array<int, int> $map */
function process(array $map): void {} // Ключ — int, значение — int
Ошибка 5: Забывание указать тип при использовании
$box = new Box(42); // PHP не знает, что это Box<int>
$value = $box->get(); // mixed, а не int
Решение: Всегда указывай тип через @var:
/** @var Box<int> $box */
$box = new Box(42);
Лучшие практики и советы профессионалов
1. Используй осмысленные имена для параметров типа
T— для одного типа (по умолчанию)KиV— для ключа и значенияTKey,TValue— для ясностиTEntity— для сущностей
2. Ограничивай типы где возможно
/**
* @template T of Stringable
*/
class StringCollection {
/** @param T[] $items */
public function __construct(
private array $items
) {}
public function join(string $glue): string {
return implode($glue, array_map(
fn($item) => (string) $item,
$this->items
));
}
}
3. Комбинируй с интерфейсами
/**
* @template T
*/
interface RepositoryInterface {
/** @param T $entity */
public function save(mixed $entity): void;
/** @return T|null */
public function find(int $id): mixed;
/** @return T[] */
public function findAll(): array;
}
4. Используй PHPStan или Psalm для проверки
Эти инструменты проверяют дженерики на уровне кода и находят ошибки до запуска. Установи их через Composer:
composer require --dev phpstan/phpstan
vendor/bin/phpstan analyse src/
5. Не злоупотребляй дженериками
Если класс работает только с одним типом (например, UserRepository), не делай его дженериком. Дженерики нужны для обобщённых структур: коллекции, стеки, очереди, репозитории.
6. Документируй сложные дженерики
/**
* @template T of object
* @template R
*
* Класс для преобразования сущностей в DTO.
* T — тип исходной сущности
* R — тип результата (DTO)
*/
class EntityMapper {
/** @param callable(T): R $mapper */
public function __construct(
private callable $mapper
) {}
/** @param T $entity
* @return R */
public function map(mixed $entity): mixed {
return ($this->mapper)($entity);
}
}
Вопросы для самопроверки
-
Что такое дженерики в PHP?
Ответ: Это механизм, позволяющий создавать классы, интерфейсы и функции, которые работают с разными типами данных, сохраняя строгую типизацию. Реализуется через аннотации PHPDoc (
@template). -
В чём отличие дженериков PHP от дженериков Java?
Ответ: В Java дженерики — часть синтаксиса (
List<String>), в PHP — аннотации в комментариях. Оба языка используют стирание типов, но Java проверяет типы на этапе компиляции, PHP — только статическими анализаторами. -
Что такое стирание типов и как оно влияет на код?
Ответ: Это процесс, при котором информация о дженериках удаляется во время компиляции. В PHP это означает, что нельзя использовать
instanceof Tилиnew T()внутри дженерик-класса. -
Как ограничить тип в дженерике?
Ответ: Используй
@template T of SomeClassили@template T of SomeInterface. Это гарантирует, что T будет подтипом указанного класса или интерфейса. -
Что такое ковариантность и контравариантность?
Ответ: Ковариантность — возможность использовать подтип вместо базового типа (для возвращаемых значений). Контравариантность — наоборот (для параметров). В PHP используются аннотации
@template-covariantи@template-contravariant. -
Почему нельзя написать
new T()внутри дженерик-класса?Ответ: Из-за стирания типов. Во время выполнения PHP не знает, что такое T. Нужно передавать класс как строку или использовать фабрику.
-
Как проверить, что дженерики работают корректно?
Ответ: Используй статические анализаторы: PHPStan (уровень 6+), Psalm. Они проверят соответствие типов на уровне кода.
-
Можно ли использовать дженерики с функциями?
Ответ: Да, через аннотации
@templateв PHPDoc функции. Например:/** @template T @param T $value @return T */. -
В чём разница между
int[]иarray<int, int>?Ответ:
int[]— это список целых чисел (ключи 0,1,2...).array<int, int>— это ассоциативный массив, где и ключи, и значения — целые числа. -
Какие инструменты помогают работать с дженериками в PHP?
Ответ: IDE (PhpStorm, VS Code с расширениями), статические анализаторы (PHPStan, Psalm), генераторы кода (например, для создания типизированных коллекций).
Итог и что изучить дальше
Дженерики — это мощный инструмент, который делает твой PHP-код безопаснее, понятнее и переиспользуемее. Ты научился:
- Понимать, зачем нужны дженерики (аналогия со ст