Полиморфизм в PHP: один вызов — разное поведение
Договориться. Подменить. Не заметить.
Один общий вызов договорились иметь для всех. Подменили исполнителя — на его место встал другой класс, а вызывающий код не заметил подмены. Одна команда — много исполнителей.
php// Договорились об одном вызове
interface Notifier
{
public function send(string $message): void;
}
// Исполнители — каждый делает по-своему
class EmailNotifier implements Notifier
{
public function send(string $message): void
{
// отправили письмо
}
}
class SmsNotifier implements Notifier
{
public function send(string $message): void
{
// отправили SMS
}
}
// Вызов один — исполнители разные
function notifyUser(Notifier $notifier): void
{
$notifier->send('Ваш заказ отправлен');
}
notifyUser(new EmailNotifier()); // выполнит EmailNotifier
notifyUser(new SmsNotifier()); // выполнит SmsNotifier — вызов тот же, код notifyUser не менялся
- договорились —
interface Notifierобещает один методsend(); - подменили исполнителя — на место
EmailNotifierвсталSmsNotifier; - не заметили —
notifyUser()остался без изменений: он принимаетNotifier, а какой конкретно класс ему передали, ему без разницы.
Зачем это нужно
Полиморфизм — третий кит ООП: разные классы отвечают на один и тот же вызов каждый по-своему, а вызывающий код не знает и не хочет знать, какой именно класс перед ним.
Боль из практики: в проекте уведомления отправлялись так — if ($channel === 'email') { ... } elseif ($channel === 'sms') { ... } elseif ($channel === 'telegram') { ... }. Работало. Потом добавили push-уведомления — и в каждое место, где был этот if, пришлось вписывать ещё одну ветку. Их нашлось семь: контроллер, очередь, крон, отчёты. Два забыли — и часть пользователей push так и не увидела. Ошибка не в коде ветки, а в самой конструкции: код, который знает «какие бывают варианты», приходится менять при каждом новом варианте.
Полиморфизм переворачивает это: вызывающий код зависит не от списка вариантов, а от контракта — и новые варианты добавляются без единой правки в нём.
Вот пример на человеческом языке. Представьте кассира в магазине. Покупатель протягивает оплату: кто-то карту, кто-то наличные, кто-то телефон с QR-кодом. Если кассир сначала выясняет, чем именно платят, и под каждый способ держит отдельную инструкцию — то с появлением нового способа (бонусная карта, оплата через смарт-браслет) его инструкцию придётся переписывать. И не одну: такие же списки способов лежат в терминале, в отчётах, в программе лояльности.
А теперь второй вариант кассира: у него одна кнопка «Принять оплату». Карта сама знает, как считаться, терминал сам проведёт платёж, телефон сам покажет QR. Кассиру всё равно, что за предмет ему протянули, — он просто жмёт кнопку. Пришёл новый способ оплаты? Кассир не менялся: новый предмет просто тоже умеет «оплачивать».
В коде это та же схема: одна «кнопка» — контракт, и исполнители, каждый из которых выполняет одну и ту же команду по-своему. Вернёмся к уведомлениям и посмотрим, как это выглядит.
Как это работает
Шаг первый — договориться о контракте. Интерфейс — это обещание: «любой, кто меня реализует, умеет вот это»:
phpinterface Notifier
{
public function send(string $message): void;
}
Внутри интерфейса — ни строчки логики. Только сигнатура. Это договор, а не реализация.
Шаг второй — каждая реализация подписывает договор и делает по-своему:
phpclass 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().
Шаг третий — вызывающий код работает только с контрактом:
phpfunction 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);
А дальше — только контракт:
phpclass 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 в бизнес-коде | Различия спрятаны в методах классов |
| Зависимость от конкретного класса в конструкторе | Зависимость от интерфейса + привязка в контейнере |
| Добавление варианта = правка всех мест вызова | Добавление варианта = один новый класс |
Подводный камень
- Интерфейс «на всякий случай». Один интерфейс с одной реализацией — ещё не полиморфизм, но и не вред: он окупается, когда придёт вторая реализация. Пропустите интерфейс там, где второй вариант уже виден.
- Жирный контракт. Если интерфейс требует десять методов, а реализация половину делает «пустышкой» — контракт разросся. Дробите: лучше два узких интерфейса, чем один, который реализаторы фальшивят.
- Логика вне классов. Оставили выбор «как уведомлять» в контроллере (
new EmailNotifier()напрямую) — контейнер не может подменить реализацию, тест упёрся в реальный SMTP. Создавайте объекты через DI, а не черезnewв глубине кода. - Полиморфизм не только про интерфейсы. Переопределение методов у наследников — тоже полиморфизм (вспомните
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();
Три вывода
- Полиморфизм — это контракт вместо ветвлений: интерфейс говорит «что умеет», классы — «как именно», а вызывающий код знает только контракт.
- Новый вариант поведения = один новый класс, а не правки во всех местах вызова.
instanceofиif/elseifпо типу объекта — знак, что различия забыли спрятать внутрь классов.