LSP: суть принципу підстановки Барбари Лісков у розробці

Автор: IT Sectr Опубліковано: 2026-05-12 Час читання: 9 хв

LSP (Liskov Substitution Principle) — третій принцип SOLID, який визначає умови коректного успадкування в об'єктно-орієнтованому програмуванні. Принцип був сформульований Барбарою Лісков у 1987 році та формалізований як: якщо S є підтипом T, то об'єкти T можуть бути замінені об'єктами S без зміни властивостей програми. Як зазначається в книзі Роберта Мартіна Clean Architecture (2017), принцип підстановки вимагає, щоб підклас не послаблював контракт базового класу.

Головне

  • LSP — принцип підстановки Лісков, третій принцип SOLID про коректне успадкування
  • Підклас повинен зберігати контракт базового класу — передумови та післяумови
  • Порушення LSP проявляється у «проблемі квадрата і прямокутника» та викинутих винятках
  • Композиція часто краща за успадкування для дотримання LSP
  • Контрактне проєктування (Design by Contract) — формальний спосіб перевірки LSP

Що таке LSP (Liskov Substitution Principle)?

LSP (Liskov Substitution Principle) — принцип підстановки, сформульований Барбарою Лісков на конференції OOPSLA у 1987 році. Формальне визначення: нехай q(x) — властивість об'єктів x типу T, що доводиться. Тоді q(y) має бути властивістю, що доводиться, для об'єктів y типу S, де S — підтип T. Простіше кажучи: об'єкти підкласу повинні поводитися так, щоб код, який працює з базовим класом, продовжував коректно працювати і з підкласом.

