🐘 PHP

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);
    }
}

Вопросы для самопроверки

  1. Что такое дженерики в PHP?

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

  2. В чём отличие дженериков PHP от дженериков Java?

    Ответ: В Java дженерики — часть синтаксиса (List<String>), в PHP — аннотации в комментариях. Оба языка используют стирание типов, но Java проверяет типы на этапе компиляции, PHP — только статическими анализаторами.

  3. Что такое стирание типов и как оно влияет на код?

    Ответ: Это процесс, при котором информация о дженериках удаляется во время компиляции. В PHP это означает, что нельзя использовать instanceof T или new T() внутри дженерик-класса.

  4. Как ограничить тип в дженерике?

    Ответ: Используй @template T of SomeClass или @template T of SomeInterface. Это гарантирует, что T будет подтипом указанного класса или интерфейса.

  5. Что такое ковариантность и контравариантность?

    Ответ: Ковариантность — возможность использовать подтип вместо базового типа (для возвращаемых значений). Контравариантность — наоборот (для параметров). В PHP используются аннотации @template-covariant и @template-contravariant.

  6. Почему нельзя написать new T() внутри дженерик-класса?

    Ответ: Из-за стирания типов. Во время выполнения PHP не знает, что такое T. Нужно передавать класс как строку или использовать фабрику.

  7. Как проверить, что дженерики работают корректно?

    Ответ: Используй статические анализаторы: PHPStan (уровень 6+), Psalm. Они проверят соответствие типов на уровне кода.

  8. Можно ли использовать дженерики с функциями?

    Ответ: Да, через аннотации @template в PHPDoc функции. Например: /** @template T @param T $value @return T */.

  9. В чём разница между int[] и array<int, int>?

    Ответ: int[] — это список целых чисел (ключи 0,1,2...). array<int, int> — это ассоциативный массив, где и ключи, и значения — целые числа.

  10. Какие инструменты помогают работать с дженериками в PHP?

    Ответ: IDE (PhpStorm, VS Code с расширениями), статические анализаторы (PHPStan, Psalm), генераторы кода (например, для создания типизированных коллекций).

Итог и что изучить дальше

Дженерики — это мощный инструмент, который делает твой PHP-код безопаснее, понятнее и переиспользуемее. Ты научился:

  • Понимать, зачем нужны дженерики (аналогия со ст