Принцип подстановки Лисков (LSP): примеры и нарушения
Принцип подстановки Лисков (LSP) в ООП: определение, формальная формулировка, примеры правильной реализации и классических нарушений вроде квадрат/прямоугольник. Как соблюдать LSP в коде.
Что такое принцип подстановки Лисков (LSP) в объектно-ориентированном проектировании, и можете ли привести практические примеры его реализации и нарушения?
Принцип подстановки Лисков (LSP), или принцип лискова, — это правило SOLID в объектно-ориентированном программировании, которое требует, чтобы подклассы были полностью взаимозаменяемы с базовым классом: программа, работающая с базовым типом, не должна ломаться при замене на наследника. Наследник расширяет поведение, но не меняет контракт суперкласса — предусловия, постусловия и инварианты остаются неизменными. Классический пример нарушения — когда квадрат наследуется от прямоугольника, и установка ширины влияет на высоту, нарушая ожидания клиентского кода.
Содержание
- Что такое принцип подстановки Лисков (LSP)
- Формальная формулировка принципа Лисков
- Примеры правильной реализации LSP
- Классические нарушения принципа подстановки Лисков
- Как избежать нарушений LSP в коде
- Как тестировать соблюдение принципа Лисков
- Источники
- Заключение
Что такое принцип подстановки Лисков (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):
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:
public class Rectangle {
public virtual int Width { get; set; }
public virtual int Height { get; set; }
public virtual int Area => Width * Height;
}
Наследник Square:
public class Square : Rectangle {
public override int Width {
set { base.Width = base.Height = value; } // Нарушение!
}
public override int Height {
set { base.Width = base.Height = value; }
}
}
Тест:
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 Rectangle — Square has Rectangle.
Из Habr: если поведение меняется — новый интерфейс или абстрактный класс. Разделите интерфейсы: IFlyable для летающих птиц отдельно.
Правила на каждый день:
- Пишите контракты явно (JavaDoc, атрибуты).
- Тестируйте подстановку: один тест-сьют для базового и всех наследников.
- Спрашивайте: “Что если клиент не знает о наследнике?”
- Предпочитайте интерфейсы — они слабее связаны.
В больших проектах LSP упрощает миграции. А если Java/Python — аннотации @Contract или docstrings спасут.
Короче, проектируйте с учётом подстановки с нуля. Стоит усилий.
Как тестировать соблюдение принципа Лисков
LSP не абстракция — проверяйте тестами. Создайте базовые тесты для суперкласса, запустите на наследниках.
Пример JUnit для Rectangle/Square:
@Test
void areaAfterSetHeight() {
Rectangle rect = createRectangle(); // Фабрика для подстановки
rect.Width = 5;
rect.Height = 4;
assertEquals(20, rect.Area);
}
Если Square проваливает — LSP нарушен. Инструменты: SpecFlow, Contract Tests.
По Metanit: тестируйте пред-/постусловия. Автоматизируйте — и код чище.
В итоге: LSP — не догма, а инструмент для надёжности.
Источники
- Habr — Принцип подстановки Барбары Лисков
- Alex Kosarev — SOLID в деталях: LSP
- Metanit — Принцип подстановки Лисков (C#)
- Dev-Gang — SOLID: Принцип подстановки Барбары Лисков
Заключение
Принцип подстановки Лисков (LSP) делает наследование безопасным: подклассы заменяют базовые без хаоса. Изучите примеры вроде Vehicle или Rectangle/Square — и поймёте, почему нарушения так часты. Соблюдайте контракты, тестируйте подстановку, предпочитайте композицию — код станет гибче и проще в поддержке. Внедрите LSP сегодня, и SOLID засияет по-новому.