Наведіть приклад порушення принципу Liskov Substitution
Порушення LSP часто маскується під "повторне використання коду". Ми намагаємося успадкувати один клас від іншого просто тому, що у них є спільні поля, ігноруючи відмінності у їх...
🟢 Junior Level
Порушення LSP часто маскується під “повторне використання коду”. Ми намагаємося успадкувати один клас від іншого просто тому, що у них є спільні поля, ігноруючи відмінності у їхній поведінці.
Приклад 1: “Лінивий” нащадок Коли підклас перевизначає метод батька і викидає виняток або нічого не робить.
// Погано: нащадок не підтримує метод батька
public class Bird {
public void fly() {
System.out.println("Лечу!");
}
}
public class Penguin extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("Пінгвіни не літають!");
}
}
// Зламається:
public void letBirdsFly(List<Bird> birds) {
for (Bird bird : birds) {
bird.fly(); // UnsupportedOperationException для Penguin!
}
}
Приклад 2: Прямокутник і Квадрат
// Погано: Square змінює поведінку setter-ів
public class Rectangle {
protected int width;
protected int height;
public void setWidth(int w) { this.width = w; }
public void setHeight(int h) { this.height = h; }
}
public class Square extends Rectangle {
@Override
public void setWidth(int w) {
this.width = w;
this.height = w; // Побічний ефект!
}
@Override
public void setHeight(int h) {
this.width = h; // Побічний ефект!
this.height = h;
}
}
// Клієнт очікує одну поведінку, отримує іншу:
public void test(Rectangle r) {
r.setWidth(5);
r.setHeight(10);
// Для Rectangle: width=5, height=10
// Для Square: width=10, height=10 — баг!
}
Як виправити?
- Виділення спільного інтерфейсу: Замість
Square extends Rectangleстворіть інтерфейсShapeз методомgetArea() - Композиція: Якщо вам потрібен функціонал іншого класу, не наслідуйте його — зробіть його полем
// Добре: композиція замість наслідування
public class ReadOnlyList<T> implements Iterable<T> {
private final List<T> list;
public ReadOnlyList(List<T> list) { this.list = list; }
// Тільки методи читання...
}
🟡 Middle Level
Підступні приклади порушення
1. Проблема “Лінивого” нащадка (The Refusal of Bequest)
Коли підклас перевизначає метод батька і викидає виняток або нічого не робить.
- Реальний приклад з Java:
java.util.Dateтаjava.sql.Date - Суть:
java.sql.Date(нащадок) “відключає” роботу з часом (години, хвилини, секунди), хоча базовий класjava.util.Dateїх підтримує. Якщо код очікує точності до мілісекунд і отримуєsql.Date, він зламається. - Виклик з java.sql.Date кине IllegalArgumentException!
- getHours() в java.sql.Date не підтримується (throws exception), хоча батьківський Date цей метод підтримує.
2. Порушення інваріанта (The Account Example)
public class Account {
protected double balance;
public void withdraw(double amount) {
this.balance -= amount;
}
}
public class SavingsAccount extends Account {
@Override
public void withdraw(double amount) {
if (amount > balance) throw new RuntimeException("No overdraft!"); // Посилення передумови
super.withdraw(amount);
}
}
Чому це порушення?
Якщо клієнтський код написаний для базового Account, він може покладатися на те, що баланс може йти в мінус (овердрафт). Підстановка SavingsAccount зламає логіку клієнта, який не очікує винятку.
Не будь-який
if— порушення. Порушення — коли батьківський контракт дозволяє дію, а нащадок — забороняє. Якщо обидва класи узгоджені у контракті (обидва забороняють овердрафт) — порушення LSP немає.
3. Прихована псування стану (Side Effects)
Класичний приклад з Прямокутником і Квадратом.
- Клієнт:
rect.setWidth(10); rect.setHeight(20); - Для Прямокутника: площа 200
- Для Квадрата: площа 400 (оскільки
setHeightпереписавwidth) Підсумок: Клієнт у шоці, інваріант “зміна висоти не впливає на ширину” зруйнований.
Чому ми це робимо? (Root Cause)
- Бажання зекономити: “Навіщо писати поле
nameще раз, якщо воно є вUser?”. І ми наслідуємоAdminвідUser, хоча у них різні життєві цикли. - Неправильна ієрархія: Ми моделюємо світ “як він є”, а не “як він використовується”. В біології страус — птах, в ООП
Ostrichне може наслідуватиFlyingBird.
Як розпізнати порушення
Ознаки:
instanceofперевірки: Якщо ви перевіряєте тип, щоб викликати специфічну логікуUnsupportedOperationException: Метод “не підтримується” у нащадку- Порожні override: Метод перевизначений порожньою реалізацією
- Різна поведінка тестів: Тести базового класу падають для нащадка
Як виправити
1. Виділення спільного інтерфейсу
// Замість Square extends Rectangle:
public interface Shape {
int getArea();
}
public class Rectangle implements Shape {
private int width, height;
// setter-и працюють незалежно
@Override
public int getArea() { return width * height; }
}
public class Square implements Shape {
private int side;
@Override
public int getArea() { return side * side; }
}
2. Композиція (Delegation)
// ДОБРЕ: Використання композиції замість кривого наслідування
public class ReadOnlyList<T> implements Iterable<T> {
private final List<T> list; // Делегат
public ReadOnlyList(List<T> list) { this.list = list; }
@Override
public Iterator<T> iterator() { return list.iterator(); }
public int size() { return list.size(); }
// Тільки методи читання — немає add/remove
}
3. Розділення ієрархії
// Замість Penguin extends Bird:
public interface Bird {
void sing();
}
public interface FlyingBird extends Bird {
void fly();
}
public class Sparrow implements FlyingBird {
@Override public void sing() { /* ... */ }
@Override public void fly() { /* ... */ }
}
public class Penguin implements Bird {
@Override public void sing() { /* ... */ }
// fly() відсутній — і це правильно
}
🔴 Senior Level
Internal Implementation та Архітектура
На рівні Senior важливо розуміти: порушення LSP — це не просто “баг у коді”. Це порушення контракту, яке може проявитися тільки в рантаймі, в продакшені, за певних умов.
LSP — це про Design by Contract: підклас може тільки послаблювати вимоги до входу та посилювати гарантії на виході.
| Constraint | Правило | Порушення |
|---|---|---|
| Preconditions | Не можуть бути посилені | SavingsAccount вимагає amount <= balance |
| Postconditions | Не можуть бути послаблені | FastSort повертає частково відсортований масив |
| Invariants | Повинні зберігатися | Square змінює width при setHeight |
| History | Стан не змінюється непередбачувано | CachingList повертає stale дані |
Реальні приклади з JDK
java.util.Date та java.sql.Date
// java.util.Date — зберігає час з точністю до мілісекунд
// java.sql.Date — нащадок, але "відключає" час (hours, minutes, seconds)
public class java.sql.Date extends java.util.Date {
// Багато методів перевизначені і кидають IllegalArgumentException
@Override
public int getHours() {
throw new IllegalArgumentException();
}
}
Це одне з найвідоміших порушень LSP в Java Standard Library.
Collections.unmodifiableList()
List<String> modifiable = new ArrayList<>();
List<String> unmodifiable = Collections.unmodifiableList(modifiable);
// unmodifiable реалізований як обгортка над List,
// але add() кидає UnsupportedOperationException
unmodifiable.add("test"); // BOOM!
Наслідки для Highload та Тестування
- Broken Unit Tests: Тести, написані для базового класу, починають “мигати” або падати при запуску з об’єктом-нащадком. Це вірна ознака порушення LSP
- Heisenbugs: Баги проявляються тільки за певних умов, коли в систему “просочується” об’єкт неправильного типу через DI або фабрику. Heisenbug — баг, який проявляється тільки за певних умов (певний тип об’єкта в DI, специфічне навантаження) і його складно відтворити. Названий на честь принципу невизначеності Гейзенберга.
- Regression Testing: Кожен новий підклас вимагає повного прогону тестів базового типу
Архітектурні Trade-offs
Наслідування (порушує LSP):
- ✅ Плюси: Перевикористання коду, мінімум boilerplate
- ❌ Мінуси: Жорсткий контракт, порушення LSP, крихкість
Композиція (дотримується LSP):
- ✅ Плюси: Повна свобода поведінки, слабка зв’язаність
- ❌ Мінуси: Більше boilerplate, потрібно делегувати методи
Інтерфейси (дотримуються LSP):
- ✅ Плюси: Чистий контракт, мінімум зобов’язань
- ❌ Мінуси: Немає перевикористання реалізації
Edge Cases
- Template Method з “опціональними” кроками: Базовий клас визначає алгоритм, підклас може пропустити крок
- Проблема: Пропуск кроку може порушити інваріант алгоритму
- Рішення: Chain of Responsibility або Decorator замість Template Method
- Abstract Classes з default реалізацією “за замовчуванням”: Метод робить “нічого” в базовому класі
- Проблема: Нащадки можуть забути перевизначити — тихе порушення контракту
- Рішення: Абстрактні методи без реалізації (forced override)
- Proxies та Dynamic Proxies: Динамічно створені об’єкти
- Проблема: Можуть не відтворювати всі післяумови оригіналу
- Рішення: Contract Testing для проксі
Продуктивність
- Virtual dispatch + LSP: Коли JIT впевнений, що всі нащадки дотримуються LSP, він може агресивніше робити devirtualization та inlining
- LSP violation cost: Якщо нащадок порушує контракт, JIT-оптимізації можуть дати некоректний результат (теоретично, на практиці JVM робить деоптимізацію)
- Branch prediction:
instanceofперевірки для обходу LSP violation руйнують branch prediction
Production Experience
Реальний сценарій з продакшену:
В e-commerce проекті BaseOrderProcessor мав метод calculateTax(Order). Нащадок InternationalOrderProcessor перевизначив його і застосував іншу формулу. Проблема: базовий клас також викликав calculateTax всередині processOrder, і розробники InternationalOrderProcessor не врахували, що calculateTax викликається двічі — для товару і для доставки.
Результат: Неправильний розрахунок податків для міжнародних замовлень. Збитки — $50K за місяць до виявлення.
Рішення: Винесли TaxCalculator в окремий компонент (композиція) замість наслідування логіки розрахунку.
Monitoring та діагностика
Як виявити порушення LSP:
- Contract Tests:
// Спільний тест для всіх реалізацій Shape public abstract class ShapeContractTest<T extends Shape> { abstract T createShape(); @Test void areaIsAlwaysNonNegative() { assertThat(createShape().getArea()).isGreaterThanOrEqualTo(0); } } - Метрики:
- Кількість
instanceofперевірок - Кількість
UnsupportedOperationException - Кількість порожніх override-методів
- Кількість
- Інструменти:
- Pitest (Mutation Testing): перевіряє, що тести ловлять зміни поведінки
- ArchUnit: архітектурні тести на наслідування
Best Practices для Highload
- Sealed Interfaces (Java 17+): Обмежте ієрархію — компілятор перевірить exhaustiveness
- Immutability: Незмінні об’єкти простіше зробити LSP-сумісними
- Contract Testing: Пишіть тести на контракти базового типу і запускайте для всіх нащадків
- Якщо ви використовуєте
if (obj instanceof Subclass)— ви вже порушили LSP
Резюме для Senior
- Якщо ви використовуєте
if (obj instanceof Subclass), ви вже порушили LSP - Наслідування — це найсильніший зв’язок в ООП. Використовуйте його тільки тоді, коли нащадок повністю замінює батька у всіх сценаріях
- Пам’ятайте про Design by Contract: підклас може тільки послаблювати вимоги до входу та посилювати гарантії на виході
- Composition over Inheritance — переважний підхід для Senior-розробника
🎯 Шпаргалка для інтерв’ю
Обов’язково знати:
- “Лінивий” нащадок — кидає
UnsupportedOperationExceptionабо робить метод порожнім - Square extends Rectangle — побічні ефекти:
setHeightзмінюєwidth, площа 400 замість 200 java.sql.Dateнаслідуєjava.util.Date, але кидаєIllegalArgumentExceptionвgetHours()— порушення LSP в JDKCollections.unmodifiableList()кидаєUnsupportedOperationExceptionприadd()— порушення LSPinstanceofдля обходу різних поведінок = LSP вже порушено- Рішення: композиція (Delegation) або розділення ієрархії через інтерфейси
Часті уточнюючі запитання:
- Чому Square не може бути нащадком Rectangle? — Тому що змінює поведінку setter-ів, порушуючи інваріант батька
- Що таке Heisenbug в контексті LSP? — Баг, що проявляється тільки за певного типу об’єкта в DI, складно відтворити
- Як тестувати LSP? — Contract Testing: спільні тести для базового типу, запуск для всіх реалізацій
- Реальні наслідки порушення? — В e-commerce: неправильний розрахунок податків, збитки $50K/місяць
Червоні прапори (НЕ говорити):
- “Не будь-який if — порушення LSP” (правильно, але краще не давати приводу для запитань)
- “Наслідування — це нормально, якщо у класів спільні поля” (ігнорування behavioral subtyping)
- “LSP порушується тільки при винятках” (ні — побічні ефекти теж порушення)
Пов’язані теми:
- [[5. Що таке принцип Liskov Substitution]]
- [[10. Що таке композиція і наслідування]]
- [[11. У яких випадках краще використовувати композицію замість наслідування]]
- [[22. Які антипатерни суперечать принципам SOLID]]