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 года. Мы проконсультируем вас и предложим наилучшее решение.

Обсудить проект

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