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;
}
Добавили новую скидку - полезли править функцию.
Лучше: стратегия
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); }
Чек-лист «когда применять OCP»
Перед тем как вытаскивать интерфейс, спроси себя:
- Появилась ли у этого
if/switchуже третья ветка, или просто две? - Прогнозируется ли четвёртая в обозримом будущем?
- Стоит ли за каждой веткой собственное состояние/зависимости (тогда стратегия), или просто разные числа (тогда параметр)?
- Удлинит ли интерфейс цепочку «прочитай - пойми - измени»?
Если хотя бы на половину вопросов ответ «нет» - оставь switch и двигайся дальше. Это не проигрыш, это здравый смысл.
Мини‑задание
- Возьми кусок кода с if/switch по типу и замени на полиморфизм (интерфейс + реализации)
- Найди в проекте middleware-пайплайн и попробуй добавить новое поведение (например, request-id) без правки роутера
- Убедись, что свежий switch на 2 кейса оставлен как есть - не каждое разветвление достойно интерфейса