Урок 02

SOLID: OCP — открытость/закрытость

OCP

Open/Closed Principle — сущности должны быть открыты для расширения, но закрыты для изменения.

Проще: добавление нового поведения — через новый код, не через правку старого.

Проблема

Класс с if/else по типам:

class AreaCalculator
{
    public function calculate(array $shapes): float
    {
        $total = 0.0;

        foreach ($shapes as $shape) {
            if ($shape['type'] === 'circle') {
                $total += M_PI * $shape['radius'] ** 2;
            } elseif ($shape['type'] === 'square') {
                $total += $shape['side'] ** 2;
            } elseif ($shape['type'] === 'rectangle') {
                $total += $shape['width'] * $shape['height'];
            }
            // хотим добавить треугольник — правим метод
        }

        return $total;
    }
}

Каждый новый тип — правка метода. Нарушение OCP.

Решение

Полиморфизм + абстракция:

interface Shape
{
    public function area(): float;
}

final class Circle implements Shape
{
    public function __construct(private float $radius) {}

    public function area(): float
    {
        return M_PI * $this->radius ** 2;
    }
}

final class Square implements Shape
{
    public function __construct(private float $side) {}

    public function area(): float
    {
        return $this->side ** 2;
    }
}

final class AreaCalculator
{
    /** @param Shape[] $shapes */
    public function calculate(array $shapes): float
    {
        return array_sum(array_map(
            fn(Shape $s) => $s->area(),
            $shapes,
        ));
    }
}

Теперь для нового типа — только новый класс. AreaCalculator не меняется.

Как применять

Три шага:

  1. Выдели интерфейс для того, что меняется.
  2. Реализуй интерфейс в конкретных классах.
  3. Передавай интерфейс, не конкретный тип.

Пример: экспорт

Плохо:

class Exporter
{
    public function export(array $data, string $format): string
    {
        return match ($format) {
            'json' => json_encode($data),
            'csv' => $this->toCsv($data),
            'xml' => $this->toXml($data),
            // новый формат — правка
        };
    }
}

Хорошо:

interface Exporter
{
    public function export(array $data): string;
    public function supports(string $format): bool;
}

final class JsonExporter implements Exporter
{
    public function export(array $data): string
    {
        return json_encode($data, JSON_UNESCAPED_UNICODE);
    }

    public function supports(string $format): bool
    {
        return $format === 'json';
    }
}

final class CsvExporter implements Exporter
{
    public function export(array $data): string
    {
        // ...
        return '';
    }

    public function supports(string $format): bool
    {
        return $format === 'csv';
    }
}

final class ExportManager
{
    /** @param Exporter[] $exporters */
    public function __construct(private array $exporters) {}

    public function export(array $data, string $format): string
    {
        foreach ($this->exporters as $exporter) {
            if ($exporter->supports($format)) {
                return $exporter->export($data);
            }
        }

        throw new InvalidArgumentException("Формат не поддерживается: $format");
    }
}

Новый формат — новый класс. ExportManager не трогаем.

Вариант с фабрикой

Тоже OCP:

final class ExporterFactory
{
    /** @var array<string, class-string<Exporter>> */
    private const MAP = [
        'json' => JsonExporter::class,
        'csv' => CsvExporter::class,
    ];

    public function create(string $format): Exporter
    {
        $class = self::MAP[$format]
            ?? throw new InvalidArgumentException("Unknown: $format");

        return new $class();
    }
}

Минус: для нового формата правим MAP. Но это одна строка.

«Закрыт для изменения»

Не значит «нельзя менять никогда».

Значит: добавление новой функциональности не требует правки существующего кода.

Багфиксы, рефакторинг — можно.

Пределы

Нельзя предусмотреть всё.

Правило трёх:

  1. Первый раз — пиши просто.
  2. Второй раз — обобщи.
  3. Третий раз — выдели абстракцию.

Не абстрагируй заранее — преждевременная оптимизация.

