Наведіть приклад порушення принципу Liskov Substitution
Порушення принципу підстановки Лісков (LSP) майже завжди виникає через спробу зекономити рядки коду за допомогою успадкування класів, які мають спільне походження в реальному св...
🟢 Junior Level
Порушення принципу підстановки Лісков (LSP) майже завжди виникає через спробу зекономити рядки коду за допомогою успадкування класів, які мають спільне походження в реальному світі, але принципово різну поведінку в програмі.
Приклад 1. «Відмова від спадщини» (Refusal of Bequest)
Підклас перевизначає метод базового класу і викидає виняток, повідомляючи, що ця операція не підтримується:
public class Bird {
public void fly() {
System.out.println("Лечу в небі!");
}
}
public class Penguin extends Bird {
@Override
public void fly() {
// Порушення LSP: викидаємо помилку замість виконання контракту
throw new UnsupportedOperationException("Пінгвіни плавають, але не літають!");
}
}
// Клієнтський код:
public class ZooService {
public void releaseBirds(List<Bird> birds) {
for (Bird bird : birds) {
bird.fly(); // Раптове падіння на пінгвіні!
}
}
}
Приклад 2. Прямокутник і Квадрат (Приховане псування інваріанта)
Геометрично квадрат — окремий випадок прямокутника. Проте в коді зі зміною стану це веде до катастрофи:
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 verifyArea(Rectangle r) {
r.setWidth(5);
r.setHeight(4);
// Клієнт очікує 5 * 4 = 20.
// Якщо передано Square: ширина стала 4, площа = 16!
assert r.getArea() == 20 : "LSP Violation! Got area: " + r.getArea();
}
Як виправити на базовому рівні
- Не успадковуйте заради полів: якщо класи мають однакові поля (
width,height), але різну поведінку — використовуйте спільний інтерфейсShapeбез методів-мутаторів (setWidth/setHeight). - Композиція замість успадкування: якщо об’єкту потрібна логіка іншого класу, зробіть його полем і викликайте через делегування.
🟡 Middle Level
4 типи контрактних порушень LSP
У межах методології Design by Contract (DbC) порушення LSP класифікується за 4 сценаріями:
1. Посилення передумов (Strengthening Preconditions)
Підклас вимагає від викликаючого коду більш суворих умов, ніж батьківський клас:
public class Account {
protected BigDecimal balance;
// Контракт: дозволено овердрафт (зняття будь-якої додатної суми)
public void withdraw(BigDecimal amount) {
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("Amount must be positive");
}
balance = balance.subtract(amount);
}
}
public class DebitAccount extends Account {
@Override
public void withdraw(BigDecimal amount) {
// Порушення: підклас посилив передумову, заборонивши овердрафт!
if (amount.compareTo(balance) > 0) {
throw new IllegalStateException("Overdraft not allowed for debit account!");
}
super.withdraw(amount);
}
}
Наслідок: Клієнтський сервіс транзакцій, розрахований на базовий Account, неочікувано падає з IllegalStateException.
2. Послаблення післяумов (Weakening Postconditions)
Підклас надає менше гарантій щодо результату своєї роботи:
public class UserRepository {
// Контракт базового типу: метод НІКОЛИ не повертає null (гарантує список)
public List<User> findAllActive() {
return database.queryActiveUsers(); // повертає List, можливо пустий: Collections.emptyList()
}
}
public class CachedUserRepository extends UserRepository {
@Override
public List<User> findAllActive() {
// Порушення: при промаху кешу повертає null замість пустого списку!
UserCacheEntry entry = cache.get("active");
return (entry != null) ? entry.getUsers() : null;
}
}
Наслідок: Клієнт отримує NullPointerException при спробі виконати repo.findAllActive().size().
3. Порушення інваріантів (Invariants Violation)
Інваріант — властивість стану об’єкта, яка залишається істинною протягом усього його життєвого циклу:
public class PositiveCounter {
protected int count = 0;
// Інваріант класу: count >= 0 ЗАВЖДИ
public void increment() { count++; }
public int getCount() { return count; }
}
public class ResetCounter extends PositiveCounter {
// Порушення: метод дозволяє встановити від'ємне значення, руйнуючи інваріант
public void forceSet(int value) {
this.count = value; // Можна передати -50
}
}
4. Порушення обмеження історії (History Constraint)
Підклас уможливлює мутацію стану, який базовий клас оголошував незмінним:
public class ImmutableMoney {
private final BigDecimal amount;
private final Currency currency;
public ImmutableMoney(BigDecimal amount, Currency currency) {
this.amount = amount;
this.currency = currency;
}
public BigDecimal getAmount() { return amount; }
}
public class MutableMoney extends ImmutableMoney {
private BigDecimal mutableAmount;
// Порушення: клієнт передає MutableMoney в кеш, розраховуючи на імутабельність,
// а інший потік змінює значення на льоту!
public void setAmount(BigDecimal newAmount) {
this.mutableAmount = newAmount;
}
}
🔴 Senior Level
Глибокі архітектурні порушення LSP у реальних системах
1. Проблема equals() при розширенні класу (Джошуа Блох, Effective Java)
Спроба додати нове значуще поле в підклас неминуче ламає рефлексивність, симетричність або транзитивність контракту equals():
public class Point {
private final int x, y;
public Point(int x, int y) { this.x = x; this.y = y; }
@Override
public boolean equals(Object o) {
if (!(o instanceof Point p)) return false;
return p.x == x && p.y == y;
}
}
public class ColorPoint extends Point {
private final Color color;
public ColorPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
@Override
public boolean equals(Object o) {
if (!(o instanceof ColorPoint cp)) return false;
return super.equals(o) && cp.color == color;
}
}
Чому порушується контракт та LSP:
Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, Color.RED);
p.equals(cp); // true (Point порівнює лише координати x, y)
cp.equals(p); // false (ColorPoint вимагає, щоб аргумент був ColorPoint)
// Порушено симетричність! Підстановка cp замість p в HashSet зламає пошук.
Висновок Джошуа Блоха: Неможливо розширити інстанційований клас новим полем значення і зберегти контракт
equals, якщо не використовувати композицію замість успадкування.
2. Hibernate/JPA Proxy та порушення LSP
У JPA сутностях використання this.getClass() == o.getClass() у методі equals() ламає LSP під час роботи з Lazy Loading:
- Hibernate генерує динамічний CGLIB/ByteBuddy-проксі
User$HibernateProxy$xyz extends User. - При виклику
user.equals(proxy)умоваgetClass() == o.getClass()повернеfalse, хоча обидві сутності представляють один і той самий рядок у БД з однаковим первинним ключем. - Рішення: Перевіряти
if (!(o instanceof User other)) return false;та звертатися до полів через гетери (getId()), оскільки прямі звернення до полівother.idу проксі повернутьnullдо ініціалізації.
3. Порушення LSP в JDK: java.util.Stack
Клас Stack успадковує Vector:
Stack<String> stack = new Stack<>();
stack.push("A");
stack.push("B");
// Метод Vector дозволяє вставити елемент у довільну позицію:
stack.add(0, "C"); // Порушено інваріант LIFO структури даних!
Правильний дизайн у сучасній Java — інтерфейс Deque (ArrayDeque), що інкапсулює операції зі стеком та чергою без витоку сторонніх методів списку.
4 Tricky Questions
1. Чому Джошуа Блох стверджує, що неможливо розширити інстанційований клас новим полем значення і зберегти контракт equals()?
Відповідь:
Якщо підклас перевизначає equals(), враховуючи нове поле, порушується симетричність: superPoint.equals(colorPoint) повертає true, а colorPoint.equals(superPoint) повертає false.
Якщо спробувати вирішити це “змішаним” порівнянням (ігнорувати колір, якщо передано базовий клас), ламається транзитивність:
ColorPoint cp1 = new ColorPoint(1, 2, RED);Point p = new Point(1, 2);ColorPoint cp2 = new ColorPoint(1, 2, BLUE);cp1.equals(p)(true) таp.equals(cp2)(true), протеcp1.equals(cp2)(false!). Єдиний спосіб зберегти LSP і контрактequals— замінити успадкування композицією (ColorPointзберігаєPointіColorвсередині себе).
2. Чи є звуження діапазону чисел, що повертаються, у підкласі порушенням LSP?
Відповідь:
Ні, це посилення післяумови (Strengthening Postconditions), що повністю дозволено правилами Design by Contract.
Якщо базовий метод int calculateScore() обіцяє повернути будь-яке число від -100 до 100, а підклас StrictScorer повертає лише діапазон від 0 до 100, будь-який клієнт, написаний для діапазону предка, успішно обробить результат підкласу.
Порушенням було б послаблення післяумови (наприклад, повернення чисел поза очікуваним діапазоном або повернення null).
3. Як патерн Decorator може порушити LSP у багатопотоковому коді?
Відповідь:
Якщо базовий компонент є потокобезпечним (ThreadSafeCollection), а клас-декоратор огортає його й додає локальний стан (наприклад, лічильник звернень private int accessCount++) без належної синхронізації (volatile, AtomicInteger, synchronized), то отриманий об’єкт перестає задовольняти інваріант потокобезпеки базового класу. Викликаючий код, підставляючи такий декоратор у конкурентне середовище, зіткнеться зі станом гонки (Race Condition).
4. Чи припустимо в підкласі змінити часову складність алгоритму з $O(1)$ на $O(N)$?
Відповідь:
Із формальної синтаксичної точки зору Java програма скомпілюється й виконається. Проте з точки зору нефункціонального контракту (Non-functional Requirements) та архітектурного LSP це грубе порушення.
Якщо клієнтський сервіс обробляє пакет із $10^6$ елементів на секунду в припущенні, що метод lookup() виконується за $O(1)$, підстановка реалізації зі складністю $O(N)$ призведе до деградації продуктивності до $O(N^2)$, вичерпання пулу потоків та відмови системи за таймаутом (Denial of Service).
🎯 Шпаргалка для інтерв’ю
Головні приклади порушень:
- Пінгвіни та птахи:
Penguin.fly()кидаєUnsupportedOperationException(відмова від виконання контракту). - Квадрат і Прямокутник:
Square.setWidth()неявно змінюєheight, руйнуючи інваріант незалежності сторін. - Банківський овердрафт:
DebitAccount.withdraw()забороняє від’ємний баланс там, де базовийAccountйого дозволяв (посилення передумов). - Колекції JDK:
Collections.unmodifiableList().add()викидає виняток у рантаймі.
Чого уникати (Червоні прапорці):
- ❌ Стверджувати, що успадкування допустиме, якщо збігається більшість полів (успадкування пов’язує поведінку, а не структуру зберігання).
- ❌ Вважати, що порушення LSP — це виключно викидання винятку (тихе спотворення обчислень або побічні ефекти є значно небезпечнішими).