Skip to main content

Що таке принцип відкритості-закритості (OCP)?

Принцип відкритості-закритості (OCP) - правило SOLID, яке каже: програмні сутності мають бути відкриті для розширення, але закриті для модифікації.

Теорія

TL;DR

  • OCP схожий на USB-порт: нові пристрої підключаєш без переробки комп'ютера
  • Головна ідея: нову поведінку додаєш новим кодом, а не редагуванням старого
  • Абстрактний клас або інтерфейс - стабільний контракт; нові типи реалізують його
  • Правило вибору: якщо нова функція вимагає змін у старих файлах, дизайн порушує OCP
  • Компроміс: поліморфний виклик додає ~2-10ns, що відчутно у гарячих циклах

Швидкий приклад

javascript
// ПОГАНО: кожна нова фігура потребує редагування цього файлу class AreaCalculator { calculate(shape) { if (shape.type === 'circle') return Math.PI * shape.r ** 2; if (shape.type === 'square') return shape.side ** 2; // Прямокутник? Редагуй. Трикутник? Редагуй знову. } } // ДОБРЕ: нові фігури підключаються без змін у існуючому коді class Shape { area() { throw new Error('Реалізуй area()'); } } class Circle extends Shape { constructor(r) { super(); this.r = r; } area() { return Math.PI * this.r ** 2; } } class Rectangle extends Shape { constructor(w, h) { super(); this.w = w; this.h = h; } area() { return this.w * this.h; } } // new Rectangle(2, 3).area() → 6 // Shape і Circle: не торкалися.

Кожна нова фігура просто розширює Shape. Калькулятор викликає .area() на тому, що отримує, без жодного if/else.

Абстракція vs реалізація

OCP розділяє дві речі: стабільний контракт (абстрактний інтерфейс) і розширювані деталі (конкретні реалізації). Існуючий код лишається незмінним, бо нові типи підключаються до контракту через наслідування. Це захищає від регресійних помилок у вже протестованому коді.

Зручна метафора з практики: AreaCalculator не повинен знати, які фігури існують. Він знає тільки одне: "у цієї штуки є метод area()". Контракт закрито. Нові фігури - відкриті.

Коли застосовувати OCP

OCP доречний, коли можна передбачити, що поведінка зростатиме в одному напрямку:

  • Кілька форматів виведення (логери, принтери, серіалізатори): базовий клас з log() або serialize(), нові формати - нові класи
  • Системи плагінів: визначаєш протокол або інтерфейс, треті сторони реалізують його без доступу до ядра
  • Платіжні процесори: Strategy pattern зі спільним інтерфейсом, Stripe або PayPal - окремий клас
  • Стеки middleware: app.use() в Express - це OCP на практиці

Пропусти OCP для одноразового коду або невеликих утиліт, де витрати на абстракцію перевищують вигоду. Тут добре спрацьовує YAGNI.

Як V8 обробляє поліморфні виклики

JavaScript-рушії використовують таблиці віртуальних методів (vtables) для поліморфного диспетчеризування. Коли виконується shape.area(), V8 шукає в vtable конкретного класу і викликає потрібну реалізацію - без знання типу на етапі компіляції. Саме це робить OCP можливим у JavaScript.

Вартість: 1-2 непрямі переходи на виклик, приблизно 2-10ns. На 10 000 фігур за один кадр це накопичується. У критичних за продуктивністю ділянках краще використовувати конкретні типи або пакетні операції.

Типові помилки

Помилка 1: наслідування для стану замість поведінки

javascript
// Неправильно: Square IS-A Rectangle ламає сеттери class Rectangle { constructor(w, h) { this.w = w; this.h = h; } setWidth(w) { this.w = w; } } class Square extends Rectangle { setWidth(w) { this.w = this.h = w; } // змінює базову поведінку }

Square, що перевизначає setWidth, ламає тих, хто використовує Rectangle. Це ще й порушує принцип підстановки Ліскова (LSP), що руйнує розширення OCP. Рішення: композиція. class Square { constructor(s) { this.rect = new Rectangle(s, s); } }.

Помилка 2: абстрактний клас з конкретними методами за замовчуванням

javascript
// Неправильно: дефолтна логіка стає магнітом для змін class Shape { area() { return 0; } } // виглядає нешкідливо

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

Помилка 3: передчасна абстракція

javascript
// Неправильно: інтерфейс для кожної дрібниці // 50 реалізацій там, де вистачило б 3 функцій interface ILogger { log(): void; }

Починай з конкретного. Абстрагуй тільки тоді, коли справді потрібне розширення.

Помилка 4: поліморфізм у гарячих циклах

javascript
// Ризиковано у критичних за продуктивністю ділянках shapes.forEach(shape => total += shape.area()); // vtable-пошук на кожен виклик

