Полиморфизм в PHP: один вызов — разное поведение

Договориться. Подменить. Не заметить.

Полиморфизм — это договориться об общем контракте: интерфейс обещает «умею send()», и всё. Подменить — любой класс, подписавшийся под контракт, встаёт на место другого: письмо, SMS, Telegram — вызывающему всё равно. Не заметить — вызывающий код не меняется ни на строчку: он не знает и не хочет знать, какая реализация перед ним работает. Отсюда и вольный перевод смысла: «многие формы» — один вызов, много форм поведения.


Зачем это нужно

Полиморфизм — третий кит ООП: разные классы отвечают на один и тот же вызов каждый по-своему, а вызывающий код не знает и не хочет знать, какой именно класс перед ним.

Боль из практики: в проекте уведомления отправлялись так — if ($channel === 'email') { ... } elseif ($channel === 'sms') { ... } elseif ($channel === 'telegram') { ... }. Работало. Потом добавили push-уведомления — и в каждое место, где был этот if, пришлось вписывать ещё одну ветку. Их нашлось семь: контроллер, очередь, крон, отчёты. Два забыли — и часть пользователей push так и не увидела. Ошибка не в коде ветки, а в самой конструкции: код, который знает «какие бывают варианты», приходится менять при каждом новом варианте.

Полиморфизм переворачивает это: вызывающий код зависит не от списка вариантов, а от контракта — и новые варианты добавляются без единой правки в нём.

Как это работает

Шаг первый — договориться о контракте. Интерфейс — это обещание: «любой, кто меня реализует, умеет вот это»:

php
interface Notifier
{
    public function send(string $message): void;
}

Внутри интерфейса — ни строчки логики. Только сигнатура. Это договор, а не реализация.

Шаг второй — каждая реализация подписывает договор и делает по-своему:

php
class EmailNotifier implements Notifier
{
    public function send(string $message): void
    {
        // подключились к SMTP и отправили письмо
    }
}

class SmsNotifier implements Notifier
{
    public function send(string $message): void
    {
        // дёрнули API оператора
    }
}

class TelegramNotifier implements Notifier
{
    public function send(string $message): void
    {
        // вызвали Bot API
    }
}

Три класса, три совершенно разных «как» — и одинаковый «что»: метод send().

Шаг третий — вызывающий код работает только с контрактом:

php
function notifyUser(Notifier $notifier, string $message): void
{
    $notifier->send($message);
}

notifyUser(new EmailNotifier(), 'Ваш заказ отправлен');
notifyUser(new SmsNotifier(), 'Ваш заказ отправлен');

notifyUser не делает if и не проверяет класс. Он говорит: «кто бы ты ни был, если ты Notifier — отправь». PHP в момент вызова сам решает, чей send() выполнять. Завтра добавляем PushNotifier — и notifyUser не меняется ни на символ. Это и есть «не заметить»: замена прошла, а вызывающий код ничего не почувствовал.

Как это выглядит в Laravel

Контейнер — главный механизм полиморфизма в Laravel. Привязка интерфейса к реализации делается один раз:

php
// В сервис-провайдере
$this->app->bind(Notifier::class, EmailNotifier::class);

А дальше — только контракт:

php
class OrderService
{
    public function __construct(
        private Notifier $notifier // тип — интерфейс, не класс
    ) {}

    public function complete(Order $order): void
    {
        $this->notifier->send("Заказ {$order->number} отправлен");
    }
}

Контейнер при создании OrderService посмотрит: «нужен Notifier — а какой класс привязан?» — и подставит EmailNotifier. Сервис про это не знает. Захотели SMS — меняете одну строку привязки, не трогая ни сервиса, ни контроллеров.

В тестах тот же приём позволяет подменить настоящую отправку заглушкой:

php
$this->mock(Notifier::class)
    ->shouldReceive('send')
    ->once();

Не письмо улетело в мир, а заглушка записала вызов — тест проверил «что вызвать должны были», не трогая внешний мир.

Полиморфизм против instanceof

Полиморфизм иногда пишут «наполовину»: контракт есть, но вызывающий код всё равно проверяет классы:

php
// ПЛОХО: контракт есть, а ветвление всё равно живёт здесь
if ($notifier instanceof EmailNotifier) {
    // одна логика
} elseif ($notifier instanceof SmsNotifier) {
    // другая
}

Это тот же if/elseif из начала статьи, только замаскированный. Каждый новый класс — снова правка всех таких мест. Разница «какая реализация передо мной» — это знание, которое должно жить внутри класса, а не снаружи. Видите instanceof в бизнес-коде — это чаще всего метод, который забыли перенести в классы.

Плохо / Хорошо

ПлохоХорошо
if/elseif по типу канала, платежа, статусаИнтерфейс + реализации за одним вызовом
instanceof в бизнес-кодеРазличия спрятаны в методах классов
Зависимость от конкретного класса в конструктореЗависимость от интерфейса + привязка в контейнере
Добавление варианта = правка всех мест вызоваДобавление варианта = один новый класс

Подводный камень

  1. Интерфейс «на всякий случай». Один интерфейс с одной реализацией — ещё не полиморфизм, но и не вред: он окупается, когда придёт вторая реализация. Пропустите интерфейс там, где второй вариант уже виден.
  2. Жирный контракт. Если интерфейс требует десять методов, а реализация половину делает «пустышкой» — контракт разросся. Дробите: лучше два узких интерфейса, чем один, который реализаторы фальшивят.
  3. Логика вне классов. Оставили выбор «как уведомлять» в контроллере (new EmailNotifier() напрямую) — контейнер не может подменить реализацию, тест упёрся в реальный SMTP. Создавайте объекты через DI, а не через new в глубине кода.
  4. Полиморфизм не только про интерфейсы. Переопределение методов у наследников — тоже полиморфизм (вспомните info() у собаки в статье про наследование). Но интерфейс — самый чистый и самый частый способ в реальных проектах.

Мини-шпаргалка

php
// Контракт
interface Notifier
{
    public function send(string $message): void;
}

// Реализации — каждое «как» внутри своего класса
class EmailNotifier implements Notifier
{
    public function send(string $message): void { /* SMTP */ }
}
class SmsNotifier implements Notifier
{
    public function send(string $message): void { /* API оператора */ }
}

// Привязка в контейнере — один раз
$this->app->bind(Notifier::class, EmailNotifier::class);

// Вызывающий код — только контракт, ноль ветвлений
public function __construct(private Notifier $notifier) {}

// Подмена в тестах
$this->mock(Notifier::class)->shouldReceive('send')->once();

Три вывода

  1. Полиморфизм — это контракт вместо ветвлений: интерфейс говорит «что умеет», классы — «как именно», а вызывающий код знает только контракт.
  2. Новый вариант поведения = один новый класс, а не правки во всех местах вызова.
  3. instanceof и if/elseif по типу объекта — знак, что различия забыли спрятать внутрь классов.