Схема замены условных операторов if/switch полиморфизмом в PHP: вместо ветвлений используется общий Exporter и отдельные классы PdfExporter, DocxExporter и XlsxExporter.

Замена условного оператора полиморфизмом: как убрать if/switch и не усложнить код

Условные операторы — нормальная часть программирования. if, else, switch, match сами по себе не зло. Проблема начинается тогда, когда один и тот же выбор по типу начинает расползаться по проекту.

Сегодня у вас два формата экспорта: pdf и xlsx. Завтра добавляется docx. Потом jpg. Потом отдельная логика для администратора, клиента, врача, менеджера и ещё кого-нибудь, кто “просто попросил маленькую правку”. В итоге метод, который когда-то выглядел безобидно, превращается в комбайн из условий.

Один из классических способов привести такой код в порядок — замена условного оператора полиморфизмом.

Смысл простой: вместо того чтобы постоянно спрашивать “какой это тип?” и выбирать поведение через if или switch, мы выносим разные варианты поведения в отдельные классы с общим интерфейсом.

Иначе говоря:

if ($type === 'pdf') {
    // pdf logic
}

if ($type === 'docx') {
    // docx logic
}

заменяется на:

$exporter = $exporterResolver->resolve($type);

return $exporter->export($protocol);

Код становится проще, а новые варианты поведения добавляются без переписывания старого метода.

Коротко

Замена условного оператора полиморфизмом нужна, когда:

один и тот же выбор по типу повторяется в разных местах;
метод разрастается из-за if/switch;
каждый новый вариант поведения требует править существующий код;
логика разных типов смешивается в одном классе;
код нарушает принцип открытости/закрытости.

Но применять этот приём надо аккуратно. Если условие одно, простое и не растёт, полиморфизм может быть лишним усложнением.

Пример проблемы

Допустим, есть страница просмотра медицинского протокола. По умолчанию она показывает HTML-страницу. Но если пользователь нажал “экспортировать”, нужно отдать файл в нужном формате.

Сначала есть только PDF. Код может выглядеть так:

public function show(Protocol $protocol, Request $request)
{
    if ($request->has('export')) {
        if ($request->get('export') === 'pdf') {
            $page = view(
                'web.sections.protocols.show-pdf',
                compact('protocol')
            );

            $newFilePath = storage_path(
                'pdf/protocol_' . $protocol->id . '.pdf'
            );

            $client = new Client();

            $result = $client->post(
                'http://pdf:3000/html',
                [
                    'body' => $page,
                ]
            );

            file_put_contents(
                $newFilePath,
                $result->getBody()->getContents()
            );

            return Response::download($newFilePath);
        }
    }

    return view(
        'web.sections.protocols.show',
        compact('protocol')
    );
}

На первый взгляд код рабочий. И это как раз ловушка. Большая часть плохого кода сначала “просто работает”.

Что в этом коде не так

Проблем несколько.

Метод делает больше одной вещи

Он одновременно:

показывает HTML-страницу;
проверяет параметр экспорта;
рендерит PDF;
работает с внешним PDF-сервисом;
сохраняет файл;
отдаёт файл пользователю.

Это уже нарушение принципа единственной ответственности.

Метод show() должен показывать страницу. Экспорт — отдельный сценарий.

Условие будет разрастаться

Сегодня есть только pdf.

Завтра код станет таким:

if ($request->get('export') === 'pdf') {
    // 30 строк кода
}

if ($request->get('export') === 'xlsx') {
    // 50 строк кода
}

if ($request->get('export') === 'docx') {
    // 40 строк кода
}

if ($request->get('export') === 'jpg') {
    // 20 строк кода
}

Через полгода это уже будет не метод, а маленький музей решений “на вчера”.

Каждый новый формат заставляет менять старый код

Если появляется новый формат экспорта, нужно открыть существующий метод и добавить ещё одно условие. Это нарушает принцип открытости/закрытости:

Код должен быть открыт для расширения, но закрыт для изменения.

В идеале новый формат должен добавляться новым классом, а не переписыванием старого метода.

Первый шаг: разделить show и export

Для начала метод просмотра должен стать простым:

public function show(Protocol $protocol)
{
    return view(
        'web.sections.protocols.show',
        compact('protocol')
    );
}

