Принцип подстановки Барбары Лисков (LSP): суть и примеры

Принцип подстановки Барбары Лисков (Liskov Substitution Principle, LSP) - это буква L в аббревиатуре SOLID и одно из самых неправильно понимаемых правил объектно-ориентированного дизайна. Формулировка короткая: объекты базового типа должны быть заменяемы объектами производного типа без нарушения корректности программы. На практике за этой фразой стоит строгая теория контрактов, ковариантности и контравариантности, а самое известное её нарушение - задача про квадрат и прямоугольник, которая кажется естественной, но ломает наследование. Разберём, что именно требует LSP, как формализуется подстановочность и как отличить корректный подтип от ловушки.
Чтобы быстро проверить собственную иерархию классов на нарушение LSP, опишите её в инструменте ниже - он соберёт запрос и покажет разбор контрактов.
Формулировка принципа
Барбара Лисков сформулировала свой принцип в 1987 году на конференции OOPSLA, а строгое определение дала вместе с Жанетт Уинг в 1994-м. В оригинале оно звучит так: если - свойство, доказуемое для объектов типа , то должно быть истинно для объектов типа , где - подтип .
Иными словами, везде, где код работает с базовым типом, можно без оговорок подставить любой его наследник, и поведение останется корректным. Подтип не просто наследует интерфейс - он обязан соблюдать поведенческий контракт базового типа. Это сильнее обычного наследования: компилятор проверяет совместимость сигнатур, но LSP требует совместимости поведения.

Ключевое слово здесь - «доказуемое свойство». Если клиентский код рассчитывает, что метод не выбрасывает исключение или возвращает положительное число, наследник не имеет права это свойство нарушить. Принцип тесно связан с принципом инверсии зависимостей: корректные подтипы позволяют расширять систему без правки клиентского кода, опираясь на абстракции.
Контракты: предусловия и постусловия
Поведенческий контракт метода описывается тремя вещами: предусловиями (что должно быть истинно до вызова), постусловиями (что гарантируется после) и инвариантами (что остаётся истинным всегда). LSP накладывает на переопределённые методы жёсткие правила:
- Предусловия нельзя усиливать в подтипе. Если базовый метод принимает любое целое число, наследник не вправе требовать только положительные - иначе вызывающий код, написанный под базовый тип, сломается.
- Постусловия нельзя ослаблять. Если база гарантирует непустой результат, наследник обязан гарантировать как минимум то же самое, а может и больше.
- Инварианты базового типа должны сохраняться наследником.
Формально, если и - пред- и постусловия базового метода, а и - наследника, то требуется:
Предусловие может только ослабляться (принимать больше входов), постусловие - только усиливаться (гарантировать больше). Это и есть математическая запись «заменяемости».
Правило истории и инварианты
Помимо пред- и постусловий, Лисков и Уинг ввели правило истории (history constraint). Подтип не должен позволять изменения состояния, недопустимые для базового типа. Классический пример - неизменяемый базовый тип Точка и наследник ИзменяемаяТочка: добавив сеттер координат, наследник позволяет менять то, что базовый контракт считал константой. Клиент, рассчитывавший на неизменность, получит сюрприз.
Простой тест на правило истории: если у базового типа объект «однажды создан и не меняется», а наследник вводит мутирующие методы, вы почти наверняка нарушаете LSP. Неизменяемость - это инвариант, и его нельзя отменить в подтипе.
Инварианты особенно коварны, потому что не видны в сигнатурах. Компилятор пропустит код, а нарушение проявится только в рантайме, когда клиент сделает допущение, верное для базы, но ложное для наследника.
Классический контрпример: Квадрат и Прямоугольник
Самое известное нарушение LSP - попытка унаследовать Квадрат от Прямоугольник. Геометрически квадрат является прямоугольником, и наследование кажется очевидным. Но поведенчески оно ломается.

