Программирование

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

Принцип подстановки Лисков (LSP) в ООП: определение, формальная формулировка, примеры правильной реализации и классических нарушений вроде квадрат/прямоугольник. Как соблюдать LSP в коде.

Что такое принцип подстановки Лисков (LSP) в объектно-ориентированном проектировании, и можете ли привести практические примеры его реализации и нарушения?

Принцип подстановки Лисков (LSP), или принцип лискова, — это правило SOLID в объектно-ориентированном программировании, которое требует, чтобы подклассы были полностью взаимозаменяемы с базовым классом: программа, работающая с базовым типом, не должна ломаться при замене на наследника. Наследник расширяет поведение, но не меняет контракт суперкласса — предусловия, постусловия и инварианты остаются неизменными. Классический пример нарушения — когда квадрат наследуется от прямоугольника, и установка ширины влияет на высоту, нарушая ожидания клиентского кода.


Содержание


Что такое принцип подстановки Лисков (LSP)

Представьте: вы пишете код, который работает с базовым классом, скажем, “Птица”. А потом добавляете наследника “Пингвин” — и вдруг всё ломается. Звучит знакомо? Это и есть суть нарушения принципа подстановки Лисков (LSP). В двух словах, LSP гарантирует, что подтипы (наследники) можно подставлять вместо базового типа без сюрпризов для клиента.

Почему это важно именно в объектно-ориентированном проектировании? Наследование — мощный инструмент, но без LSP оно превращается в мину замедленного действия. Клиентский код полагается на контракт базового класса: что метод принимает (предусловия), что возвращает (постусловия) и что всегда верно (инварианты). Наследник не имеет права это менять. Как отметили в статье на Habr, “наследующий класс должен дополнять, а не заменять поведение базового класса”.

В реальных проектах LSP спасает от рефакторингового ада. Без него полиморфизм работает через раз, а поддержка кода становится кошмаром. Коротко: замени базовый объект на наследника — и программа не должна заметить разницы.


Формальная формулировка принципа Лисков

Барбара Лисков, автор принципа, дала чёткую формулировку: “Пусть q(x) — свойство, верное для объектов x типа T. Тогда q(y) должно быть верным и для объектов y типа S, где S — подтип T”. Проще говоря, функции, использующие базовый тип, должны работать с подтипами без изменений.

Alex Kosarev в своей статье разбирает это на правилах:

  • Предусловия: наследник не может их усиливать (требовать больше, чем базовый класс).
  • Постусловия: не ослаблять (гарантировать не меньше).
  • Инварианты: сохранять всегда (свойства, верные для всех состояний).

Например, если базовый метод требует неотрицательное число, наследник не может требовать чётное. Таблица для ясности:

Элемент контракта Базовый класс Наследник по LSP
Предусловия ≤ 100 ≤ 100 (или слабее)
Постусловия Возвращает > 0 Возвращает > 0 (или сильнее)
Инварианты Сумма > 0 Сумма > 0 всегда

Это не теория — в Metanit подчёркивают: подтип должен быть подставляемым без знания о нём. Иначе полиморфизм теряет смысл.

А теперь вопрос: сколько раз вы видели код, где наследник “немного меняет логику”? Вероятно, много. LSP учит думать о контрактах заранее.


Примеры правильной реализации LSP

Давайте к практике. Хороший пример — транспортные средства. Базовый класс Vehicle с методом start(). Подклассы Car и ElectricBus реализуют его по-своему, но контракт не меняют: метод всегда заводит транспорт.

Вот код на Java (адаптировано из Dev-Gang):

java
abstract class Vehicle {
 public abstract void start(); // Контракт: заводит двигатель
}

class Car extends Vehicle {
 @Override
 public void start() {
 System.out.println("Бензиновый двигатель запущен");
 }
}

class ElectricBus extends Vehicle {
 @Override
 public void start() {
 System.out.println("Электродвигатель запущен");
 }
}

// Клиентский код
void drive(Vehicle vehicle) { // Работает с любым Vehicle
 vehicle.start();
}