Экспорт выносим в отдельный метод или отдельный action:

public function export(Protocol $protocol, Request $request)
{
    $type = $request->get('type');

    // export logic
}

Уже лучше. Теперь просмотр и экспорт не смешаны.

Но внутри export() всё ещё может появиться большой условный оператор. Вот его и будем убирать.

Что такое полиморфизм

Полиморфизм — это способность кода работать с разными объектами через общий интерфейс, не зная их конкретный класс.

Проще:

Есть общий интерфейс Exporter.
Есть PdfExporter, DocxExporter, XlsxExporter.
Все они умеют делать export().
Код вызывает export(), не думая, какой именно формат используется.

Внешний код работает не с конкретным PdfExporter, а с абстракцией:

interface ProtocolExporter
{
    public function export(Protocol $protocol): ExportedFile;
}

А конкретные классы реализуют этот интерфейс.

Создаём общий интерфейс экспортера

interface ProtocolExporter
{
    public function export(Protocol $protocol): ExportedFile;
}

ExportedFile — простой объект результата. Он может хранить путь к файлу, имя файла и MIME-тип.

class ExportedFile
{
    public function __construct(
        public readonly string $path,
        public readonly string $name,
        public readonly string $mimeType,
    ) {
    }
}

Теперь каждый формат экспорта будет отдельным классом.

Реализация PDF-экспортера

class PdfProtocolExporter implements ProtocolExporter
{
    public function __construct(
        private Client $client,
    ) {
    }

    public function export(Protocol $protocol): ExportedFile
    {
        $page = view(
            'web.sections.protocols.show-pdf',
            [
                'protocol' => $protocol,
            ]
        );

        $filePath = storage_path(
            'pdf/protocol_' . $protocol->id . '.pdf'
        );

        $response = $this->client->post(
            'http://pdf:3000/html',
            [
                'body' => $page,
            ]
        );

        file_put_contents(
            $filePath,
            $response->getBody()->getContents()
        );

        return new ExportedFile(
            path: $filePath,
            name: 'protocol_' . $protocol->id . '.pdf',
            mimeType: 'application/pdf',
        );
    }
}

Теперь вся логика PDF живёт в одном месте. Она не мешает контроллеру и не смешивается с другими форматами.

Реализация DOCX-экспортера

class DocxProtocolExporter implements ProtocolExporter
{
    public function export(Protocol $protocol): ExportedFile
    {
        $filePath = storage_path(
            'docx/protocol_' . $protocol->id . '.docx'
        );

        // Логика генерации DOCX.

        return new ExportedFile(
            path: $filePath,
            name: 'protocol_' . $protocol->id . '.docx',
            mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
        );
    }
}

Теперь для нового формата не нужно лезть в старый if. Мы добавляем новый класс.

Как выбрать нужный экспортер

Полиморфизм не убирает выбор полностью. Где-то всё равно нужно решить, какой класс использовать. Вопрос в том, где это решение должно жить.

Плохой вариант — держать выбор в контроллере:

if ($type === 'pdf') {
    $exporter = new PdfProtocolExporter();
}

if ($type === 'docx') {
    $exporter = new DocxProtocolExporter();
}

Так мы просто перенесли проблему из одного места в другое.

Лучше сделать отдельный resolver:

class ProtocolExporterResolver
{
    public function __construct(
        private PdfProtocolExporter $pdfProtocolExporter,
        private DocxProtocolExporter $docxProtocolExporter,
        private XlsxProtocolExporter $xlsxProtocolExporter,
    ) {
    }

    public function resolve(string $type): ProtocolExporter
    {
        return match ($type) {
            'pdf' => $this->pdfProtocolExporter,
            'docx' => $this->docxProtocolExporter,
            'xlsx' => $this->xlsxProtocolExporter,
            default => throw new InvalidArgumentException(
                'Unsupported export type: ' . $type
            ),
        };
    }
}

Да, здесь всё ещё есть match. Это нормально.

Цель рефакторинга не в том, чтобы запретить любые условия. Цель — убрать разрастание бизнес-логики внутри условий. В resolver остался только выбор стратегии. Сама логика экспорта разнесена по отдельным классам.

Итоговый контроллер

Теперь контроллер становится коротким:

class ProtocolController
{
    public function __construct(
        private ProtocolExporterResolver $protocolExporterResolver,
    ) {
    }