У прямоугольника есть независимые setШирину и setВысоту. Контракт прямоугольника гласит: после setШирину(5) ширина равна 5, а высота не изменилась. Квадрат обязан держать стороны равными, поэтому его setШирину(5) вынужден менять и высоту. Это ослабляет постусловие прямоугольника - нарушение прямое.
Клиентский код, написанный под прямоугольник, сломается:
Функция, проверяющая площадь, пройдёт на прямоугольнике и упадёт на квадрате - а ведь квадрат подставлен туда, где ждали прямоугольник. Это и есть нарушение подстановочности. Вывод не в том, что «квадрат не прямоугольник», а в том, что отношение наследования должно отражать поведение, а не житейскую таксономию.
Ковариантность и контравариантность
LSP формализует, как могут меняться типы параметров и возвращаемых значений при переопределении:
- Возвращаемые типы ковариантны: наследник может вернуть более конкретный тип, чем база. Это безопасно - клиент ждёт базовый тип, а получает его частный случай.
- Типы параметров контравариантны: наследник может принимать более общий тип, чем база. Тоже безопасно - он способен обработать всё, что обработала бы база, и даже больше.
Эти правила - прямое следствие условий на пред- и постусловия. Параметр метода участвует в предусловии (его нельзя сужать), результат - в постусловии (его можно сужать). Большинство языков поддерживает ковариантность возвращаемого типа, но контравариантность параметров встречается реже - её строго реализует, например, Eiffel и (частично) Scala.
Как обнаружить нарушение
На практике LSP нарушают, когда наследник:
- бросает исключение там, где база его не бросала (например,
NotImplementedExceptionв переопределённом методе); - усиливает предусловия - требует более узкий вход;
- возвращает
nullили пустой результат там, где база гарантировала значение; - молча игнорирует вызов метода, который у базы делал работу;
- проверяет тип через
instanceofв клиентском коде - это почти всегда симптом того, что подтипы ведут себя по-разному.
Последний пункт - самый надёжный индикатор. Если клиентскому коду приходится спрашивать «а какой именно это подтип?», подстановочность уже сломана: клиент не может работать с базовым типом единообразно. Чтобы отделить корректную иерархию от мнимой, полезно сверяться и со смежными принципами SOLID.
Как чинить нарушения
Нарушение LSP - почти всегда сигнал неправильной декомпозиции. Типовые лекарства:
- Композиция вместо наследования. Если квадрат не подставляется вместо прямоугольника, не наследуйте - сделайте оба наследниками общего неизменяемого
Фигурас методомплощадь(), без сеттеров. - Выделение общего абстрактного базового типа с минимальным контрактом, который честно соблюдают все наследники.
- Сужение базового интерфейса до того поведения, которое реально общее. Если только часть наследников умеет «летать», метод
летать()не место в базовомПтица. - Неизменяемость убирает целый класс нарушений правила истории: нет сеттеров - нет способа разойтись в поведении.
Принцип не запрещает наследование - он запрещает наследование, которое лжёт о поведении.
Частые ошибки
- Путать LSP с обычным наследованием. Компилятор разрешает переопределить метод как угодно; LSP требует, чтобы переопределение сохраняло контракт. Совместимость сигнатур не равна подстановочности.
- Считать LSP правилом про «is-a» из реального мира. Квадрат «является» прямоугольником в геометрии, но не в коде. Решает поведение, а не словарь.
- Бросать исключение в наследнике как «заглушку».
throw new NotImplementedException()в переопределённом методе - прямое нарушение: база этого не делала, клиент не готов. - Усиливать предусловия из лучших побуждений. Добавить в наследнике проверку «аргумент должен быть положительным», которой не было в базе, - значит сломать вызывающий код.
- Игнорировать инварианты, потому что их не видно. Неизменность, диапазон значений, непустота коллекции - всё это контракт, даже если не выражено в типах.
FAQ
Чем LSP отличается от обычного полиморфизма? Полиморфизм - это техническая возможность вызвать переопределённый метод через базовую ссылку. LSP - требование, чтобы такой вызов всегда давал корректное поведение. Полиморфизм без LSP компилируется, но даёт сюрпризы в рантайме: подтип «подходит» по типу, но «лжёт» по поведению.
Почему квадрат всё-таки нельзя наследовать от прямоугольника? Потому что у прямоугольника независимые ширина и высота, а квадрат вынужден связать их. Любой клиент, меняющий стороны по отдельности и проверяющий результат, сломается на квадрате. Если убрать сеттеры и сделать фигуры неизменяемыми, противоречие исчезает - но это уже другая иерархия.
Связан ли LSP с типизацией языка? Да, частично. Ковариантность возвращаемых типов и контравариантность параметров - это формальная сторона LSP, которую система типов может проверять. Но поведенческие контракты (инварианты, исключения, диапазоны) большинство языков не проверяет - за них отвечает дисциплина проектировщика и тесты.
Коротко
Принцип подстановки Лисков требует, чтобы объект подтипа можно было использовать всюду вместо объекта базового типа без нарушения корректности. За этим стоит теория контрактов: предусловия в подтипе нельзя усиливать, постусловия - ослаблять, инварианты и неизменяемость - нарушать. Формально это даёт ковариантность результатов и контравариантность параметров. Классический контрпример Квадрат-Прямоугольник показывает, что наследование должно отражать поведение, а не житейскую таксономию. Чинят нарушения композицией, неизменяемостью и честным выделением общего контракта. LSP - это буква L в SOLID и тест на то, врёт ли ваша иерархия о поведении.
Читайте также

Принцип единственной ответственности SRP в SOLID
Принцип единственной ответственности SRP из SOLID простыми словами: у класса должна быть одна причина для изменения. Разбираем формулировку Мартина, примеры рефакторинга и частые ошибки.

Принцип инверсии зависимостей DIP: зависим от абстракций
Принцип инверсии зависимостей (DIP) из SOLID простыми словами: почему модули верхнего уровня не должны зависеть от деталей, как развернуть стрелку зависимости через интерфейс и внедрение зависимостей.

Принципы SOLID с примерами: разбор пяти правил ООП
Принципы SOLID с примерами на коде: расшифровка SRP, OCP, LSP, ISP и DIP, типичные нарушения и рефакторинг, понятные аналогии и ответы для собеседования.