Що таке принцип відкритості-закритості (OCP)?
Принцип відкритості-закритості (OCP) - правило SOLID, яке каже: програмні сутності мають бути відкриті для розширення, але закриті для модифікації.
Теорія
TL;DR
- OCP схожий на USB-порт: нові пристрої підключаєш без переробки комп'ютера
- Головна ідея: нову поведінку додаєш новим кодом, а не редагуванням старого
- Абстрактний клас або інтерфейс - стабільний контракт; нові типи реалізують його
- Правило вибору: якщо нова функція вимагає змін у старих файлах, дизайн порушує OCP
- Компроміс: поліморфний виклик додає ~2-10ns, що відчутно у гарячих циклах
Швидкий приклад
// ПОГАНО: кожна нова фігура потребує редагування цього файлу
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: наслідування для стану замість поведінки
// Неправильно: 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: абстрактний клас з конкретними методами за замовчуванням
// Неправильно: дефолтна логіка стає магнітом для змін
class Shape { area() { return 0; } } // виглядає нешкідливоКоли дефолтна логіка зміниться, всі підкласи під загрозою. Краще чисто абстрактний метод: кидай помилку, або в TypeScript оголошуй метод без тіла.
Помилка 3: передчасна абстракція
// Неправильно: інтерфейс для кожної дрібниці
// 50 реалізацій там, де вистачило б 3 функцій
interface ILogger { log(): void; }Починай з конкретного. Абстрагуй тільки тоді, коли справді потрібне розширення.
Помилка 4: поліморфізм у гарячих циклах
// Ризиковано у критичних за продуктивністю ділянках
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. Спочатку виділяєш інтерфейс, обгортаєш легасі-код в адаптер, мігруєш по одній гілці за раз. Тести перед кожним кроком. Жодних великих рефакторингів за один раз.
Приклади
Базовий: калькулятор площі фігур
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 для логування
// Базовий логер - закритий для модифікації
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-композиція
// Закритий 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, який виловлюють на співбесідах рівня сеньйор.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.