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

Що таке принцип Liskov Substitution?

Простіше кажучи: якщо клас B наслідується від класу A, то всюди, де використовується A, повинен працювати і B — без сюрпризів.

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

🟢 Junior Level

Принцип підстановки Лісков (LSP — Liskov Substitution Principle) — це один з п’яти принципів SOLID, який гласить: “Об’єкти підкласу повинні бути замінені об’єктами базового класу без порушення коректності програми”.

Простіше кажучи: якщо клас B наслідується від класу A, то всюди, де використовується A, повинен працювати і B — без сюрпризів.

Проста аналогія: Уявіть розетку. Якщо ви купите будь-яку вилку, що відповідає стандарту, вона повинна працювати. Якщо якась вилка “спеціальна” і не вставляється — стандарт порушено.

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

// Погано: нащадок кидає виняток на методі батька
public class Bird {
    public void fly() {
        System.out.println("Лечу!");
    }
}

public class Ostrich extends Bird {
    @Override
    public void fly() {
        throw new UnsupportedOperationException("Страуси не літають!");
    }
}

// Клієнтський код — зламається при підстановці Ostrich
public void makeBirdsFly(List<Bird> birds) {
    for (Bird bird : birds) {
        bird.fly(); // ClassCastException або UnsupportedOperationException!
    }
}

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

// Добре: розділення за поведінкою
public interface Bird {
    void sing();
}

public interface FlyingBird extends Bird {
    void fly();
}

public class Sparrow implements FlyingBird {
    @Override
    public void sing() { System.out.println("Цвірінькаю!"); }
    @Override
    public void fly() { System.out.println("Лечу!"); }
}

public class Ostrich implements Bird {
    @Override
    public void sing() { System.out.println("Шиплю!"); }
    // fly() відсутній — і це коректно
}

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

  • Завжди при проектуванні ієрархій наслідування
  • При створенні базових класів, які будуть розширюватися
  • При роботі з колекціями базових типів

🟡 Middle Level

Як це працює

LSP — найбільш технічно складний принцип SOLID. Він не просто про наслідування, а про проектування за контрактом (Design by Contract).

Терміни Design by Contract:

  • Preconditions — вимоги до вхідних даних (що повинно бути true ДО виклику)
  • Postconditions — гарантії результату (що буде true ПІСЛЯ виклику)
  • Invariants — умови, які ЗАВЖДИ істинні для об’єкта
  • History Constraint — об’єкт не повинен змінювати минулий стан непередбачувано

Контрактні правила LSP:

  1. Передумови (Preconditions) не можуть бути посилені: Підклас не може вимагати більше, ніж батько. (Якщо батько приймає будь-яке число, нащадок не може вимагати тільки додатні)
  2. Післяумови (Postconditions) не можуть бути послаблені: Підклас повинен гарантувати як мінімум той самий результат, що і батько
  3. Інваріанти (Invariants) повинні зберігатися: Стан об’єкта, який був істинним для батька, повинен залишатися істинним і для нащадка
  4. Історія (History Constraint): Підклас не повинен змінювати стан, який у батька вважався незмінним

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

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

  1. instanceof у коді: Якщо ви перевіряєте тип об’єкта, щоб викликати специфічний метод — ієрархія спроектована неправильно
  2. Порожні методи: Нащадок перевизначає метод батька порожньою реалізацією, бо “йому це не потрібно”
  3. Кидання неочікуваних винятків: Як у прикладі з UnsupportedOperationException
  4. Документація “не підтримує”: У javadoc написано “This implementation does not support…”

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

Класичний приклад: Квадрат і Прямокутник

// Порушення LSP
public class Rectangle {
    protected int width;
    protected int height;

    public void setWidth(int width) { this.width = width; }
    public void setHeight(int height) { this.height = height; }
    public int getArea() { return width * height; }
}

public class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width; // "Побічний ефект"
    }

    @Override
    public void setHeight(int height) {
        this.width = height; // "Побічний ефект"
        this.height = height;
    }
}

// Клієнтський код — зламається при підстановці Square
public void testRectangle(Rectangle r) {
    r.setWidth(10);
    r.setHeight(20);
    // Для Rectangle: площа = 200
    // Для Square: площа = 400 — клієнт у шоці!
}

Правильне рішення — композиція, а не наслідування:

