Приведите пример нарушения принципа Liskov Substitution
Нарушение LSP часто маскируется под "повторное использование кода". Мы пытаемся наследовать один класс от другого просто потому, что у них есть общие поля, игнорируя различия в...
🟢 Junior Level
Нарушение LSP часто маскируется под “повторное использование кода”. Мы пытаемся наследовать один класс от другого просто потому, что у них есть общие поля, игнорируя различия в их поведении.
Пример 1: “Ленивый” наследник Когда подкласс переопределяет метод родителя и выбрасывает исключение или ничего не делает.
// Плохо: наследник не поддерживает метод родителя
public class Bird {
public void fly() {
System.out.println("Лечу!");
}
}
public class Penguin extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("Пингвины не летают!");
}
}
// Сломается:
public void letBirdsFly(List<Bird> birds) {
for (Bird bird : birds) {
bird.fly(); // UnsupportedOperationException для Penguin!
}
}
Пример 2: Прямоугольник и Квадрат
// Плохо: Square меняет поведение setter-ов
public class Rectangle {
protected int width;
protected int height;
public void setWidth(int w) { this.width = w; }
public void setHeight(int h) { this.height = h; }
}
public class Square extends Rectangle {
@Override
public void setWidth(int w) {
this.width = w;
this.height = w; // Побочный эффект!
}
@Override
public void setHeight(int h) {
this.width = h; // Побочный эффект!
this.height = h;
}
}
// Клиент ожидает одно поведение, получает другое:
public void test(Rectangle r) {
r.setWidth(5);
r.setHeight(10);
// Для Rectangle: width=5, height=10
// Для Square: width=10, height=10 — баг!
}
Как исправить?
- Выделение общего интерфейса: Вместо
Square extends Rectangleсоздайте интерфейсShapeс методомgetArea() - Композиция: Если вам нужен функционал другого класса, не наследуйте его — сделайте его полем
// Хорошо: композиция вместо наследования
public class ReadOnlyList<T> implements Iterable<T> {
private final List<T> list;
public ReadOnlyList(List<T> list) { this.list = list; }
// Только методы чтения...
}
🟡 Middle Level
Коварные примеры нарушения
1. Проблема “Ленивого” наследника (The Refusal of Bequest)
Когда подкласс переопределяет метод родителя и выбрасывает исключение или ничего не делает.
- Реальный пример из Java:
java.util.Dateиjava.sql.Date - Суть:
java.sql.Date(наследник) “отключает” работу с временем (часы, минуты, секунды), хотя базовый классjava.util.Dateих поддерживает. Если код ожидает точность до миллисекунд и получаетsql.Date, он сломается. - Вызов с java.sql.Date бросит IllegalArgumentException!
- getHours() в java.sql.Date не поддерживается (throws exception), хотя родительский Date этот метод поддерживает.
2. Нарушение инварианта (The Account Example)
public class Account {
protected double balance;
public void withdraw(double amount) {
this.balance -= amount;
}
}
public class SavingsAccount extends Account {
@Override
public void withdraw(double amount) {
if (amount > balance) throw new RuntimeException("No overdraft!"); // Усиление предусловия
super.withdraw(amount);
}
}
Почему это нарушение?
Если клиентский код написан для базового Account, он может полагаться на то, что баланс может уходить в минус (овердрафт). Подстановка SavingsAccount сломает логику клиента, который не ожидает исключения.
Не любой
if— нарушение. Нарушение — когда родительский контракт разрешает действие, а наследник — запрещает. Если оба класса согласованы в контракте (оба запрещают овердрафт) — нарушения LSP нет.
3. Скрытая порча состояния (Side Effects)
Классический пример с Прямоугольником и Квадратом.
- Клиент:
rect.setWidth(10); rect.setHeight(20); - Для Прямоугольника: площадь 200
- Для Квадрата: площадь 400 (так как
setHeightпереписалwidth) Итог: Клиент в шоке, инвариант “изменение высоты не влияет на ширину” разрушен.
Почему мы это делаем? (Root Cause)
- Желание сэкономить: “Зачем писать поле
nameещё раз, если оно есть вUser?”. И мы наследуемAdminотUser, хотя у них разные жизненные циклы. - Неверная иерархия: Мы моделируем мир “как он есть”, а не “как он используется”. В биологии страус — птица, в ООП
Ostrichне может наследоватьFlyingBird.
Как распознать нарушение
Признаки:
instanceofпроверки: Если вы проверяете тип, чтобы вызвать специфичную логикуUnsupportedOperationException: Метод “не поддерживается” в наследнике- Пустые override: Метод переопределён пустой реализацией
- Разное поведение тестов: Тесты базового класса падают для наследника
Как исправить
1. Выделение общего интерфейса
// Вместо Square extends Rectangle:
public interface Shape {
int getArea();
}
public class Rectangle implements Shape {
private int width, height;
// setter-ы работают независимо
@Override
public int getArea() { return width * height; }
}
public class Square implements Shape {
private int side;
@Override
public int getArea() { return side * side; }
}
2. Композиция (Delegation)
// ХОРОШО: Использование композиции вместо кривого наследования
public class ReadOnlyList<T> implements Iterable<T> {
private final List<T> list; // Делегат
public ReadOnlyList(List<T> list) { this.list = list; }
@Override
public Iterator<T> iterator() { return list.iterator(); }
public int size() { return list.size(); }
// Только методы чтения — нет add/remove
}
3. Разделение иерархии
// Вместо Penguin extends Bird:
public interface Bird {
void sing();
}
public interface FlyingBird extends Bird {
void fly();
}
public class Sparrow implements FlyingBird {
@Override public void sing() { /* ... */ }
@Override public void fly() { /* ... */ }
}
public class Penguin implements Bird {
@Override public void sing() { /* ... */ }
// fly() отсутствует — и это правильно
}
🔴 Senior Level
Internal Implementation и Архитектура
На уровне Senior важно понимать: нарушение LSP — это не просто “баг в коде”. Это нарушение контракта, которое может проявиться только в рантайме, в продакшене, при определённых условиях.
LSP — это про Design by Contract: подкласс может только ослаблять требования к входу и усиливать гарантии на выходе.
| Constraint | Правило | Нарушение |
|---|---|---|
| Preconditions | Не могут быть усилены | SavingsAccount требует amount <= balance |
| Postconditions | Не могут быть ослаблены | FastSort возвращает частично отсортированный массив |
| Invariants | Должны сохраняться | Square меняет width при setHeight |
| History | Состояние не меняется непредсказуемо | CachingList возвращает stale данные |
Реальные примеры из JDK
java.util.Date и java.sql.Date
// java.util.Date — хранит время с точностью до миллисекунд
// java.sql.Date — наследник, но "отключает" время (hours, minutes, seconds)
public class java.sql.Date extends java.util.Date {
// Многие методы переопределены и бросают IllegalArgumentException
@Override
public int getHours() {
throw new IllegalArgumentException();
}
}
Это одно из самых известных нарушений LSP в Java Standard Library.
Collections.unmodifiableList()
List<String> modifiable = new ArrayList<>();
List<String> unmodifiable = Collections.unmodifiableList(modifiable);
// unmodifiable реализован как обёртка над List,
// но add() бросает UnsupportedOperationException
unmodifiable.add("test"); // BOOM!
Последствия для Highload и Тестирования
- Broken Unit Tests: Тесты, написанные для базового класса, начинают “мигать” или падать при запуске с объектом-наследником. Это верный признак нарушения LSP
- Heisenbugs: Баги проявляются только в определённых условиях, когда в систему “просачивается” объект неправильного типа через DI или фабрику. Heisenbug — баг, который проявляется только при определённых условиях (определённый тип объекта в DI, специфичная нагрузка) и его сложно воспроизвести. Назван в честь принципа неопределённости Гейзенберга.
- Regression Testing: Каждый новый подкласс требует полного прогона тестов базового типа
Архитектурные Trade-offs
Наследование (нарушает LSP):
- ✅ Плюсы: Переиспользование кода, минимум boilerplate
- ❌ Минусы: Жёсткий контракт, нарушение LSP, хрупкость
Композиция (соблюдает LSP):
- ✅ Плюсы: Полная свобода поведения, слабая связанность
- ❌ Минусы: Больше boilerplate, нужно делегировать методы
Интерфейсы (соблюдает LSP):
- ✅ Плюсы: Чистый контракт, минимум обязательств
- ❌ Минусы: Нет переиспользования реализации
Edge Cases
- Template Method с “опциональными” шагами: Базовый класс определяет алгоритм, подкласс может пропустить шаг
- Проблема: Пропуск шага может нарушить инвариант алгоритма
- Решение: Chain of Responsibility или Decorator вместо Template Method
- Abstract Classes с default реализацией “по умолчанию”: Метод делает “ничего” в базовом классе
- Проблема: Наследники могут забыть переопределить — тихое нарушение контракта
- Решение: Абстрактные методы без реализации (forced override)
- Proxies и Dynamic Proxies: Динамически созданные объекты
- Проблема: Могут не воспроизводить все постусловия оригинала
- Решение: Contract Testing для прокси
Производительность
- Virtual dispatch + LSP: Когда JIT уверен, что все наследники соблюдают LSP, он может агрессивнее делать devirtualization и inlining
- LSP violation cost: Если наследник нарушает контракт, JIT-оптимизации могут дать некорректный результат (теоретически, на практике JVM делает деоптимизацию)
- Branch prediction:
instanceofпроверки для обхода LSP violation разрушают branch prediction
Production Experience
Реальный сценарий из продакшена:
В e-commerce проекте BaseOrderProcessor имел метод calculateTax(Order). Наследник InternationalOrderProcessor переопределил его и применил другую формулу. Проблема: базовый класс также вызывал calculateTax внутри processOrder, и разработчики InternationalOrderProcessor не учли, что calculateTax вызывается дважды — для товара и для доставки.
Результат: Неправильный расчёт налогов для международных заказов. Убытки — $50K за месяц до обнаружения.
Решение: Вынесли TaxCalculator в отдельный компонент (композиция) вместо наследования логики расчёта.
Monitoring и диагностика
Как обнаружить нарушение LSP:
- Contract Tests:
// Общий тест для всех реализаций Shape public abstract class ShapeContractTest<T extends Shape> { abstract T createShape(); @Test void areaIsAlwaysNonNegative() { assertThat(createShape().getArea()).isGreaterThanOrEqualTo(0); } } - Метрики:
- Количество
instanceofпроверок - Количество
UnsupportedOperationException - Количество пустых override-методов
- Количество
- Инструменты:
- Pitest (Mutation Testing): проверяет, что тесты ловят изменения поведения
- ArchUnit: архитектурные тесты на наследование
Best Practices для Highload
- Sealed Interfaces (Java 17+): Ограничьте иерархию — компилятор проверит exhaustiveness
- Immutability: Неизменяемые объекты проще сделать LSP-совместимыми
- Contract Testing: Пишите тесты на контракты базового типа и запускайте для всех наследников
- Если вы используете
if (obj instanceof Subclass)— вы уже нарушили LSP
Резюме для Senior
- Если вы используете
if (obj instanceof Subclass), вы уже нарушили LSP - Наследование — это самая сильная связь в ООП. Используйте его только тогда, когда наследник полностью заменяет родителя во всех сценариях
- Помните про Design by Contract: подкласс может только ослаблять требования к входу и усиливать гарантии на выходе
- Composition over Inheritance — предпочтительный подход для Senior-разработчика
🎯 Шпаргалка для интервью
Обязательно знать:
- “Ленивый” наследник — бросает
UnsupportedOperationExceptionили делает метод пустым - Square extends Rectangle — побочные эффекты:
setHeightменяетwidth, площадь 400 вместо 200 java.sql.Dateнаследуетjava.util.Date, но бросаетIllegalArgumentExceptionвgetHours()— нарушение LSP в JDKCollections.unmodifiableList()бросаетUnsupportedOperationExceptionприadd()— нарушение LSPinstanceofдля обхода разных поведений = LSP уже нарушен- Решение: композиция (Delegation) или разделение иерархии через интерфейсы
Частые уточняющие вопросы:
- Почему Square не может быть наследником Rectangle? — Потому что меняет поведение setter-ов, нарушая инвариант родителя
- Что такое Heisenbug в контексте LSP? — Баг, проявляющийся только при определённом типе объекта в DI, сложно воспроизвести
- Как тестировать LSP? — Contract Testing: общие тесты для базового типа, запуск для всех реализаций
- Реальные последствия нарушения? — В e-commerce: неправильный расчёт налогов, убытки $50K/месяц
Красные флаги (НЕ говорить):
- “Не любой if — нарушение LSP” (правильно, но лучше не давать повода для вопросов)
- “Наследование — это нормально, если у классов общие поля” (игнорирование behavioral subtyping)
- “LSP нарушается только при исключениях” (нет — побочные эффекты тоже нарушение)
Связанные темы:
- [[5. Что такое принцип Liskov Substitution]]
- [[10. Что такое композиция и наследование]]
- [[11. В каких случаях лучше использовать композицию вместо наследования]]
- [[22. Какие антипаттерны противоречат SOLID принципам]]