Що таке принцип Liskov Substitution
Якщо у вас є базовий клас (або інтерфейс) A та його нащадок B, то будь-яка частина програми, яка очікує об'єкт типу A, повинна без збоїв і непередбачуваних сюрпризів працювати й...
🟢 Junior Level
Принцип підстановки Лісков (Liskov Substitution Principle, LSP) — це третій принцип SOLID, сформульований Барбарою Лісков у 1987 році:
«Об’єкти підкласу мають бути замінюваними об’єктами базового класу без порушення коректності роботи програми».
Що це означає простими словами
Якщо у вас є базовий клас (або інтерфейс) A та його нащадок B, то будь-яка частина програми, яка очікує об’єкт типу A, повинна без збоїв і непередбачуваних сюрпризів працювати й з об’єктом типу B.
- Нащадок не повинен ламати поведінку, на яку розраховує клієнтський код.
- Нащадок не повинен забороняти те, що дозволяв батьківський клас.
- Нащадок не повинен викидати винятки на виклики методів, оголошених у базовому типі.
Життєва аналогія
Електрична розетка європейського стандарту (Schuko):
- Розетка очікує вилку, що відповідає стандарту (230 В, заземлюючі вусики, діаметр штирів 4.8 мм).
- Чайник, пилосос чи блок живлення ноутбука — це різні прилади («нащадки» концепції електричного штепселя).
- Якщо виробник випустить «вилку-сюрприз» із квадратними штирями або таку, що вибиває автомати в щитку — контракт порушено, прилад не може замінити стандартну вилку.
Приклад порушення LSP (Страуси та птахи)
// Базовий клас обіцяє: усі птахи літають
public class Bird {
public void fly() {
System.out.println("Лечу в небі!");
}
}
// Страус - біологічно птах, але літати не вміє!
public class Ostrich extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("Страуси не вміють літати!");
}
}
// Клієнтський код
public class SanctuaryService {
public void migrateAll(List<Bird> birds) {
for (Bird bird : birds) {
bird.fly(); // Раптовий збій у продакшені при передачі Ostrich!
}
}
}
Чому це порушення: клієнтський код розраховував на контракт Bird.fly(). Підстановка Ostrich призвела до аварійної зупинки програми.
Рішення: Розділення абстракцій за поведінкою
В ООП ієрархія будується не за побутовою чи біологічною класифікацією («страус — це птах»), а за поведінкою (Behavioral Subtyping):
public interface Bird {
void eat();
}
public interface FlyingBird extends Bird {
void fly();
}
public class Sparrow implements FlyingBird {
@Override
public void eat() { System.out.println("Їм зерно"); }
@Override
public void fly() { System.out.println("Лечу!"); }
}
public class Ostrich implements Bird {
@Override
public void eat() { System.out.println("Їм траву"); }
// Метод fly() відсутній, контракт не порушено!
}
Клієнтський код migrateAll приймає List<FlyingBird>, унеможливлюючи передачу нелітаючих птахів ще на етапі компіляції.
🟡 Middle Level
Design by Contract (Проєктування за контрактом)
Формально принцип LSP ґрунтується на теорії Бертрана Меєра та Барбари Лісков Design by Contract (DbC). Будь-який метод базового типу формує контракт, що складається з 4 ключових правил:
| Правило контракту | Формулювання | Що заборонено підкласу |
|---|---|---|
| 1. Preconditions (Передумови) | Вимоги до аргументів та стану системи до виконання методу. | Не можна посилювати у підкласі. Якщо базовий клас приймає будь-які цілі числа int, підклас не має права викидати помилку на від’ємні значення. |
| 2. Postconditions (Післяумови) | Гарантії результату та стану системи після виконання методу. | Не можна послаблювати у підкласі. Якщо базовий метод гарантує повернення невід’ємного числа або закриття з’єднання, підклас зобов’язаний це забезпечити. |
| 3. Invariants (Інваріанти) | Умови стану класу, які залишаються істинними завжди (до і після будь-якого методу). | Повинні зберігатися. Якщо інваріант Account: balance >= 0, жоден метод підкласу не може залишати від’ємний баланс. |
| 4. History Constraint (Обмеження історії) | Стани, що вважалися незмінними в батьківському класі, не повинні змінюватися нащадком. | Якщо базовий об’єкт незмінний (immutable), підклас не має права робити його мутабельним. |
Класичний антипатерн: Квадрат і Прямокутник
У геометрії квадрат — це окремий випадок прямокутника. Проте в мутабельному об’єктно-орієнтованому коді успадкування Square extends Rectangle грубо порушує 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;
}
}
Тест, що руйнує ілюзію підстановки:
public void resizeAndVerify(Rectangle r) {
r.setWidth(5);
r.setHeight(10);
// Для Rectangle: площа 50.
// Для Square: r.setHeight(10) встановив ширину 10, площа стала 100!
assert r.getArea() == 50 : "LSP Violation! Expected 50, but got " + r.getArea();
}
Висновок: Відношення «є» (IS-A) має оцінюватися не за статичними властивостями, а виключно за поведінкою мутацій.
Порушення LSP у стандартній бібліотеці Java (JDK)
Навіть творці Java припустилися класичних порушень LSP у JDK:
java.util.Collections.unmodifiableList(list):- Повертає реалізацію
List, у якій методиadd(),remove()викидаютьUnsupportedOperationException. - Клієнт, що приймає
List<T>, розраховує на загальний контракт мутабельного списку.
- Повертає реалізацію
java.sql.Date extends java.util.Date:java.sql.Dateпредставляє лише дату без часу (hours, minutes, seconds).- Виклики
date.getHours()викидаютьIllegalArgumentException, порушуючи контракт батьківськогоjava.util.Date.
java.util.Stack extends Vector:- Стек — це LIFO структура. Проте через успадкування від
Vectorклієнт може викликатиstack.add(2, element)і вставити елемент у середину стека!
- Стек — це LIFO структура. Проте через успадкування від
🔴 Senior Level
Математичне формулювання Лісков та Вінг (1994)
Барбара Лісков та Джаннет Вінг у праці “A Behavioral Notion of Subtyping” сформулювали принцип предикатною логікою:
\[\forall x : T, \quad \phi(x) \implies \phi(y) \quad \text{де } y : S \text{ і } S \text{ є підтипом } T\]Нехай $\phi(x)$ — властивість, доказовна щодо об’єктів $x$ типу $T$. Тоді $\phi(y)$ має залишатися істинною для об’єктів $y$ типу $S$, де $S$ — підтип типу $T$.
Формалізація через контракти:
- Передумови: $Pre(T) \implies Pre(S)$ (Передумова предка спричиняє передумову нащадка, тобто нащадок вимагає менше або стільки ж).
- Післяумови: $Post(S) \implies Post(T)$ (Післяумова нащадка спричиняє післяумову предка, тобто нащадок гарантує більше або стільки ж).
- Інваріанти: $Inv(S) \implies Inv(T)$.
Коваріантність і контраваріантність у Java
Як система типів мови підтримує LSP:
- Коваріантність типів повернення (Return Type Covariance):
- Метод підкласу може повертати більш специфічний тип:
public class BaseService { public CharSequence getName() { return "base"; } } public class SpecificService extends BaseService { @Override public String getName() { return "specific"; } // String - підтип CharSequence. Валідно! } - Це узгоджується з LSP: клієнт очікував
CharSequence, а отримавString, що задовольняє всі його методи.
- Метод підкласу може повертати більш специфічний тип:
- Контраваріантність типів аргументів (Argument Contravariance):
- З погляду LSP підклас мав би право послабити вимоги до аргументу: метод предка приймає
String, а метод нащадка приймаєObject. - У Java контраваріантність аргументів під час перевизначення методів не підтримується: компілятор вважатиме це перевантаженням (overloading), а не перевизначенням (overriding). Проте контраваріантність підтримується в дженериках через wildcards (
<? super T>).
- З погляду LSP підклас мав би право послабити вимоги до аргументу: метод предка приймає
Contract Testing: як гарантувати LSP у CI/CD
В архітектурі мікросервісів та бібліотек дотримання LSP перевіряється через Contract Tests (тести базового контракту):
// Базовий параметризований тест контракту
public abstract class RepositoryContractTest<T extends Repository> {
protected abstract T createRepository();
@Test
void shouldSaveAndRetrieveEntity() {
T repo = createRepository();
Entity entity = new Entity("test-id");
repo.save(entity);
Optional<Entity> found = repo.findById("test-id");
assertTrue(found.isPresent());
assertEquals("test-id", found.get().id());
}
@Test
void shouldNotThrowExceptionWhenDeletingNonExistent() {
T repo = createRepository();
assertDoesNotThrow(() -> repo.deleteById("unknown-id"));
}
}
// Тест конкретної реалізації PostgreSQL
class PostgresRepositoryTest extends RepositoryContractTest<PostgresRepository> {
@Override
protected PostgresRepository createRepository() {
return new PostgresRepository(testDatabasePool);
}
}
// Тест реалізації InMemory кешу
class InMemoryRepositoryTest extends RepositoryContractTest<InMemoryRepository> {
@Override
protected InMemoryRepository createRepository() {
return new InMemoryRepository();
}
}
Якщо розробник InMemoryRepository вирішить викидати виняток при видаленні неіснуючого ключа, він зламає базовий контрактний тест і не пройде CI pipeline.
4 Tricky Questions
1. Чи дозволено в підкласі викинути ширший або новий Checked-виняток при перевизначенні методу?
Відповідь:
Ні. Компілятор Java суворо забороняє оголошувати в throws перевизначеного методу Checked-винятки, які не оголошені в базовому класі (або не є їхніми нащадками). Це прямий захист LSP на рівні мови: якщо клієнт ловить IOException, поява SQLException зламала б логіку обробки помилок.
Проте компілятор дозволяє викидати будь-які RuntimeException (Unchecked). І саме неочікувані RuntimeException (NullPointerException, UnsupportedOperationException, IllegalStateException) найчастіше ламають LSP у реальному коді.
2. Чому незмінність (Immutability) вирішує проблему «Квадрат-Прямокутник»?
Відповідь:
Проблема «Квадрат-Прямокутник» виникає виключно через мутації стану (setWidth / setHeight), які порушують інваріант незалежності сторін прямокутника.
Якщо зробити класи незмінними (record ImmutableRectangle(int width, int height) та record ImmutableSquare(int side)):
- Немає методів модифікації сторін окремо.
ImmutableSquareможе безпечно реалізовувати інтерфейсShapeчи трансформуватися вImmutableRectangleбез ризику прихованих побічних ефектів для інших посилань на цей об’єкт.
3. Чи порушує LSP патерн Null Object (об’єкт-заглушка замість null)?
Відповідь:
Якщо Null Object спроєктовано коректно — це зразковий приклад дотримання LSP! Замість повернення null (який викликає NullPointerException у клієнта), Null Object реалізує контракт інтерфейсу безпечною нейтральною поведінкою: повертає порожні колекції, виконує no-op операції, задовольняє всі післяумови базового типу.
Порушенням LSP він стане лише тоді, коли клієнти базового класу розраховують на зміну стану системи (наприклад, збереження в БД), а Null Object мовчки ігнорує цю дію там, де клієнт очікував гарантованого збереження.
4. Чи є перевірка if (obj instanceof SpecificClass) у бізнес-коді доказом порушення LSP?
Відповідь:
Практично завжди так (за винятком Pattern Matching за закритими sealed-ієрархіями). Якщо клієнтський код перевіряє конкретний тип об’єкта для виконання специфічних дій або обходу дефектів підкласу, це свідчить про те, що абстракція базового типу неповна або підклас не реалізує очікуваний контракт. Правильне рішення — інкапсулювати варіативну поведінку в метод базового інтерфейсу або використати патерн Visitor.
🎯 Шпаргалка для інтерв’ю
Головні тези:
- Суть: Підклас має коректно замінювати базовий клас усюди, де очікується базовий тип, не ламаючи поведінку клієнта.
- Правило контракту: Передумови не можна посилювати, післяумови не можна послаблювати, інваріанти предка зобов’язані зберігатися.
- Головні маркери порушення:
UnsupportedOperationExceptionу перевизначених методах; порожні методи-заглушки; перевіркиinstanceofперед викликом специфічної логіки. - Класичні приклади: Успадкування
Square extends Rectangle(побічні ефекти сторін);Collections.unmodifiableList(падіння наadd()).
Чого уникати (Червоні прапорці):
- ❌ Стверджувати, що якщо код успішно скомпілювався — LSP дотримано (LSP — семантичний поведінковий принцип, компілятор не бачить побічних ефектів і бізнес-інваріантів).
- ❌ Виправдовувати успадкування спорідненістю термінів реального світу (ООП моделює поведінку, а не біологічні чи геометричні класифікації).