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

Наведіть приклад порушення принципу Liskov Substitution

Порушення LSP часто маскується під "повторне використання коду". Ми намагаємося успадкувати один клас від іншого просто тому, що у них є спільні поля, ігноруючи відмінності у їх...

Мовні версії: English Russian Ukrainian

🟢 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 — баг!
}

Як виправити?

  1. Виділення спільного інтерфейсу: Замість Square extends Rectangle створіть інтерфейс Shape з методом getArea()
  2. Композиція: Якщо вам потрібен функціонал іншого класу, не наслідуйте його — зробіть його полем
// Добре: композиція замість наслідування
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)

  1. Бажання зекономити: “Навіщо писати поле name ще раз, якщо воно є в User?”. І ми наслідуємо Admin від User, хоча у них різні життєві цикли.
  2. Неправильна ієрархія: Ми моделюємо світ “як він є”, а не “як він використовується”. В біології страус — птах, в ООП Ostrich не може наслідувати FlyingBird.

Як розпізнати порушення

Ознаки:

  1. instanceof перевірки: Якщо ви перевіряєте тип, щоб викликати специфічну логіку
  2. UnsupportedOperationException: Метод “не підтримується” у нащадку
  3. Порожні override: Метод перевизначений порожньою реалізацією
  4. Різна поведінка тестів: Тести базового класу падають для нащадка

Як виправити

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

  1. Template Method з “опціональними” кроками: Базовий клас визначає алгоритм, підклас може пропустити крок
    • Проблема: Пропуск кроку може порушити інваріант алгоритму
    • Рішення: Chain of Responsibility або Decorator замість Template Method
  2. Abstract Classes з default реалізацією “за замовчуванням”: Метод робить “нічого” в базовому класі
    • Проблема: Нащадки можуть забути перевизначити — тихе порушення контракту
    • Рішення: Абстрактні методи без реалізації (forced override)
  3. 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:

  1. Contract Tests:
    // Спільний тест для всіх реалізацій Shape
    public abstract class ShapeContractTest<T extends Shape> {
        abstract T createShape();
    
        @Test
        void areaIsAlwaysNonNegative() {
            assertThat(createShape().getArea()).isGreaterThanOrEqualTo(0);
        }
    }
    
  2. Метрики:
    • Кількість instanceof перевірок
    • Кількість UnsupportedOperationException
    • Кількість порожніх override-методів
  3. Інструменти:
    • 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 в JDK
  • Collections.unmodifiableList() кидає UnsupportedOperationException при add() — порушення LSP
  • instanceof для обходу різних поведінок = 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]]