Вопрос 6 · Раздел 18

Приведите пример нарушения принципа Liskov Substitution

Нарушение LSP часто маскируется под "повторное использование кода". Мы пытаемся наследовать один класс от другого просто потому, что у них есть общие поля, игнорируя различия в...

Версии по языкам: English Russian Ukrainian

🟢 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 — баг!
}

Как исправить?

  1. Выделение общего интерфейса: Вместо Square extends Rectangle создайте интерфейс Shape с методом getArea()
  2. Композиция: Если вам нужен функционал другого класса, не наследуйте его — сделайте его полем
// Хорошо: композиция вместо наследования
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)

  1. Желание сэкономить: “Зачем писать поле name ещё раз, если оно есть в User?”. И мы наследуем Admin от User, хотя у них разные жизненные циклы.
  2. Неверная иерархия: Мы моделируем мир “как он есть”, а не “как он используется”. В биологии страус — птица, в ООП Ostrich не может наследовать FlyingBird.

Как распознать нарушение

Признаки:

  1. instanceof проверки: Если вы проверяете тип, чтобы вызвать специфичную логику
  2. UnsupportedOperationException: Метод “не поддерживается” в наследнике
  3. Пустые override: Метод переопределён пустой реализацией
  4. Разное поведение тестов: Тесты базового класса падают для наследника

Как исправить

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

  1. Template Method с “опциональными” шагами: Базовый класс определяет алгоритм, подкласс может пропустить шаг
    • Проблема: Пропуск шага может нарушить инвариант алгоритма
    • Решение: Chain of Responsibility или Decorator вместо Template Method
  2. Abstract Classes с default реализацией “по умолчанию”: Метод делает “ничего” в базовом классе
    • Проблема: Наследники могут забыть переопределить — тихое нарушение контракта
    • Решение: Абстрактные методы без реализации (forced override)
  3. 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:

  1. Contract Tests:
    // Общий тест для всех реализаций Shape
    public abstract class ShapeContractTest<T extends Shape> {
        abstract T createShape();
    
        @Test
        void areaIsAlwaysNonNegative() {
            assertThat(createShape().getArea()).isGreaterThanOrEqualTo(0);
        }
    }
    
  2. Метрики:
    • Количество instanceof проверок
    • Количество UnsupportedOperationException
    • Количество пустых override-методов
  3. Инструменты:
    • 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 в JDK
  • Collections.unmodifiableList() бросает UnsupportedOperationException при add() — нарушение LSP
  • instanceof для обхода разных поведений = 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 принципам]]