// Добре: інтерфейс замість наслідування
public interface Shape {
    int getArea();
}

public class Rectangle implements Shape {
    private int width;
    private int height;

    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }

    public void setWidth(int width) { this.width = width; }
    public void setHeight(int height) { this.height = height; }
    @Override
    public int getArea() { return width * height; }
}

public class Square implements Shape {
    private int side;

    public Square(int side) { this.side = side; }
    @Override
    public int getArea() { return side * side; }
}

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

  1. Помилка: Наслідування заради перевикористання коду (“у них же є спільні поля!”) Рішення: Використовуйте композицію замість наслідування

  2. Помилка: java.util.Collections.unmodifiableList() кидає UnsupportedOperationException при add() Рішення: Це відоме порушення LSP. Використовуйте ReadOnlyList wrapper з інтерфейсом тільки для читання

  3. Помилка: Модифікація інваріанта батька Рішення: Чітко документуйте інваріанти кожного класу

Коли LSP особливо важливий

  • Публічні API та бібліотеки (користувачі очікують передбачуваної поведінки)
  • Фреймворки з плагінами/розширеннями
  • Системи з поліморфними колекціями

🔴 Senior Level

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

LSP — це про передбачуваність поведінки. Наслідування — це зобов’язання дотримуватися всіх інваріантів батька. Якщо підклас поводиться “дивно” з точки зору клієнта базового класу — LSP порушено.

На рівні Senior важливо говорити мовою обмежень контракту:

Constraint Правило Приклад порушення
Preconditions Не можуть бути посилені SavingsAccount.withdraw вимагає amount <= balance, а Account дозволяє овердрафт
Postconditions Не можуть бути послаблені FastSort може повернути частково відсортований масив
Invariants Повинні зберігатися Square змінює width при виклику setHeight
History Стан не повинен змінюватися непередбачувано CachingList повертає stale дані

// Ключове запитання: якщо клієнтський код написаний для Account і розраховує // на овердрафт, підстановка SavingsAccount зламає його логіку. // Якщо клієнт НЕ розраховує на овердрафт — порушення LSP немає.

LSP та типізація в Java

Java підтримує LSP на рівні компілятора через коваріантність типів, що повертаються:

public class Parent {
    public Number get() { return 1; }
}

public class Child extends Parent {
    @Override
    public Integer get() { return 1; } // Коваріантність: Integer - це Number. OK.
}

Однак компілятор не може перевірити семантичну коректність (як у прикладі з Квадратом і Прямокутником). Це відповідальність архітектора.

Контрваріантність аргументів (не підтримується в Java):

// Теоретично допустимо в LSP:
// Якщо Parent.accept(Object o), то Child.accept(String s) — звуження типу
// Але Java цього не дозволяє при override

Проблема крихкого базового класу (Fragile Base Class)

LSP захищає нас від ситуації, коли зміна внутрішньої логіки нащадка ламає логіку, на яку покладається клієнт.

  • Класичний приклад: ArrayList vs Collections.unmodifiableList()
    • unmodifiableList є обгорткою над List, але кидає UnsupportedOperationException при спробі add()
    • Це порушення LSP, оскільки клієнт, що очікує List, не готовий до такої поведінки

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

Суворе дотримання LSP:

  • ✅ Плюси: Передбачувана поведінка, надійне наслідування, мінімум runtime-помилок
  • ❌ Мінуси: Обмежує перевикористання коду, вимагає ретельнішого проектування

Композиція замість наслідування:

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

Edge Cases

  1. Template Method Pattern: Базовий клас визначає алгоритм, підкласи — кроки. Чи порушує це LSP?
    • Відповідь: Ні, якщо підкласи не змінюють інваріанти алгоритму. Але якщо крок може “зламати” алгоритм — це порушення
  2. Decorators: Обгортки додають поведінку. Чи порушують LSP?
    • Відповідь: Ні, якщо вони делегують всі методи базовому об’єкту і не змінюють його інваріанти
  3. Proxies та Mocks: Динамічні проксі для тестування
    • Відповідь: Можуть порушувати LSP, якщо не відтворюють всі післяумови оригіналу

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

  • Virtual dispatch: Виклик перевизначеного метода = непряма адресація. JIT робить inline caching та class hierarchy analysis
  • Inlining barrier: Якщо JIT не може довести, що метод не перевизначений — він не зможе зробити inlining. Це впливає на продуктивність у hot path
  • Escape Analysis: При створенні об’єктів нащадків JVM може оптимізувати allocation, але тільки якщо ієрархія відома на етапі JIT