    public function show(Protocol $protocol)
    {
        return view(
            'web.sections.protocols.show',
            [
                'protocol' => $protocol,
            ]
        );
    }

    public function export(Protocol $protocol, Request $request)
    {
        $type = $request->get('type', 'pdf');

        $exporter = $this->protocolExporterResolver->resolve(
            type: $type,
        );

        $exportedFile = $exporter->export(
            protocol: $protocol,
        );

        return Response::download(
            file: $exportedFile->path,
            name: $exportedFile->name,
            headers: [
                'Content-Type' => $exportedFile->mimeType,
            ]
        );
    }
}

Теперь метод export() не знает, как именно создаётся PDF, DOCX или XLSX. Он знает только общий сценарий:

получить тип;
найти экспортер;
выполнить экспорт;
отдать файл пользователю.

Это и есть нормальное разделение ответственности.

Вариант для Laravel через контейнер

В Laravel можно сделать ещё аккуратнее: зарегистрировать экспортеры в контейнере или конфигурации.

Например, в конфиге:

return [
    'protocol_exporters' => [
        'pdf' => PdfProtocolExporter::class,
        'docx' => DocxProtocolExporter::class,
        'xlsx' => XlsxProtocolExporter::class,
    ],
];

Resolver:

class ProtocolExporterResolver
{
    public function resolve(string $type): ProtocolExporter
    {
        $exporterClass = config(
            'exports.protocol_exporters.' . $type
        );

        if ($exporterClass === null) {
            throw new InvalidArgumentException(
                'Unsupported export type: ' . $type
            );
        }

        $exporter = app($exporterClass);

        if (! $exporter instanceof ProtocolExporter) {
            throw new RuntimeException(
                'Exporter must implement ProtocolExporter.'
            );
        }

        return $exporter;
    }
}

Теперь новый формат добавляется так:

1. Создать новый класс экспортера.
2. Реализовать ProtocolExporter.
3. Добавить класс в конфиг.

Контроллер при этом не меняется.

Почему это лучше

Код проще читать

Вместо большого метода с кучей условий появляются маленькие классы с понятной ответственностью:

PdfProtocolExporter
DocxProtocolExporter
XlsxProtocolExporter
ProtocolExporterResolver

Каждый класс отвечает за одну вещь.

Код проще тестировать

Можно отдельно протестировать PDF-экспорт:

public function testPdfExporterCreatesExportedFile(): void
{
    $exporter = new PdfProtocolExporter(
        client: $this->client,
    );

    $exportedFile = $exporter->export(
        protocol: $this->protocol,
    );

    $this->assertSame(
        'application/pdf',
        $exportedFile->mimeType,
    );
}

Не нужно гонять весь контроллер ради проверки одного формата.

Новые форматы добавляются безопаснее

Когда появляется новый формат, старый код почти не трогаем. Это снижает риск сломать существующие сценарии.

Меньше дублирования

Если выбор формата раньше был в нескольких местах, полиморфизм позволяет собрать его в одном resolver, а поведение — в отдельных классах.

Связь с SOLID

Замена условного оператора полиморфизмом напрямую связана с двумя принципами SOLID.

Single Responsibility Principle

Контроллер не должен знать, как генерируется каждый формат.

Его задача — принять запрос и вернуть ответ.

Генерация PDF, DOCX или XLSX — отдельная ответственность.

Open/Closed Principle

Система должна быть открыта для расширения, но закрыта для изменения.

Добавление нового формата экспорта должно выглядеть так:

добавили новый класс;
зарегистрировали его;
старый код не переписали.

Именно это даёт полиморфизм.

Когда замена условного оператора полиморфизмом не нужна

Это важный момент. Не каждый if надо превращать в интерфейс, три класса и фабрику. Иначе вместо чистого кода получится архитектурная бюрократия.

Не стоит применять этот рефакторинг, если:

условие простое и не растёт;
вариантов всего два и новых не ожидается;
логика внутри веток занимает одну-две строки;
код находится в одном месте и не дублируется;
абстракция делает код сложнее, а не проще.

Например:

if ($user->isAdmin()) {
    return true;
}

return false;

Не надо делать для этого:

AdminAccessStrategyInterface
AdminAccessStrategyResolver
DefaultAdminAccessStrategy

