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

Что такое принцип Liskov Substitution?

Проще говоря: если класс B наследуется от класса A, то везде, где используется A, должен работать и B — без сюрпризов.

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

🟢 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:

  1. Предусловия (Preconditions) не могут быть усилены: Подкласс не может требовать больше, чем родитель. (Если родитель принимает любое число, наследник не может требовать только положительные)
  2. Постусловия (Postconditions) не могут быть ослаблены: Подкласс должен гарантировать как минимум тот же результат, что и родитель
  3. Инварианты (Invariants) должны сохраняться: Состояние объекта, которое было истинным для родителя, должно оставаться истинным и для наследника
  4. История (History Constraint): Подкласс не должен изменять состояние, которое в родителе считалось неизменяемым

Как выявить нарушение LSP

Признаки нарушения:

  1. instanceof в коде: Если вы проверяете тип объекта, чтобы вызвать специфичный метод — иерархия спроектирована неверно
  2. Пустые методы: Наследник переопределяет метод родителя пустой реализацией, потому что “ему это не нужно”
  3. Бросание неожиданных исключений: Как в примере с UnsupportedOperationException
  4. Документация “не поддерживает”: В 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; }
}

Типичные ошибки

  1. Ошибка: Наследование ради переиспользования кода (“у них же есть общие поля!”) Решение: Используйте композицию вместо наследования

  2. Ошибка: java.util.Collections.unmodifiableList() бросает UnsupportedOperationException при add() Решение: Это известное нарушение LSP. Используйте ReadOnlyList wrapper с интерфейсом только для чтения

  3. Ошибка: Модификация инварианта родителя Решение: Чётко документируйте инварианты каждого класса

Когда 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 защищает нас от ситуации, когда изменение внутренней логики наследника ломает логику, на которую полагается клиент.

  • Классический пример: ArrayList vs Collections.unmodifiableList()
    • unmodifiableList является обёрткой над List, но бросает UnsupportedOperationException при попытке add()
    • Это нарушение LSP, так как клиент, ожидающий List, не готов к такому поведению

Архитектурные Trade-offs

Строгое соблюдение LSP:

  • ✅ Плюсы: Предсказуемое поведение, надежное наследование, минимум runtime-ошибок
  • ❌ Минусы: Ограничивает переиспользование кода, требует более тщательного проектирования

Композиция вместо наследования:

  • ✅ Плюсы: Полная свобода поведения, нет обязательств перед контрактом родителя
  • ❌ Минусы: Больше boilerplate-кода, нужно делегировать методы

Edge Cases

  1. Template Method Pattern: Базовый класс определяет алгоритм, подклассы — шаги. Нарушает ли это LSP?
    • Ответ: Нет, если подклассы не меняют инварианты алгоритма. Но если шаг может “сломать” алгоритм — это нарушение
  2. Decorators: Обёртки добавляют поведение. Нарушают ли LSP?
    • Ответ: Нет, если они делегируют все методы базовому объекту и не меняют его инварианты
  3. 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 в коде:

  1. Code Review признаки:
    • Если instanceof используется для обхода разных поведений вместо полиморфизма — это нарушение LSP. Pattern matching с sealed types — легитимное исключение.
    • Пустые override-методы
    • throw new UnsupportedOperationException(...)
  2. Unit Test признаки:
    • Тесты для базового класса падают при запуске с наследником
    • Разное поведение одного метода для разных подклассов
  3. Инструменты:
    • 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 в наследнике — яркий признак нарушения LSP
  • instanceof в коде для обхода разных поведений = 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]]