Production Experience

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

У проекті використовувався BaseRepository з методом save(Entity). Один з підкласів AuditRepository перевизначив save() і додав логування після збереження. Інший підклас ValidatingRepository — валідацію до збереження.

Коли з’явився CachingRepository, який перевизначив save() і додав кеш замість БД — все зламалося: валідація не працювала, аудит не логував.

Рішення: Замінили наслідування на ланцюжок декораторів (Decorator Pattern), де кожен додає свою поведінку до базового save().

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

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

  1. Code Review ознаки:
    • Якщо instanceof використовується для обходу різних поведінок замість поліморфізму — це порушення LSP. Pattern matching з sealed types — легітимний виняток.
    • Порожні override-методи
    • throw new UnsupportedOperationException(...)
  2. Unit Test ознаки:
    • Тести для базового класу падають при запуску з нащадком
    • Різна поведінка одного методу для різних підкласів
  3. Інструменти:
    • Mutation Testing (Pitest): перевіряє, що тести ловлять зміни поведінки
    • ArchUnit: перевіряє, що нащадки не використовують заборонені патерни

Best Practices для Highload

  • Sealed Interfaces (Java 17+): Обмежте ієрархію — компілятор перевірить exhaustiveness, JIT оптимізує dispatch
  • Immutability: Незмінні об’єкти простіше зробити LSP-сумісними (немає змінного стану = немає порушення інваріантів)
  • Contract Testing: Пишіть тести на контракти базового типу і запускайте їх для всіх нащадків

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

  • LSP ← OCP: Якщо нащадки коректно підставляються, система відкрита для розширення
  • LSP ← SRP: Маленькі класи з однією відповідальністю легше правильно наслідувати
  • LSP → ISP: Розділення інтерфейсів автоматично допомагає дотримуватися LSP, оскільки контракти стають меншими

Резюме для Senior

  • LSP — це про передбачуваність поведінки, а не про синтаксис наслідування
  • Наслідування — це зобов’язання дотримуватися всіх інваріантів батька
  • Якщо підклас поводиться “дивно” з точки зору клієнта базового класу — LSP порушено
  • Пам’ятайте: Квадрат — це геометрично частковий випадок прямокутника, але в ООП Square не може бути нащадком Rectangle з методами setWidth/setHeight
  • Composition over Inheritance: Часто LSP порушується при спробі зекономити код через наслідування. Senior-розробник обере композицію

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

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

  • LSP: об’єкти підкласу повинні бути замінені об’єктами базового класу без порушення коректності
  • Design by Contract: передумови не можна посилювати, післяумови не можна послаблювати, інваріанти повинні зберігатися
  • Класичний приклад порушення — Square extends Rectangle (побічні ефекти в setter-ах)
  • UnsupportedOperationException у нащадку — яскрава ознака порушення LSP
  • instanceof у коді для обходу різних поведінок = LSP порушено
  • Композиція переважніша за наслідування для дотримання LSP
  • java.util.Date / java.sql.Date — відоме порушення LSP в JDK

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

  • Як виявити порушення LSP? — Тести базового класу падають для нащадка, instanceof перевірки, порожні override-методи
  • Що таке Fragile Base Class Problem? — Зміна батька ламає всіх нащадків непередбачуваним чином
  • Чи можна порушити LSP з інтерфейсами? — Так, якщо реалізація кидає неочікувані винятки або змінює контракт
  • Contract Testing що це? — Спільні тести для інтерфейсу, що запускаються для всіх реалізацій

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

  • “LSP — це просто про наслідування” (ні — це про передбачуваність поведінки та контракти)
  • “Квадрат наслідує Прямокутник — це нормально” (класичний приклад порушення)
  • “Якщо компілятор не лається — LSP дотримано” (LSP — семантичний, а не синтаксичний принцип)

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

  • [[6. Наведіть приклад порушення принципу Liskov Substitution]]
  • [[10. Що таке композиція і наслідування]]
  • [[11. У яких випадках краще використовувати композицію замість наслідування]]
  • [[7. Що таке принцип Interface Segregation]]