V8 іноді може девіртуалізувати це, але не завжди. Якщо профайлер показує цю ділянку як вузьке місце, використовуй типізовані масиви або конкретні типи.

Де зустрічається в реальних проектах

  • React: HOC на кшталт withRouter у react-router розширюють компоненти без зміни їхнього коду
  • Express: middleware через app.use() додає авторизацію або логування без змін у роутері
  • Redux: combineReducers додає функціональність без редагування існуючих reducer-файлів
  • NestJS: декоратори @Injectable дозволяють розширювати модулі без змін у базових класах
  • Stripe Node SDK: класи PaymentMethod розширюють базу через плагіни, без редагування ядра

Follow-up питання

Q: Чим OCP відрізняється від принципу стабільних абстракцій (SAP)?
A: OCP каже "не модифікуй". SAP кількісно визначає, наскільки абстрактним має бути пакет відносно кількості залежностей від нього. Роберт Мартін описує SAP як метрику здоров'я OCP через коефіцієнти fan-out/in. Відповідь джуніора: "це одне й те саме". Відповідь сеньйора: "SAP дає число, щоб виміряти, чи справді OCP працює".

Q: Покажи порушення OCP і виправ його в TypeScript через інтерфейси.
A: Порушення: функція зі switch по рядкових літералах. Виправлення: interface Shape { area(): number; }, один клас на тип фігури. Функція, що приймає Shape, більше ніколи не змінюється.

Q: Які компроміси OCP в мікросервісах порівняно з монолітом?
A: У моноліті наслідування і спільні базові класи прості у використанні. У мікросервісах спільні базові класи стають проблемою зв'язування між сервісами. Там краще підходять event-контракти або OpenAPI-схеми як "закрита" частина, а розширення відбуваються в окремих сервісах.

Q: У легасі-кодовій базі зі 100 гілками if/else, який твій план міграції до OCP?
A: Патерн Strangler. Спочатку виділяєш інтерфейс, обгортаєш легасі-код в адаптер, мігруєш по одній гілці за раз. Тести перед кожним кроком. Жодних великих рефакторингів за один раз.

Приклади

Базовий: калькулятор площі фігур

javascript
class Shape { area() { throw new Error('Підклас має реалізувати area()'); } } class Circle extends Shape { constructor(radius) { super(); this.radius = radius; } area() { return Math.PI * this.radius ** 2; } } class Rectangle extends Shape { constructor(width, height) { super(); this.width = width; this.height = height; } area() { return this.width * this.height; } } class Triangle extends Shape { constructor(base, height) { super(); this.base = base; this.height = height; } area() { return (this.base * this.height) / 2; } } const shapes = [new Circle(5), new Rectangle(4, 6), new Triangle(3, 8)]; shapes.forEach(s => console.log(s.area())); // 78.54, 24, 12

Додавання Triangle не торкнулося жодного рядка в Shape, Circle або Rectangle. Новий клас - і все.

Середній рівень: Express middleware для логування

javascript
// Базовий логер - закритий для модифікації class Logger { log(request) { console.log(`${request.method} ${request.url}`); } } // Розширення для JSON-формату - Logger не чіпаємо class JSONLogger extends Logger { log(request) { console.log(JSON.stringify({ method: request.method, url: request.url, timestamp: new Date().toISOString() })); } } // Розширення для Sentry - Logger знову не чіпаємо class SentryLogger extends Logger { log(request) { Sentry.captureMessage(`Access: ${request.method} ${request.url}`); } } const logger = new JSONLogger(); app.use((req, res, next) => { logger.log(req); next(); }); // Вивід: {"method":"GET","url":"/api/users","timestamp":"2025-01-15T10:00:00Z"}

Потрібен DatadogLogger завтра? Один новий клас. Logger, app.js і JSONLogger лишаються незмінними.

Просунутий рівень: React HOC-композиція

javascript
// Закритий HOC-контракт - надає prop featureEnabled function withFeature(WrappedComponent) { return class extends React.Component { render() { return <WrappedComponent {...this.props} featureEnabled={true} />; } }; } // Розширення: додає аналітику - WrappedComponent не торкаємось function withAnalytics(WrappedComponent) { return class extends React.Component { componentDidMount() { analytics.track('ComponentMounted', this.props); } render() { return <WrappedComponent {...this.props} />; } }; } // Композиція без змін у Button const TrackedButton = withAnalytics(withFeature(Button)); // Монтує Button, логує {page: 'home', featureEnabled: true}

Порядок HOC має значення. withAnalytics обгортає withFeature(Button), тому аналітика спрацьовує після того, як featureEnabled вже є в props. Поміняй порядок - і featureEnabled зникне з payload аналітики. Це і є підступний момент у OCP-композиції HOC, який виловлюють на співбесідах рівня сеньйор.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?