Що таке принцип Liskov Substitution?
Простіше кажучи: якщо клас B наслідується від класу A, то всюди, де використовується A, повинен працювати і B — без сюрпризів.
🟢 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:
- Передумови (Preconditions) не можуть бути посилені: Підклас не може вимагати більше, ніж батько. (Якщо батько приймає будь-яке число, нащадок не може вимагати тільки додатні)
- Післяумови (Postconditions) не можуть бути послаблені: Підклас повинен гарантувати як мінімум той самий результат, що і батько
- Інваріанти (Invariants) повинні зберігатися: Стан об’єкта, який був істинним для батька, повинен залишатися істинним і для нащадка
- Історія (History Constraint): Підклас не повинен змінювати стан, який у батька вважався незмінним
Як виявити порушення LSP
Ознаки порушення:
instanceofу коді: Якщо ви перевіряєте тип об’єкта, щоб викликати специфічний метод — ієрархія спроектована неправильно- Порожні методи: Нащадок перевизначає метод батька порожньою реалізацією, бо “йому це не потрібно”
- Кидання неочікуваних винятків: Як у прикладі з
UnsupportedOperationException - Документація “не підтримує”: У 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; }
}
Типові помилки
-
Помилка: Наслідування заради перевикористання коду (“у них же є спільні поля!”) Рішення: Використовуйте композицію замість наслідування
-
Помилка:
java.util.Collections.unmodifiableList()кидаєUnsupportedOperationExceptionприadd()Рішення: Це відоме порушення LSP. ВикористовуйтеReadOnlyListwrapper з інтерфейсом тільки для читання -
Помилка: Модифікація інваріанта батька Рішення: Чітко документуйте інваріанти кожного класу
Коли 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 захищає нас від ситуації, коли зміна внутрішньої логіки нащадка ламає логіку, на яку покладається клієнт.
- Класичний приклад:
ArrayListvsCollections.unmodifiableList()unmodifiableListє обгорткою надList, але кидаєUnsupportedOperationExceptionпри спробіadd()- Це порушення LSP, оскільки клієнт, що очікує
List, не готовий до такої поведінки
Архітектурні Trade-offs
Суворе дотримання LSP:
- ✅ Плюси: Передбачувана поведінка, надійне наслідування, мінімум runtime-помилок
- ❌ Мінуси: Обмежує перевикористання коду, вимагає ретельнішого проектування
Композиція замість наслідування:
- ✅ Плюси: Повна свобода поведінки, немає зобов’язань перед контрактом батька
- ❌ Мінуси: Більше boilerplate-коду, потрібно делегувати методи
Edge Cases
- Template Method Pattern: Базовий клас визначає алгоритм, підкласи — кроки. Чи порушує це LSP?
- Відповідь: Ні, якщо підкласи не змінюють інваріанти алгоритму. Але якщо крок може “зламати” алгоритм — це порушення
- Decorators: Обгортки додають поведінку. Чи порушують LSP?
- Відповідь: Ні, якщо вони делегують всі методи базовому об’єкту і не змінюють його інваріанти
- 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 у коді:
- Code Review ознаки:
- Якщо
instanceofвикористовується для обходу різних поведінок замість поліморфізму — це порушення LSP. Pattern matching з sealed types — легітимний виняток. - Порожні override-методи
throw new UnsupportedOperationException(...)
- Якщо
- Unit Test ознаки:
- Тести для базового класу падають при запуску з нащадком
- Різна поведінка одного методу для різних підкласів
- Інструменти:
- 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у нащадку — яскрава ознака порушення LSPinstanceofу коді для обходу різних поведінок = 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]]