Питання 3 · Розділ 18

Що таке принцип Open-Closed?

Простіше кажучи: ви повинні мати можливість додати нову функціональність у систему, не змінюючи вже існуючий і працюючий код.

Мовні версії: English Russian Ukrainian

🟢 Junior Level

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

Пояснення: Програмні сутності = класи, модулі, функції. «Закриті для модифікації» = не потрібно міняти вже протестований і працюючий код. Натомість — додавайте нові класи/модулі. «Відкриті для розширення» = нову функціональність можна додати без правки існуючого коду.

Простіше кажучи: ви повинні мати можливість додати нову функціональність у систему, не змінюючи вже існуючий і працюючий код.

Проста аналогія: Уявіть смартфон з роз’ємом USB. Ви можете підключити нові пристрої (навушники, флешку, клавіатуру), не розкриваючи й не переробляючи сам телефон.

Приклад порушення OCP:

// Погано: при додаванні нової фігури доведеться міняти цей метод
public double calculateArea(Object shape) {
    if (shape instanceof Circle) {
        Circle c = (Circle) shape;
        return Math.PI * c.radius * c.radius;
    } else if (shape instanceof Rectangle) {
        Rectangle r = (Rectangle) shape;
        return r.width * r.height;
    }
    // Новий тип фігури — потрібно міняти цей метод!
    throw new IllegalArgumentException("Unknown shape");
}

Приклад з дотриманням OCP:

// Добре: кожен клас знає свою площу сам
public interface Shape {
    double calculateArea();
}

public class Circle implements Shape {
    private double radius;
    @Override
    public double calculateArea() {
        return Math.PI * radius * radius;
    }
}

public class Rectangle implements Shape {
    private double width;
    private double height;
    @Override
    public double calculateArea() {
        return width * height;
    }
}

// Клієнтський код — НЕ ЗМІНЮЄТЬСЯ при додаванні нових фігур
public double calculateTotalArea(List<Shape> shapes) {
    return shapes.stream().mapToDouble(Shape::calculateArea).sum();
}

Коли використовувати:

  • Завжди, коли очікується, що система буде розширюватися новими типами, форматами, правилами
  • При проектуванні API, бібліотек, плагінів
  • При створенні бізнес-логіки, яка буде змінюватися з часом

🟡 Middle Level

Як це працює

OCP спирається на два ключові механізми:

  1. Абстракції (інтерфейси, абстрактні класи): клієнтський код залежить від абстракції, а не від конкретної реалізації
  2. Поліморфізм: нові реалізації підставляються без зміни клієнтського коду

Як виявити порушення OCP

Ознаки порушення:

  1. Ланцюжки if-else або switch за типом об’єкта: кожен новий тип вимагає додавання нової гілки
  2. Часті правки в стабільних модулях: код, який має бути “замороженим”, постійно редагується
  3. Регресійні баги: зміна для нової фічі ламає стару функціональність
  4. Звичайні перевірки instanceof — ознака порушення OCP. Виняток — pattern matching із sealed interface (Java 21+), де компілятор гарантує повноту покриття всіх варіантів.

Pattern Matching із sealed interface доступний з Java 21. Для Java 8/11 використовуйте Strategy pattern.

Практичне застосування

Патерн Strategy для OCP:

// Порушення OCP: метод з купою умов
public class PaymentService {
    public void processPayment(String type, BigDecimal amount) {
        if ("credit_card".equals(type)) {
            processCreditCard(amount);
        } else if ("paypal".equals(type)) {
            processPayPal(amount);
        } else if ("crypto".equals(type)) {
            processCrypto(amount);
        }
        // Новий спосіб оплати — міняємо існуючий код!
    }
}

// Дотримання OCP: стратегія
public interface PaymentStrategy {
    void process(BigDecimal amount);
}

public class CreditCardPayment implements PaymentStrategy {
    @Override
    public void process(BigDecimal amount) { /* логіка */ }
}