Пример: скидки

Плохо:

class PriceCalculator
{
    public function calculate(int $price, string $customerType): int
    {
        return match ($customerType) {
            'regular' => $price,
            'vip' => (int) ($price * 0.9),
            'employee' => (int) ($price * 0.5),
            // новый тип — правка
        };
    }
}

Хорошо:

interface DiscountRule
{
    public function discount(int $price): int;
}

final class RegularDiscount implements DiscountRule
{
    public function discount(int $price): int { return $price; }
}

final class VipDiscount implements DiscountRule
{
    public function discount(int $price): int
    {
        return (int) ($price * 0.9);
    }
}

final class EmployeeDiscount implements DiscountRule
{
    public function discount(int $price): int
    {
        return (int) ($price * 0.5);
    }
}

final class PriceCalculator
{
    public function calculate(int $price, DiscountRule $rule): int
    {
        return $rule->discount($price);
    }
}

Расширение через композицию

Дополнительные скидки:

final class StackedDiscount implements DiscountRule
{
    /** @param DiscountRule[] $rules */
    public function __construct(private array $rules) {}

    public function discount(int $price): int
    {
        $result = $price;

        foreach ($this->rules as $rule) {
            $result = $rule->discount($result);
        }

        return $result;
    }
}

$stacked = new StackedDiscount([
    new VipDiscount(),
    new PromoDiscount(500),
]);

$calc->calculate(1000, $stacked);

Один вызов — цепочка скидок.

Паттерны и OCP

OCP тесно связан с паттернами:

  • Strategy — заменяемый алгоритм.
  • Decorator — расширение поведения.
  • Factory — создание без правки.
  • Observer — подписки.

Типичные ошибки

1. Абстракция ради абстракции:

interface UserNameProviderInterface
{
    public function getName(): string;
}
// ради одного класса

2. Слишком общая абстракция:

interface Entity
{
    public function getId(): int;
    public function save(): void;
    public function delete(): void;
    public function validate(): bool;
    public function toJson(): string;
    // ...
}

3. Преждевременное обобщение:

// «На будущее» сделаем интерфейс для одного класса
interface CalculatorInterface { }
class Calculator implements CalculatorInterface { }

Практика: Notification

<?php

declare(strict_types=1);

interface NotificationChannel
{
    public function send(string $recipient, string $message): void;
    public function supports(string $channel): bool;
}

final class EmailChannel implements NotificationChannel
{
    public function send(string $recipient, string $message): void
    {
        echo "Email → $recipient: $message\n";
    }

    public function supports(string $channel): bool
    {
        return $channel === 'email';
    }
}

final class SmsChannel implements NotificationChannel
{
    public function send(string $recipient, string $message): void
    {
        echo "SMS → $recipient: " . mb_substr($message, 0, 70) . "\n";
    }

    public function supports(string $channel): bool
    {
        return $channel === 'sms';
    }
}

final class NotificationService
{
    /** @param NotificationChannel[] $channels */
    public function __construct(private array $channels) {}

    public function notify(string $channel, string $recipient, string $message): void
    {
        foreach ($this->channels as $c) {
            if ($c->supports($channel)) {
                $c->send($recipient, $message);
                return;
            }
        }

        throw new InvalidArgumentException("Канал не поддерживается: $channel");
    }
}

$service = new NotificationService([
    new EmailChannel(),
    new SmsChannel(),
]);

$service->notify('email', 'user@example.com', 'Добро пожаловать!');
$service->notify('sms', '+79001234567', 'Код: 1234');

Чтобы добавить — новый класс TelegramChannel. Service не меняется.

Итог

  • OCP — открыт для расширения, закрыт для изменения.
  • Полиморфизм — основной инструмент.
  • Интерфейс + реализация — вместо if/else.
  • Правило трёх — не абстрагируй заранее.
  • Паттерны: Strategy, Decorator, Factory.

Дальше — SOLID: LSP — подстановка Лисков.