Это не архитектура. Это способ потерять уважение коллег.

Когда полиморфизм особенно полезен

Приём хорошо работает, когда есть семейство похожих сценариев:

экспорт в разные форматы;
расчёт стоимости разными способами;
разные платёжные провайдеры;
разные типы уведомлений;
разные способы доставки;
разные правила валидации;
разные статусы с разным поведением;
разные обработчики команд.

Например:

PaymentProvider
NotificationChannel
ShippingCalculator
DiscountPolicy
ReportExporter

Если вы видите, что в коде появляется выбор по типу, а каждая ветка содержит отдельное поведение, это кандидат на полиморфизм.

Частая ошибка: заменить if на классы, но оставить хаос

Иногда разработчик выносит каждую ветку в отдельный класс, но общая модель всё равно остаётся слабой.

Плохо:

$service = $factory->make($type);
$service->doSomething($data, $flag, $mode, $context);

Если в каждый класс передаётся мешок данных и флагов, проблема не решена. Она просто стала распределённой.

Хороший полиморфизм требует нормального интерфейса:

interface ProtocolExporter
{
    public function export(Protocol $protocol): ExportedFile;
}

Один понятный метод. Один понятный контракт. Один ожидаемый результат.

Практический чек-лист

Перед заменой условного оператора полиморфизмом ответьте на вопросы:

1. Условия выбирают разные варианты одного поведения?
2. Веток уже несколько или они точно будут добавляться?
3. Логика внутри веток достаточно большая?
4. Этот выбор повторяется в разных местах?
5. Новый вариант должен добавляться без изменения старого кода?
6. Можно ли описать общий интерфейс для всех вариантов?
7. Станет ли код проще после выделения классов?

Если большинство ответов “да”, полиморфизм уместен.

Если нет — обычный if, switch или match может быть нормальным решением.

Итог

Замена условного оператора полиморфизмом — это не борьба против if. Это борьба против кода, где разные варианты поведения смешаны в одном методе и постоянно разрастаются.

Хороший результат выглядит так:

контроллер стал короче;
каждый формат экспорта живёт в отдельном классе;
новый формат добавляется без переписывания старой логики;
код проще тестировать;
условия остались только в resolver, а не в бизнес-логике.

Главная мысль:

Если условный оператор выбирает не значение, а поведение — возможно, перед вами кандидат на полиморфизм.

Читайте дальше

Рейтинг
( 4 оценки, среднее 5 из 5 )
Максим Гречушников/ автор статьи
Мне 40 лет, и больше половины жизни я занимаюсь разработкой. За это время я работал с корпоративными системами, highload-проектами, интеграциями, платежными решениями, внутренними платформами и legacy-кодом, который пережил не одного разработчика. Некоторые системы, кажется, переживут и нас. Моя специализация — PHP, Laravel, backend-архитектура, DDD, тестирование, рефакторинг и техническое лидерство. Мне интересны задачи, где нужно соединить код, бизнес, данные, процессы и здравый смысл. Последние годы я много работаю с LLM и AI-инструментами: использую их для проектирования, анализа кода, документации, тестов, ревью, работы с legacy и построения инженерных процессов. Для меня LLM — это не замена разработчику, а усилитель для тех, кто умеет думать системно. В блоге я пишу о Laravel, PHP, архитектуре, тестировании, инженерной практике, управлении разработкой и реальном применении AI в работе программиста.
Понравилась статья? Поделиться с друзьями:
Комментарии: 1
  1. ян

    Скажу спасибо автору за наличие интересного материала на сайте и статей по фреймворку laravel .

    Внесу свои 5 копеек:
    – Если мы используем простую фабрику для генерации объектов по флагу (из массива) как в вашем случае по “типу” то окей switch case еще допустимо.

    В своей статье Вы пишите об использование полиморфизма – но при этом реализовываете его на половину – то есть немного не в том месте. Почему бы Вам не прокинуть уже готовый объект интерфейса ProtocolExportInterface в метод public function getExportService(ProtocolExportInterface $item):void и там уже дернуть метод run()? – это и будет полиморфизм.

    А вот простую фабрику как раз таки Вам нужно было бы использовать предварительно перед созданием объекта интерфейса ProtocolExportInterface исходя из типа.

Комментарии закрыты.