public class PaymentService {
    private final Map<String, PaymentStrategy> strategies;

    public PaymentService(List<PaymentStrategy> strategyList) {
        this.strategies = strategyList.stream()
            .collect(Collectors.toMap(
                s -> s.getClass().getSimpleName(),
                s -> s
            ));
    }

    public void processPayment(String type, BigDecimal amount) {
        PaymentStrategy strategy = strategies.get(type);
        if (strategy == null) {
            throw new IllegalArgumentException("Unknown payment type: " + type);
        }
        strategy.process(amount);
    }
}

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

  1. Помилка: Створення інтерфейсу “на майбутнє” для класу з однією реалізацією Рішення: Слідуйте YAGNI — додавайте абстракцію, коли з’явилася друга реалізація або реальна потреба

  2. Помилка: Надмірне захоплення наслідуванням замість композиції Рішення: Віддавайте перевагу композиції — вона дає більшу гнучкість

  3. Помилка: OCP тільки для одного рівня (розширюєте сервіс, але не репозиторій) Рішення: Застосовуйте принцип послідовно по всій архітектурі

Коли НЕ варто строго слідувати OCP

  • Прості CRUD-додатки з фіксованим набором операцій
  • Прототипи та MVP (швидка перевірка гіпотези)
  • Код, який гарантовано не буде розширюватися (утиліти, конвертери форматів)

🔴 Senior Level

Internal Implementation та Архітектура

OCP — це не просто “використовуй інтерфейси”. Це принцип управління ризиками змін. Бертран Меєр, який сформулював OCP у 1988 році, мав на увазі наступне:

Модуль вважається “закритим”, коли він доступний для використання іншими модулями. Він вважається “відкритим”, коли залишається доступним для розширення (у ньому можна визначати нові поведінки).

На рівні Senior важливо розуміти: OCP застосовується не до кожного класу, а до архітектурно значущих точок розширення.

Архітектурні Trade-offs

Строге дотримання OCP:

  • ✅ Плюси: Мінімум регресійних багів, легке розширення, стабільний API, паралельна розробка
  • ❌ Мінуси: Більше абстракцій, складність початкового проектування, cognitive overhead

Помірне дотримання OCP:

  • ✅ Плюси: Баланс між простотою та гнучкістю, менше boilerplate
  • ❌ Мінуси: Вимагає зрілого судження, ризик поступового накопичення технічного боргу

Edge Cases

  1. Abstractions Overkill: Не потрібно робити інтерфейс для кожного класу. Якщо у класу буде лише одна реалізація — інтерфейс може бути зайвим ускладненням (порушення YAGNI)

  2. Взаємозалежні розширення: Коли нова фіча вимагає змін у 3+ шарах (controller → service → repository)
    • Рішення: Проектуйте “точки розширення” заздалегідь на рівні API/контрактів
  3. Наслідування vs Композиція: Наслідування створює жорстке зчеплення. Композиція з делегуванням — гнучке
    • Правило: Віддавайте перевагу композиції, наслідування — тільки для істинних “is-a” відносин

Продуктивність

  • Virtual method dispatch: Виклик методу через інтерфейс додає непряму адресацію. JVM робить inline caching та devirtualization, зводячи оверхед до мінімуму
  • Startup time: Велика кількість дрібних класів/інтерфейсів незначно сповільнює старт (завантаження метаданих, classloading)
  • JIT оптимізація: Чим більше конкретних реалізацій інтерфейсу, тим складніше JIT обрати оптимальну стратегію inlining. При > 2-3 реалізацій може статися megamorphic call — значне падіння продуктивності. Коли у інтерфейсу 3+ реалізації, JVM не може оптимізувати виклик. Замість прямого виклику (1-2 ns) відбувається пошук у таблиці (~10-20 ns).

Production Experience

Реальний сценарій із продакшену:

