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 не меняется.
Как применять
Три шага:
- Выдели интерфейс для того, что меняется.
- Реализуй интерфейс в конкретных классах.
- Передавай интерфейс, не конкретный тип.
Пример: экспорт
Плохо:
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. Но это одна строка.
«Закрыт для изменения»
Не значит «нельзя менять никогда».
Значит: добавление новой функциональности не требует правки существующего кода.
Багфиксы, рефакторинг — можно.
Пределы
Нельзя предусмотреть всё.
Правило трёх:
- Первый раз — пиши просто.
- Второй раз — обобщи.
- Третий раз — выдели абстракцию.
Не абстрагируй заранее — преждевременная оптимизация.
Пример: скидки
Плохо:
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 — подстановка Лисков.