O - Open/Closed: расширяем без переписывания

O - Open/Closed: расширяем без переписывания

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

Когда добавляешь новый тип - не должен переписывать полпроекта.

Плохой пример: скидки через if

func CalcDiscount(kind string, sum int) int {
  if kind == "student" { return sum * 10 / 100 }
  if kind == "vip" { return sum * 20 / 100 }
  return 0
}
function calcDiscount(string $kind, int $sum): int {
  if ($kind === 'student') return intdiv($sum * 10, 100);
  if ($kind === 'vip') return intdiv($sum * 20, 100);
  return 0;
}
function calcDiscount(kind, sum) {
  if (kind === 'student') return Math.floor(sum * 10 / 100);
  if (kind === 'vip')     return Math.floor(sum * 20 / 100);
  return 0;
}

Добавили новую скидку - полезли править функцию.

Лучше: стратегия

Правка if в монолитном калькуляторе против добавления новой реализации Discount

type Discount interface { Apply(sum int) int }

type StudentDiscount struct{}
func (StudentDiscount) Apply(sum int) int { return sum * 10 / 100 }

type VipDiscount struct{}
func (VipDiscount) Apply(sum int) int { return sum * 20 / 100 }

func Calc(d Discount, sum int) int { return d.Apply(sum) }
interface Discount
{
    public function apply(int $sum): int;
}

final class StudentDiscount implements Discount
{
    public function apply(int $sum): int
    {
        return intdiv($sum * 10, 100);
    }
}

final class VipDiscount implements Discount
{
    public function apply(int $sum): int
    {
        return intdiv($sum * 20, 100);
    }
}

function calc(Discount $d, int $sum): int
{
    return $d->apply($sum);
}
/** @typedef {{ apply: (sum: number) => number }} Discount */

class StudentDiscount {
  apply(sum) { return Math.floor(sum * 10 / 100); }
}

class VipDiscount {
  apply(sum) { return Math.floor(sum * 20 / 100); }
}

/** @param {Discount} d */
function calc(d, sum) { return d.apply(sum); }
Новый тип поведения = новый класс, а не правки старого кода. Старый код стабилен - меньше багов.

OCP в Go-роутерах: middleware как точка расширения

Самый знакомый Go-разработчику пример OCP - HTTP middleware (это паттерн Decorator в чистом виде). Роутер один раз пишется так, чтобы принимать произвольную цепочку обработчиков. Дальше добавление новой кросс-функции (логирование, метрики, rate-limit, аутентификация) - это новый middleware, а не правка существующего.

type Middleware func(http.Handler) http.Handler

func Logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
    })
}

func RequireAuth(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.Header.Get("Authorization") == "" {
            http.Error(w, "unauthorized", http.StatusUnauthorized)
            return
        }
        next.ServeHTTP(w, r)
    })
}

// Применение: каждое новое поведение - новая обёртка, без правки роутера
handler := Logging(RequireAuth(routes))
<?php
declare(strict_types=1);

// Symfony EventSubscriber - стандартный механизм OCP в HTTP-стеке.
// Новый аспект (логирование, метрики, rate-limit) - новый subscriber,
// без правки роутера или контроллеров.
namespace App\Http\EventSubscriber;

use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\TerminateEvent;
use Symfony\Component\HttpKernel\KernelEvents;

final class LoggingSubscriber implements EventSubscriberInterface
{
    private float $start = 0.0;

    public function __construct(private readonly LoggerInterface $logger) {}

    public static function getSubscribedEvents(): array
    {
        return [
            KernelEvents::REQUEST   => 'onRequest',
            KernelEvents::TERMINATE => 'onTerminate',
        ];
    }

    public function onRequest(RequestEvent $event): void
    {
        $this->start = microtime(true);
    }

    public function onTerminate(TerminateEvent $event): void
    {
        $r = $event->getRequest();
        $this->logger->info('http', [
            'method'   => $r->getMethod(),
            'path'     => $r->getPathInfo(),
            'duration' => microtime(true) - $this->start,
        ]);
    }
}

// RequireAuth - отдельный subscriber на KernelEvents::REQUEST
// с приоритетом выше контроллеров. Добавление новой проверки -
// новый subscriber, существующие не трогаем.
// Express middleware - тот же паттерн. Новый аспект = новый middleware.
function logging(req, res, next) {
  const start = Date.now();
  res.on('finish', () => {
    console.log(`${req.method} ${req.url} ${Date.now() - start}ms`);
  });
  next();
}

function requireAuth(req, res, next) {
  if (!req.headers.authorization) {
    res.status(401).send('unauthorized');
    return;
  }
  next();
}

// Применение: каждое новое поведение - новый middleware, без правки роутера
app.use(logging);
app.use(requireAuth);
app.use(routes);

Появилась задача добавить трассировку OpenTelemetry? Пишем Tracing middleware и оборачиваем - ни роутер, ни существующие middleware не трогаем.

OCP через generics (Go 1.18+)

Иногда полиморфизм перебор, и достаточно параметризовать поведение функцией или типом. Generics превращают обычную процедуру в открытую для расширения без интерфейсов:

