📐 Розділ 18 · Питання #6

Наведіть приклад порушення принципу 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();
}

Як виправити на базовому рівні

  1. Не успадковуйте заради полів: якщо класи мають однакові поля (width, height), але різну поведінку — використовуйте спільний інтерфейс Shape без методів-мутаторів (setWidth/setHeight).
  2. Композиція замість успадкування: якщо об’єкту потрібна логіка іншого класу, зробіть його полем і викликайте через делегування.

🟡 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).


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

Головні приклади порушень:

  1. Пінгвіни та птахи: Penguin.fly() кидає UnsupportedOperationException (відмова від виконання контракту).
  2. Квадрат і Прямокутник: Square.setWidth() неявно змінює height, руйнуючи інваріант незалежності сторін.
  3. Банківський овердрафт: DebitAccount.withdraw() забороняє від’ємний баланс там, де базовий Account його дозволяв (посилення передумов).
  4. Колекції JDK: Collections.unmodifiableList().add() викидає виняток у рантаймі.

Чого уникати (Червоні прапорці):

  • ❌ Стверджувати, що успадкування допустиме, якщо збігається більшість полів (успадкування пов’язує поведінку, а не структуру зберігання).
  • ❌ Вважати, що порушення LSP — це виключно викидання винятку (тихе спотворення обчислень або побічні ефекти є значно небезпечнішими).

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