У фінансовому проекті була система розрахунку комісій з hardcoded логікою для 3 типів клієнтів. При виході на новий ринок (10+ країн) знадобилося 6 місяців рефакторингу:

  1. Виділили інтерфейс FeeCalculator
  2. Створили PercentageFeeCalculator, TieredFeeCalculator, FlatFeeCalculator
  3. Реалізували фабрику на основі конфігурації

Результат: Додавання нової країни скоротилося з 2 тижнів до 2 днів (тільки конфігурація + один новий клас).

Monitoring та діагностика

Як виявити порушення OCP у коді:

  1. Метрики коду:
    • Кількість if-else / switch гілок за типом > 3
    • Частота змін одного файлу (> 5 правок/місяць у стабільному модулі)
    • Кількість instanceof перевірок
  2. Інструменти:
    • SonarQube (Cognitive Complexity, Number of Branches)
    • Git history (git log --follow -- <file> — часті правки = OCP violation)
    • ArchUnit (архітектурні тести на залежності)
  3. CI/CD ознаки:
    • Регресійні баги при додаванні нових фіч
    • Довгі код-ревью через великий diff у стабільних класах

Best Practices для Highload

  • Plugin Architecture: Проектуйте систему як ядро + плагіни. Ядро стабільне, плагіни розширюються
  • Specification Pattern: Для складної бізнес-логіки використовуйте композицію специфікацій замість if-else
  • Event-Driven Extension: Замість прямого виклику використовуйте події — нові обробники підписуються без зміни видавця

Зв’язок з іншими принципами

  • OCP ← SRP: Коли у класу одна відповідальність, його легше розширювати без модифікації
  • OCP → LSP: Нові реалізації мають коректно підставлятися замість абстракції
  • OCP → ISP: Маленькі інтерфейси легше розширювати
  • OCP → DIP: Залежність від абстракцій — фундамент для OCP

Резюме для Senior

  • OCP — це про зниження ризиків, а не про красу архітектури
  • Застосовуйте OCP до точок розширення, а не до кожного класу
  • Пам’ятайте про JIT megamorphic call — занадто багато реалізацій інтерфейсу погіршують продуктивність
  • Використовуйте Event-Driven підхід для слабкого зчеплення розширень
  • OCP без реальної потреби у розширенні — це over-engineering

🎯 Шпаргалка для інтерв’ю

Обов’язково знати:

  • OCP: класи відкриті для розширення, але закриті для модифікації
  • Головна ознака порушення — ланцюжки if-else / switch за типом об’єкта
  • Патерн Strategy — основний інструмент дотримання OCP
  • OCP спирається на абстракції (інтерфейси) та поліморфізм
  • Megamorphic call: при >2-3 реалізаціях інтерфейсу JIT втрачає оптимізацію
  • OCP застосовується до точок розширення, а не до кожного класу
  • Pattern matching із sealed interface (Java 21+) — легітимна альтернатива Strategy

Часті уточнюючі питання:

  • Як виявити порушення OCP?instanceof перевірки, часті правки в стабільних модулях, регресійні баги
  • Коли НЕ потрібен OCP? — CRUD з фіксованими операціями, прототипи, код який не буде розширюватися
  • Що таке megamorphic call? — Коли у інтерфейсу 3+ реалізації, JVM не може оптимізувати виклик (10-20 ns замість 1-2 ns)
  • OCP і Plugin Architecture? — Ядро стабільне, плагіни розширюються через SPI/ServiceLoader

Червоні прапорці (НЕ говорити):

  • “Потрібно робити інтерфейс для кожного класу на майбутнє” (порушення YAGNI)
  • “OCP означає, що код взагалі не можна міняти” (не можна модифікувати працюючий, але можна розширювати)
  • “Switch — це завжди погано” (для закритих ієрархій із sealed interface — допустимо)

Пов’язані теми:

  • [[4. Як рефакторити код, що порушує принцип Open_Closed]]
  • [[19. Як принципи SOLID допомагають при розширенні функціоналу]]
  • [[7. Що таке принцип Interface Segregation]]
  • [[8. Що таке принцип Dependency Inversion]]