// Закрыто: универсальный «применитель скидки» - больше не правится
func ApplyAll[T any](items []T, fn func(T) T) []T {
    out := make([]T, len(items))
    for i, item := range items {
        out[i] = fn(item)
    }
    return out
}

// Открыто: каждое новое правило - новая функция, а не правка ApplyAll
func studentRule(sum int) int { return sum * 90 / 100 }
func vipRule(sum int) int     { return sum * 80 / 100 }
<?php
declare(strict_types=1);

// В PHP нет generics, но first-class callables + array_map дают тот же эффект.
// PHPStan/Psalm с дженерик-аннотациями @template обеспечивают типобезопасность.

/**
 * @template T
 * @param  T[]            $items
 * @param  callable(T): T $fn
 * @return T[]
 */
function applyAll(array $items, callable $fn): array
{
    return array_map($fn, $items);
}

// Открыто: каждое новое правило - новая функция, а не правка applyAll
$studentRule = static fn (int $sum): int => intdiv($sum * 90, 100);
$vipRule     = static fn (int $sum): int => intdiv($sum * 80, 100);

$discounted = applyAll([1000, 2000], $vipRule); // [800, 1600]
// JavaScript изначально функциональный - higher-order functions встроены.

/**
 * @template T
 * @param {T[]} items
 * @param {(item: T) => T} fn
 * @returns {T[]}
 */
function applyAll(items, fn) {
  return items.map(fn);
}

// Открыто: каждое новое правило - новая функция, а не правка applyAll
const studentRule = (sum) => Math.floor(sum * 90 / 100);
const vipRule     = (sum) => Math.floor(sum * 80 / 100);

const discounted = applyAll([1000, 2000], vipRule); // [800, 1600]

Generics - лёгкая альтернатива стратегии для случаев, где «поведение» - это просто функция, а не сущность с состоянием.

Связь с GoF Strategy

Если ты уже знаком с треком GoF, OCP - это архитектурное обоснование паттерна Strategy. Strategy решает, как добавлять варианты поведения через композицию объектов; OCP объясняет, зачем мы это делаем - чтобы стабильный код не приходилось править ради новых вариантов. Большинство OCP-рефакторингов в реальной жизни заканчиваются именно стратегией (или Decorator/Chain of Responsibility - для middleware).

Полиморфизм vs параметризация vs композиция

Способов закрыть код для изменений несколько, и они уместны в разных ситуациях:

ПодходКогда использовать
Интерфейс + реализацииПоведение содержит состояние или несколько связанных операций
Функция-параметрПоведение - это одна короткая операция без состояния
GenericsСтруктура алгоритма одна, тип данных - параметр

Главное - выбрать минимально-достаточный механизм. Интерфейс там, где хватит функции, - это шум.

Где OCP вредит: правило трёх

OCP не означает «делай всё расширяемым с первого дня». Если у тебя одна реализация скидки и других не предвидится - if/switch короче, понятнее и не плодит лишних типов. Включай OCP, когда видишь третью разновидность, которая просится в этот же код. До этого - YAGNI.

// Над-инжиниринг: только VIP, других категорий не было и не предвидится
type Discount interface { Apply(int) int }
type VipDiscount struct{}
func (VipDiscount) Apply(sum int) int { return sum * 80 / 100 }

// Достаточно:
func vipDiscount(sum int) int { return sum * 80 / 100 }
<?php
declare(strict_types=1);

// Над-инжиниринг: только VIP, других категорий не было и не предвидится
interface Discount
{
    public function apply(int $sum): int;
}

final class VipDiscount implements Discount
{
    public function apply(int $sum): int { return intdiv($sum * 80, 100); }
}

// Достаточно:
function vipDiscount(int $sum): int { return intdiv($sum * 80, 100); }
// Над-инжиниринг: только VIP, других категорий не было и не предвидится
/** @typedef {{ apply: (sum: number) => number }} Discount */

class VipDiscount {
  apply(sum) { return Math.floor(sum * 80 / 100); }
}

// Достаточно:
function vipDiscount(sum) { return Math.floor(sum * 80 / 100); }
Ты ловишь себя на мысли «опять трогаю этот switch, чтобы добавить новый case». Это сигнал, что код устал быть закрытым для изменений - пора инвертировать через интерфейс.

Чек-лист «когда применять OCP»

Перед тем как вытаскивать интерфейс, спроси себя:

  • Появилась ли у этого if/switch уже третья ветка, или просто две?
  • Прогнозируется ли четвёртая в обозримом будущем?
  • Стоит ли за каждой веткой собственное состояние/зависимости (тогда стратегия), или просто разные числа (тогда параметр)?
  • Удлинит ли интерфейс цепочку «прочитай - пойми - измени»?

Если хотя бы на половину вопросов ответ «нет» - оставь switch и двигайся дальше. Это не проигрыш, это здравый смысл.

Мини‑задание

  • Возьми кусок кода с if/switch по типу и замени на полиморфизм (интерфейс + реализации)
  • Найди в проекте middleware-пайплайн и попробуй добавить новое поведение (например, request-id) без правки роутера
  • Убедись, что свежий switch на 2 кейса оставлен как есть - не каждое разветвление достойно интерфейса

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.