Что такое принцип Liskov Substitution?
Проще говоря: если класс B наследуется от класса A, то везде, где используется A, должен работать и B — без сюрпризов.
🟢 Junior Level
Принцип подстановки Лисков (LSP — Liskov Substitution Principle) — это один из пяти принципов SOLID, который гласит: “Объекты подкласса должны быть заменяемы объектами базового класса без нарушения корректности программы”.
Проще говоря: если класс B наследуется от класса A, то везде, где используется A, должен работать и B — без сюрпризов.
Простая аналогия: Представьте розетку. Если вы купите любую вилку, соответствующую стандарту, она должна работать. Если какая-то вилка “специальная” и не вставляется — стандарт нарушен.
Пример нарушения LSP:
// Плохо: наследник бросает исключение на метод родителя
public class Bird {
public void fly() {
System.out.println("Лечу!");
}
}
public class Ostrich extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("Страусы не летают!");
}
}
// Клиентский код — сломается при подстановке Ostrich
public void makeBirdsFly(List<Bird> birds) {
for (Bird bird : birds) {
bird.fly(); // ClassCastException или UnsupportedOperationException!
}
}
Пример с соблюдением LSP:
// Хорошо: разделение по поведению
public interface Bird {
void sing();
}
public interface FlyingBird extends Bird {
void fly();
}
public class Sparrow implements FlyingBird {
@Override
public void sing() { System.out.println("Чирикаю!"); }
@Override
public void fly() { System.out.println("Лечу!"); }
}
public class Ostrich implements Bird {
@Override
public void sing() { System.out.println("Шиплю!"); }
// fly() отсутствует — и это корректно
}
Когда использовать:
- Всегда при проектировании иерархий наследования
- При создании базовых классов, которые будут расширяться
- При работе с коллекциями базовых типов
🟡 Middle Level
Как это работает
LSP — самый технически сложный принцип SOLID. Он не просто про наследование, а про проектирование по контракту (Design by Contract).
Термины Design by Contract:
- Preconditions — требования к входным данным (что должно быть true ДО вызова)
- Postconditions — гарантии результата (что будет true ПОСЛЕ вызова)
- Invariants — условия, которые ВСЕГДА истинны для объекта
- History Constraint — объект не должен менять прошлое состояние непредсказуемо
Контрактные правила LSP:
- Предусловия (Preconditions) не могут быть усилены: Подкласс не может требовать больше, чем родитель. (Если родитель принимает любое число, наследник не может требовать только положительные)
- Постусловия (Postconditions) не могут быть ослаблены: Подкласс должен гарантировать как минимум тот же результат, что и родитель
- Инварианты (Invariants) должны сохраняться: Состояние объекта, которое было истинным для родителя, должно оставаться истинным и для наследника
- История (History Constraint): Подкласс не должен изменять состояние, которое в родителе считалось неизменяемым
Как выявить нарушение LSP
Признаки нарушения:
instanceofв коде: Если вы проверяете тип объекта, чтобы вызвать специфичный метод — иерархия спроектирована неверно- Пустые методы: Наследник переопределяет метод родителя пустой реализацией, потому что “ему это не нужно”
- Бросание неожиданных исключений: Как в примере с
UnsupportedOperationException - Документация “не поддерживает”: В javadoverride написано “This implementation does not support…”
Практическое применение
Классический пример: Квадрат и Прямоугольник
// Нарушение LSP
public class Rectangle {
protected int width;
protected int height;
public void setWidth(int width) { this.width = width; }
public void setHeight(int height) { this.height = height; }
public int getArea() { return width * height; }
}
public class Square extends Rectangle {
@Override
public void setWidth(int width) {
this.width = width;
this.height = width; // "Побочный эффект"
}
@Override
public void setHeight(int height) {
this.width = height; // "Побочный эффект"
this.height = height;
}
}
// Клиентский код — сломается при подстановке Square
public void testRectangle(Rectangle r) {
r.setWidth(10);
r.setHeight(20);
// Для Rectangle: площадь = 200
// Для Square: площадь = 400 — клиент в шоке!
}
Правильное решение — композиция, а не наследование:
// Хорошо: интерфейс вместо наследования
public interface Shape {
int getArea();
}
public class Rectangle implements Shape {
private int width;
private int height;
public Rectangle(int width, int height) {
this.width = width;
this.height = height;
}
public void setWidth(int width) { this.width = width; }
public void setHeight(int height) { this.height = height; }
@Override
public int getArea() { return width * height; }
}
public class Square implements Shape {
private int side;
public Square(int side) { this.side = side; }
@Override
public int getArea() { return side * side; }
}
Типичные ошибки
-
Ошибка: Наследование ради переиспользования кода (“у них же есть общие поля!”) Решение: Используйте композицию вместо наследования
-
Ошибка:
java.util.Collections.unmodifiableList()бросаетUnsupportedOperationExceptionприadd()Решение: Это известное нарушение LSP. ИспользуйтеReadOnlyListwrapper с интерфейсом только для чтения -
Ошибка: Модификация инварианта родителя Решение: Чётко документируйте инварианты каждого класса
Когда LSP особенно важен
- Публичные API и библиотеки (пользователи ожидают предсказуемого поведения)
- Фреймворки с плагинами/расширениями
- Системы с полиморфными коллекциями
🔴 Senior Level
Internal Implementation и Архитектура
LSP — это про предсказуемость поведения. Наследование — это обязательство соблюдать все инварианты родителя. Если подкласс ведёт себя “странно” с точки зрения клиента базового класса — LSP нарушен.
На уровне Senior важно говорить на языке ограничений контракта:
| Constraint | Правило | Пример нарушения |
|---|---|---|
| Preconditions | Не могут быть усилены | SavingsAccount.withdraw требует amount <= balance, а Account разрешает овердрафт |
| Postconditions | Не могут быть ослаблены | FastSort может вернуть частично отсортированный массив |
| Invariants | Должны сохраняться | Square меняет width при вызове setHeight |
| History | Состояние не должно меняться непредсказуемо | CachingList возвращает stale данные |
// Ключевой вопрос: если клиентский код написан для Account и рассчитывает // на овердрафт, подстановка SavingsAccount сломает его логику. // Если клиент НЕ рассчитывает на овердрафт — нарушения LSP нет.
LSP и типизация в Java
Java поддерживает LSP на уровне компилятора через ковариантность возвращаемых типов:
public class Parent {
public Number get() { return 1; }
}
public class Child extends Parent {
@Override
public Integer get() { return 1; } // Ковариантность: Integer - это Number. OK.
}
Однако компилятор не может проверить семантическую корректность (как в примере с Квадратом и Прямоугольником). Это ответственность архитектора.
Контрвариантность аргументов (не поддерживается в Java):
// Теоретически допустимо в LSP:
// Если Parent.accept(Object o), то Child.accept(String s) — сужение типа
// Но Java этого не позволяет при override
Проблема хрупкого базов класса (Fragile Base Class)
LSP защищает нас от ситуации, когда изменение внутренней логики наследника ломает логику, на которую полагается клиент.
- Классический пример:
ArrayListvsCollections.unmodifiableList()unmodifiableListявляется обёрткой надList, но бросаетUnsupportedOperationExceptionпри попыткеadd()- Это нарушение LSP, так как клиент, ожидающий
List, не готов к такому поведению
Архитектурные Trade-offs
Строгое соблюдение LSP:
- ✅ Плюсы: Предсказуемое поведение, надежное наследование, минимум runtime-ошибок
- ❌ Минусы: Ограничивает переиспользование кода, требует более тщательного проектирования
Композиция вместо наследования:
- ✅ Плюсы: Полная свобода поведения, нет обязательств перед контрактом родителя
- ❌ Минусы: Больше boilerplate-кода, нужно делегировать методы
Edge Cases
- Template Method Pattern: Базовый класс определяет алгоритм, подклассы — шаги. Нарушает ли это LSP?
- Ответ: Нет, если подклассы не меняют инварианты алгоритма. Но если шаг может “сломать” алгоритм — это нарушение
- Decorators: Обёртки добавляют поведение. Нарушают ли LSP?
- Ответ: Нет, если они делегируют все методы базовому объекту и не меняют его инварианты
- Proxies и Mocks: Динамические прокси для тестирования
- Ответ: Могут нарушать LSP, если не воспроизводят все постусловия оригинала
Производительность
- Virtual dispatch: Вызов переопределённого метода = косвенная адресация. JIT делает inline caching и class hierarchy analysis
- Inlining barrier: Если JIT не может доказать, что метод не переопределён — он не сможет сделать inlining. Это влияет на производительность в hot path
- Escape Analysis: При создании объектов наследников JVM может оптимизиров allocation, но только если иерархия известна на этапе JIT
Production Experience
Реальный сценарий из продакшена:
В проекте использовался BaseRepository с методом save(Entity). Один из подклассов AuditRepository переопределил save() и добавил логирование после сохранения. Другой подкласс ValidatingRepository — валидацию до сохранения.
Когда появился CachingRepository, который переопределил save() и добавил кэш вместо БД — всё сломалось: валидация не работала, аудит не логировал.
Решение: Заменили наследование на цепочку декораторов (Decorator Pattern), где каждый добавляет своё поведение к базовому save().
Monitoring и диагностика
Как обнаружить нарушение LSP в коде:
- Code Review признаки:
- Если
instanceofиспользуется для обхода разных поведений вместо полиморфизма — это нарушение LSP. Pattern matching с sealed types — легитимное исключение. - Пустые override-методы
throw new UnsupportedOperationException(...)
- Если
- Unit Test признаки:
- Тесты для базового класса падают при запуске с наследником
- Разное поведение одного метода для разных подклассов
- Инструменты:
- Mutation Testing (Pitest): проверяет, что тесты ловят изменения поведения
- ArchUnit: проверяет, что наследники не используют запрещённые паттерны
Best Practices для Highload
- Sealed Interfaces (Java 17+): Ограничьте иерархию — компилятор проверит exhaustiveness, JIT оптимизирует dispatch
- Immutability: Неизменяемые объекты проще сделать LSP-совместимыми (нет изменяемого состояния = нет нарушения инвариантов)
- Contract Testing: Пишите тесты на контракты базового типа и запускайте их для всех наследников
Связь с другими принципами
- LSP ← OCP: Если наследники корректно подставляются, система открыта для расширения
- LSP ← SRP: Маленькие классы с одной ответственностью легче правильно наследовать
- LSP → ISP: Разделение интерфейсов автоматически помогает соблюдать LSP, так как контракты становятся меньше
Резюме для Senior
- LSP — это про предсказуемость поведения, а не про синтаксис наследования
- Наследование — это обязательство соблюдать все инварианты родителя
- Если подкласс ведёт себя “странно” с точки зрения клиента базового класса — LSP нарушен
- Помните: Квадрат — это геометрически частный случай прямоугольника, но в ООП
Squareне может быть наследникомRectangleс методамиsetWidth/setHeight - Composition over Inheritance: Часто LSP нарушается при попытке сэкономить код через наследование. Senior-разработчик предпочтёт композицию
🎯 Шпаргалка для интервью
Обязательно знать:
- LSP: объекты подкласса должны быть заменяемы объектами базового класса без нарушения корректности
- Design by Contract: предусловия нельзя усиливать, постусловия нельзя ослаблять, инварианты должны сохраняться
- Классический пример нарушения — Square extends Rectangle (побочные эффекты в setter-ах)
UnsupportedOperationExceptionв наследнике — яркий признак нарушения LSPinstanceofв коде для обхода разных поведений = LSP нарушен- Композиция предпочтительнее наследования для соблюдения LSP
java.util.Date/java.sql.Date— известное нарушение LSP в JDK
Частые уточняющие вопросы:
- Как обнаружить нарушение LSP? — Тесты базового класса падают для наследника,
instanceofпроверки, пустые override-методы - Что такое Fragile Base Class Problem? — Изменение родителя ломает всех наследников непредсказуемым образом
- Можно ли нарушить LSP с интерфейсами? — Да, если реализация бросает неожиданные исключения или меняет контракт
- Contract Testing что это? — Общие тесты для интерфейса, запускаемые для всех реализаций
Красные флаги (НЕ говорить):
- “LSP — это просто про наследование” (нет — это про предсказуемость поведения и контракты)
- “Квадрат наследует Прямоугольник — это нормально” (классический пример нарушения)
- “Если компилятор не ругается — LSP соблюдён” (LSP — семантический, а не синтаксический принцип)
Связанные темы:
- [[6. Приведите пример нарушения принципа Liskov Substitution]]
- [[10. Что такое композиция и наследование]]
- [[11. В каких случаях лучше использовать композицию вместо наследования]]
- [[7. Что такое принцип Interface Segregation]]