На практиці LSP означає, що підклас не повинен порушувати контракт базового класу. Контракт включає передумови (що потрібно для виклику методу), післяумови (що гарантується після виклику) та інваріанти (умови, що зберігаються протягом життя об'єкта). Підклас може посилювати передумови або послаблювати післяумови — це і є порушення LSP.

Класичний приклад порушення LSP — квадрат, що успадковує від прямокутника. Метод setWidth у прямокутника встановлює ширину, у квадрата — і ширину, і висоту. Клієнт, який очікує поведінки прямокутника (зміна однієї сторони не впливає на іншу), отримує неочікуваний результат. Квадрат не є коректним підтипом прямокутника.

Формальні умови LSP

LSP встановлює три умови для коректного успадкування: передумови підкласу не можуть бути сильнішими за передумови базового класу (підклас не вимагає більше), післяумови підкласу не можуть бути слабшими за післяумови базового класу (підклас гарантує не менше), інваріанти базового класу повинні зберігатися в підкласі. Ці умови відомі як правило контрактного проєктування за Бертраном Мейєром.

Якщо хоча б одна умова порушена — код, що використовує поліморфізм, може давати збої. Компілятор не перевіряє семантичні контракти, лише синтаксичні. Тому LSP — питання архітектурної дисципліни, а не статичної типізації.

Як працює принцип підстановки Лісков

Механізм LSP заснований на поведінковій сумісності типів. Якщо клас S успадковує від класу T, клієнтський код повинен мати можливість використовувати S скрізь, де очікується T, без зміни своєї поведінки. Це включає не лише сигнатури методів, але й їх семантику.

LSP не забороняє підкласу додавати нову поведінку. Заборонено порушувати очікування коду, написаного для базового класу. Якщо базовий клас гарантує, що метод save не викидає винятків, підклас не повинен їх викидати. Якщо базовий клас повертає невід'ємне значення, підклас не повинен повертати від'ємне.

У реальних проєктах LSP найчастіше порушується при додаванні умовної логіки в методи підкласу: «якщо умова — викинути виняток», «якщо умова — повернути null». Кожен такий «сюрприз» підриває поліморфізм і змушує клієнтський код перевіряти тип об'єкта перед викликом — що суперечить самій ідеї об'єктно-орієнтованого проєктування.

У мобільних проєктах типове порушення LSP зустрічається при створенні базових ViewModel. Якщо BaseViewModel гарантує, що метод onCleared звільняє всі ресурси, а підклас перевизначає цей метод порожнім — будь-який код, що покладається на очищення ресурсів через поліморфний виклик onCleared, працюватиме некоректно. LSP вимагає, щоб підклас або викликав super.onCleared(), або сам виконував ту ж роботу. Композиція через LifecycleObserver — альтернатива, що виключає порушення LSP при управлінні життєвим циклом.

Ознаки порушення LSP в коді

Основні індикатори порушення LSP включають: перевірку типу об'єкта через instanceof або is перед викликом методу, порожні реалізації методів (заглушки), викид винятку NotImplementedError або UnsupportedOperationException, повернення null замість значення. Кожен з цих паттернів сигналізує, що підклас не є коректним підтипом.

Ще одна поширена ознака — успадкування з метою перевикористання коду, а не для моделювання відношення «є» (is-a). Клас Bird має метод fly(). Клас Penguin успадковує від Bird і перевизначає fly() як порожній або такий, що викидає виняток. Це порушення LSP: пінгвін не є коректним підтипом птаха.

У мобільній розробці LSP порушується при створенні базових ViewHolder, Fragment або ViewController з методами-заглушками. Якщо підклас не використовує половину методів базового класу — успадкування обрано невірно. Композиція або розділення інтерфейсу вирішують проблему коректніше.

Тест на LSP

Простий тест для перевірки LSP: напишіть unit-тест для базового класу, що перевіряє його контракт (значення, що повертаються, винятки, побічні ефекти). Запустіть цей тест для кожного підкласу. Якщо тест падає — LSP порушено. Цей підхід називається «тестування через контракт базового класу».

У Android-проєктах такий тест корисний для ViewModel та Repository. Якщо BaseViewModel гарантує стан Loading перед помилкою, а підклас викидає помилку без Loading — тест зафіксує порушення LSP на етапі CI.

Приклади LSP в мобільній розробці

Розглянемо Android-приклад з обробкою ClickListener. Порушення LSP виникає, коли базова реалізація гарантує щось, а підклас порушує.

kotlin
// Базовий клас з гарантією: onClick буде викликано
open class BaseClickListener {
    open fun onClick(view: View) {
        // базова обробка
    }
}

// Порушення LSP: підклас додає умову, що викидає виняток
class RestrictedClickListener : BaseClickListener() {
    override fun onClick(view: View) {
        if (!isLoggedIn) {
            throw IllegalStateException("Not logged in")
        }
        super.onClick(view)
    }
}

// Коректне рішення: контракт не порушено
class ConditionalClickListener : BaseClickListener() {
    override fun onClick(view: View) {
        if (isLoggedIn) {
            super.onClick(view)
        }
    }
}

iOS-приклад з протоколом DataSource демонструє порушення LSP через повернення nil замість даних:

swift
// Протокол з контрактом: повертає дані або помилку
protocol DataProvider {
    func fetchData() async throws -> [String]
}

// Порушення LSP: повертає nil без помилки
class SilentFailProvider: DataProvider {
    func fetchData() async throws -> [String] {
        return [] // порожній масив замість помилки
    }
}

// Коректне дотримання LSP
class NetworkProvider: DataProvider {
    func fetchData() async throws -> [String] {
        throw NetworkError.timeout
    }
}

Практичне правило: якщо підклас не може виконати контракт базового класу — він не повинен бути підкласом. Альтернатива — виділити інтерфейс з мінімальним контрактом та реалізувати його в кожному типі по-своєму.

LSP та успадкування: коли обирати композицію

Композиція краща за успадкування в ситуаціях, де відношення «є» (is-a) неоднозначне або умовне. Класичний приклад: Manager є Employee? Так. Але чи є Square коректним Rectangle? LSP каже «ні». Якщо сумніваєтеся в коректності успадкування — обирайте композицію.

У мобільній розробці композиція часто використовується через впровадження залежностей: замість того, щоб успадковувати поведінку від базового класу, клас отримує її через конструктор. ViewModel не успадковує від Repository, а приймає його як залежність. Це виключає порушення LSP за визначенням — немає успадкування, немає й порушення контракту.

Ознаки, що успадкування слід замінити на композицію: підклас не використовує частину методів базового класу, підклас перевизначає методи порожніми заглушками, клієнтський код перевіряє тип об'єкта через instanceof. У цих випадках успадкування було обрано невірно, і LSP порушено.

Рішення через інтерфейси

Інтерфейси вирішують проблему LSP без успадкування: кожен тип реалізує рівно ті методи, які йому потрібні. Замість спільного базового класу Bird з методом fly (де Penguin не літає) — інтерфейс Flyable, який реалізують лише літаючі птахи. Penguin реалізує Bird без методу fly — LSP не порушено.

В Android-архітектурі цей підхід застосовується через сегреговані інтерфейси UseCase: замість одного великого UseCase з методами getAll, getById, save, delete — окремі інтерфейси GetItemsUseCase, SaveItemUseCase. Клієнт залежить лише від потрібного інтерфейсу, і будь-який клас, що реалізує цей інтерфейс, коректний з точки зору LSP.

Часті запитання

Чим LSP відрізняється від простого успадкування?

Успадкування — механізм мови, LSP — правило коректного використання цього механізму. Успадкування гарантує сумісність сигнатур (синтаксис), LSP вимагає сумісності поведінки (семантика). Успадкування без LSP дає поліморфізм, який ламається в рантаймі.

Чи завжди null у підкласі порушує LSP?

Якщо базовий клас гарантує не-null повернення — так. Якщо контракт допускає null (опціональне значення) — ні. LSP не забороняє null, він забороняє послаблення контракту. Вивчіть документацію базового класу та перевірте, чи сумісний контракт підкласу.

Як LSP застосовується до протоколів у Swift?

До протоколів LSP застосовується так само, як до класів. Реалізація протоколу повинна дотримуватися семантичного контракту: якщо протокол визначає метод як non-throwing, реалізація не повинна викидати помилки. Swift не перевіряє це на рівні компіляції — відповідальність на розробнику.

Чи може LSP порушуватися при використанні sealed class?

Sealed class в Kotlin — спеціальний випадок, оскільки ієрархія закрита та відома компілятору. LSP до sealed class застосовний меншою мірою, тому що всі підтипи перераховані явно в when-виразі. Помилка sealed-підкласу буде локальною, а не прихованою поліморфною помилкою.

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

Напишіть parameterized test на базовий клас, який запускається для всіх його підкласів. Тест перевіряє ключові поведінкові контракти: значення, що повертаються, винятки, стани. Якщо тест падає на одному з підкласів — LSP порушено. У CI такий тест запобігає регресії поліморфного коду.

Підсумки

  • LSP (Liskov Substitution Principle) — принцип підстановки, третій в SOLID, про семантичну сумісність успадкування
  • Підклас повинен зберігати контракт базового класу: передумови, післяумови та інваріанти
  • Перевірка instanceof та порожні перевизначення методів — головні ознаки порушення LSP
  • Композиція та інтерфейси вирішують проблему LSP там, де успадкування некоректне
  • Проблема квадрата і прямокутника — класичний приклад несумісності підтипів
  • Тест контракту для базового класу, що запускається для всіх підкласів, виявляє порушення LSP на CI
  • Sealed class в Kotlin знижує ризики LSP завдяки закритій ієрархії, відомій компілятору

Ми розробимо мобільний застосунок під ключ

IT Sectr створює застосунки для iOS та Android для стартапів і бізнесу з 2017 року. Ми проконсультуємо вас і запропонуємо найкраще рішення.

Обговорити проект

Читайте також