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

Що таке принцип 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:

  1. java.util.Collections.unmodifiableList(list):
    • Повертає реалізацію List, у якій методи add(), remove() викидають UnsupportedOperationException.
    • Клієнт, що приймає List<T>, розраховує на загальний контракт мутабельного списку.
  2. java.sql.Date extends java.util.Date:
    • java.sql.Date представляє лише дату без часу (hours, minutes, seconds).
    • Виклики date.getHours() викидають IllegalArgumentException, порушуючи контракт батьківського java.util.Date.
  3. java.util.Stack extends Vector:
    • Стек — це LIFO структура. Проте через успадкування від Vector клієнт може викликати stack.add(2, element) і вставити елемент у середину стека!

🔴 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$.

Формалізація через контракти:

  1. Передумови: $Pre(T) \implies Pre(S)$ (Передумова предка спричиняє передумову нащадка, тобто нащадок вимагає менше або стільки ж).
  2. Післяумови: $Post(S) \implies Post(T)$ (Післяумова нащадка спричиняє післяумову предка, тобто нащадок гарантує більше або стільки ж).
  3. Інваріанти: $Inv(S) \implies Inv(T)$.

Коваріантність і контраваріантність у Java

Як система типів мови підтримує LSP:

  1. Коваріантність типів повернення (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, що задовольняє всі його методи.
  2. Контраваріантність типів аргументів (Argument Contravariance):
    • З погляду LSP підклас мав би право послабити вимоги до аргументу: метод предка приймає String, а метод нащадка приймає Object.
    • У Java контраваріантність аргументів під час перевизначення методів не підтримується: компілятор вважатиме це перевантаженням (overloading), а не перевизначенням (overriding). Проте контраваріантність підтримується в дженериках через wildcards (<? super T>).

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 — семантичний поведінковий принцип, компілятор не бачить побічних ефектів і бізнес-інваріантів).
  • ❌ Виправдовувати успадкування спорідненістю термінів реального світу (ООП моделює поведінку, а не біологічні чи геометричні класифікації).

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