Заменяем Car на ElectricBus — всё ок. Клиент не знает деталей, но поведение предсказуемо.

Ещё вариант: логгер. Базовый Logger пишет в файл, наследник CachingLogger добавляет кэш, но не меняет интерфейс. Предусловие “строка не null” остаётся, постусловие “сообщение записано” — тоже. Полиморфизм на высоте.

Такие примеры показывают: LSP работает, когда подклассы дополняют, а не переписывают логику.


Классические нарушения принципа подстановки Лисков

Теперь тёмная сторона. Самый известный — прямоугольник и квадрат. Квадрат является прямоугольником математически, но в коде это провал.

Базовый Rectangle:

csharp
public class Rectangle {
 public virtual int Width { get; set; }
 public virtual int Height { get; set; }
 public virtual int Area => Width * Height;
}

Наследник Square:

csharp
public class Square : Rectangle {
 public override int Width {
 set { base.Width = base.Height = value; } // Нарушение!
 }
 public override int Height {
 set { base.Width = base.Height = value; }
 }
}

Тест:

csharp
Rectangle r = new Square { Width = 5 };
r.Height = 4; // Ожидаем площадь 20, но получаем 16 — инвариант сломан!

Как в alexkosarev.name: замена ломает клиентский код. Предусловие setter’ов изменилось — теперь они влияют друг на друга.

Другой пример из Metanit: Account с проверкой баланса > 0 перед снятием. MicroAccount убирает проверку — постусловие ослаблено, клиент может снять в минус, чего не ждал.

Или птицы: Bird.fly(), Penguin.fly() бросает исключение. Контракт нарушен — полиморфизм мёртв.

Эти случаи убивают код. Замечаете паттерн? Наследник меняет ожидания.


Как избежать нарушений LSP в коде

Не хотите ловушек? Используйте композицию вместо наследования. Вместо Square extends RectangleSquare has Rectangle.

Из Habr: если поведение меняется — новый интерфейс или абстрактный класс. Разделите интерфейсы: IFlyable для летающих птиц отдельно.

Правила на каждый день:

  1. Пишите контракты явно (JavaDoc, атрибуты).
  2. Тестируйте подстановку: один тест-сьют для базового и всех наследников.
  3. Спрашивайте: “Что если клиент не знает о наследнике?”
  4. Предпочитайте интерфейсы — они слабее связаны.

В больших проектах LSP упрощает миграции. А если Java/Python — аннотации @Contract или docstrings спасут.

Короче, проектируйте с учётом подстановки с нуля. Стоит усилий.


Как тестировать соблюдение принципа Лисков

LSP не абстракция — проверяйте тестами. Создайте базовые тесты для суперкласса, запустите на наследниках.

Пример JUnit для Rectangle/Square:

java
@Test
void areaAfterSetHeight() {
 Rectangle rect = createRectangle(); // Фабрика для подстановки
 rect.Width = 5;
 rect.Height = 4;
 assertEquals(20, rect.Area);
}

Если Square проваливает — LSP нарушен. Инструменты: SpecFlow, Contract Tests.

По Metanit: тестируйте пред-/постусловия. Автоматизируйте — и код чище.

В итоге: LSP — не догма, а инструмент для надёжности.


Источники

  1. Habr — Принцип подстановки Барбары Лисков
  2. Alex Kosarev — SOLID в деталях: LSP
  3. Metanit — Принцип подстановки Лисков (C#)
  4. Dev-Gang — SOLID: Принцип подстановки Барбары Лисков

Заключение

Принцип подстановки Лисков (LSP) делает наследование безопасным: подклассы заменяют базовые без хаоса. Изучите примеры вроде Vehicle или Rectangle/Square — и поймёте, почему нарушения так часты. Соблюдайте контракты, тестируйте подстановку, предпочитайте композицию — код станет гибче и проще в поддержке. Внедрите LSP сегодня, и SOLID засияет по-новому.

Авторы
Проверено модерацией
Модерация
Принцип подстановки Лисков (LSP): примеры и нарушения