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

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

Нарушение принципа подстановки Лисков (LSP) почти всегда возникает из-за попытки сэкономить строки кода через наследование классов, у которых общее происхождение в реальном мире...


🟢 Junior Level

Нарушение принципа подстановки Лисков (LSP) почти всегда возникает из-за попытки сэкономить строки кода через наследование классов, у которых общее происхождение в реальном мире, но принципиально разное поведение в программе.

Пример 1. «Отказ от наследства» (Refusal of Bequest)

Подкласс переопределяет метод базового класса и бросает исключение, сообщая, что данная операция не поддерживается:

public class Bird {
    public void fly() {
        System.out.println("Лечу в небе!");
    }
}

public class Penguin extends Bird {
    @Override
    public void fly() {
        // Нарушение LSP: выбрасываем ошибку вместо выполнения контракта
        throw new UnsupportedOperationException("Пингвины плавают, но не летают!");
    }
}

// Клиентский код:
public class ZooService {
    public void releaseBirds(List<Bird> birds) {
        for (Bird bird : birds) {
            bird.fly(); // Внезапное падение на пингвине!
        }
    }
}

Пример 2. Прямоугольник и Квадрат (Скрытая порча инварианта)

Геометрически квадрат — частный случай прямоугольника. Но в коде с мутацией состояния это ведет к катастрофе:

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;
    }
}

Где ломается клиент:

public void verifyArea(Rectangle r) {
    r.setWidth(5);
    r.setHeight(4);
    // Клиент ожидает 5 * 4 = 20.
    // Если передан Square: ширина стала 4, площадь = 16!
    assert r.getArea() == 20 : "LSP Violation! Got area: " + r.getArea();
}

Как исправить на базовом уровне

  1. Не наследуйте ради полей: если у классов есть одинаковые поля (width, height), но разное поведение — используйте общий интерфейс Shape без методов-мутаторов (setWidth/setHeight).
  2. Композиция вместо наследования: если объекту требуется логика другого класса, сделайте его полем и вызывайте через делегирование.

🟡 Middle Level

4 типа контрактных нарушений LSP

В рамках методологии Design by Contract (DbC) нарушение LSP классифицируется по 4 сценариям:

1. Усиление предусловий (Strengthening Preconditions)

Подкласс требует от вызывающего кода более строгих условий, чем родитель:

public class Account {
    protected BigDecimal balance;

    // Контракт: разрешен овердрафт (снятие любой положительной суммы)
    public void withdraw(BigDecimal amount) {
        if (amount.compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("Amount must be positive");
        }
        balance = balance.subtract(amount);
    }
}

public class DebitAccount extends Account {
    @Override
    public void withdraw(BigDecimal amount) {
        // Нарушение: подкласс ужесточил предусловие, запретив овердрафт!
        if (amount.compareTo(balance) > 0) {
            throw new IllegalStateException("Overdraft not allowed for debit account!");
        }
        super.withdraw(amount);
    }
}

Последствие: Клиентский сервис транзакций, рассчитанный на Account, неожиданно падает с IllegalStateException.


2. Ослабление постусловий (Weakening Postconditions)

Подкласс дает меньше гарантий относительно результата своей работы:

public class UserRepository {
    // Контракт базового типа: метод НИКОГДА не возвращает null (гарантирует непустой список)
    public List<User> findAllActive() {
        return database.queryActiveUsers(); // возвращает List, возможно пустой: Collections.emptyList()
    }
}

public class CachedUserRepository extends UserRepository {
    @Override
    public List<User> findAllActive() {
        // Нарушение: при промахе кэша возвращает null вместо пустого списка!
        UserCacheEntry entry = cache.get("active");
        return (entry != null) ? entry.getUsers() : null;
    }
}

Последствие: Клиент получает NullPointerException на repo.findAllActive().size().


3. Нарушение инвариантов (Invariants Violation)

Инвариант — свойство объекта, истинное на протяжении всего его жизненного цикла:

public class PositiveCounter {
    protected int count = 0;

    // Инвариант класса: count >= 0 ВСЕГДА
    public void increment() { count++; }
    public int getCount() { return count; }
}

public class ResetCounter extends PositiveCounter {
    // Нарушение: метод позволяет установить отрицательное значение, ломая инвариант
    public void forceSet(int value) {
        this.count = value; // Можно передать -50
    }
}

4. Нарушение ограничения истории (History Constraint)

Подкласс допускает мутацию состояния, которое базовый класс объявлял неизменяемым:

public class ImmutableMoney {
    private final BigDecimal amount;
    private final Currency currency;

    public ImmutableMoney(BigDecimal amount, Currency currency) {
        this.amount = amount;
        this.currency = currency;
    }
    public BigDecimal getAmount() { return amount; }
}

public class MutableMoney extends ImmutableMoney {
    private BigDecimal mutableAmount;

    // Нарушение: клиент передает MutableMoney в кеш, рассчитывая на иммутабельность,
    // а другой поток меняет значение на лету!
    public void setAmount(BigDecimal newAmount) {
        this.mutableAmount = newAmount;
    }
}

🔴 Senior Level

Глубокие архитектурные нарушения LSP в реальных системах

1. Проблема equals() при расширении класса (Джошуа Блох, Effective Java)

Попытка добавить новое значащее поле в подкласс неизбежно ломает либо рефлексивность, либо симметричность, либо транзитивность контракта equals():

public class Point {
    private final int x, y;
    public Point(int x, int y) { this.x = x; this.y = y; }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof Point p)) return false;
        return p.x == x && p.y == y;
    }
}

public class ColorPoint extends Point {
    private final Color color;
    public ColorPoint(int x, int y, Color color) {
        super(x, y);
        this.color = color;
    }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof ColorPoint cp)) return false;
        return super.equals(o) && cp.color == color;
    }
}

Почему нарушается контракт и LSP:

Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, Color.RED);

p.equals(cp);  // true (Point сравнивает только координаты x, y)
cp.equals(p);  // false (ColorPoint требует, чтобы аргумент был ColorPoint)
// Нарушена симметричность! Подстановка cp вместо p в HashSet ломает поиск.

Вывод Джошуа Блоха: Невозможно расширить инстанцируемый класс новым полем значения и сохранить контракт equals, если не использовать композицию вместо наследования.


2. Hibernate/JPA Proxy и нарушение LSP

В JPA сущностях использование this.getClass() == o.getClass() в методе equals() ломает LSP при работе с Lazy Loading:

  • Hibernate создает динамический CGLIB/ByteBuddy-прокси User$HibernateProxy$xyz extends User.
  • При вызове user.equals(proxy) условие getClass() == o.getClass() вернет false, хотя обе сущности представляют одну строку в БД с одинаковым первичным ключом.
  • Решение: Проверять if (!(o instanceof User other)) return false; и обращаться к полям через геттеры (getId()), так как прямые обращения к полям other.id в прокси вернут null до инициализации.

3. Нарушение LSP в JDK: java.util.Stack

Класс Stack наследует Vector:

Stack<String> stack = new Stack<>();
stack.push("A");
stack.push("B");

// Метод Vector позволяет вставить элемент в произвольную позицию:
stack.add(0, "C"); // Нарушен инвариант LIFO структуры данных!

Правильный дизайн в современной Java — интерфейс Deque (ArrayDeque), инкапсулирующий операции со стеком и очередью без утечки посторонних методов списка.


4 Tricky Questions

1. Почему Джошуа Блох утверждает, что невозможно расширить инстанцируемый класс новым полем и сохранить контракт equals()?

Ответ: Если подкласс переопределяет equals(), учитывая новое поле, нарушается симметричность: superPoint.equals(colorPoint) возвращает true, а colorPoint.equals(superPoint) возвращает false. Если попытаться решить это “смешанным” сравнением (игнорировать цвет, если передан базовый класс), ломается транзитивность:

  • ColorPoint cp1 = new ColorPoint(1, 2, RED);
  • Point p = new Point(1, 2);
  • ColorPoint cp2 = new ColorPoint(1, 2, BLUE); cp1.equals(p) (true) и p.equals(cp2) (true), но cp1.equals(cp2) (false!). Единственный способ сохранить LSP и контракт equals — заменить наследование композицией (ColorPoint хранит Point и Color внутри себя).

2. Является ли сужение возвращаемого диапазона чисел в подклассе нарушением LSP?

Ответ: Нет, это усиление постусловия (Strengthening Postconditions), что полностью разрешено правилами Design by Contract. Если базовый метод int calculateScore() обещает вернуть любое число от -100 до 100, а подкласс StrictScorer возвращает только диапазон от 0 до 100, любой клиент, написанный для диапазона предка, успешно обработает результат подкласса. Нарушением было бы ослабление постусловия (например, возврат чисел вне ожидаемого диапазона или возврат null).

3. Как паттерн Decorator может нарушить LSP в многопоточном коде?

Ответ: Если базовый компонент является потокобезопасным (ThreadSafeCollection), а класс-декоратор оборачивает его и добавляет локальное состояние (например, счетчик обращений private int accessCount++) без надлежащей синхронизации (volatile, AtomicInteger, synchronized), то полученный объект перестает удовлетворять инварианту потокобезопасности базового класса. Вызывающий код, подставляя такой декоратор в конкурентную среду, столкнется с состоянием гонки (Race Condition).

4. Допустимо ли в подклассе изменить временную сложность алгоритма с $O(1)$ на $O(N)$?

Ответ: С формальной синтаксической точки зрения Java программа скомпилируется и отработает. Однако с точки зрения нефункционального контракта (Non-functional Requirements) и архитектурного LSP это грубое нарушение. Если вызывающий сервис обрабатывает пакет из $10^6$ элементов в секунду в предположении, что метод lookup() выполняется за $O(1)$, подстановка реализации со сложностью $O(N)$ приведет к деградации производительности до $O(N^2)$, исчерпанию пула потоков Tomcat и падению всей системы по тайм-ауту (Denial of Service).


🎯 Шпаргалка для интервью

Главные примеры нарушений (быстрый ответ):

  1. Пингвины и птицы: Penguin.fly() бросает UnsupportedOperationException (отказ от контракта).
  2. Квадрат и Прямоугольник: Square.setWidth() неявно меняет height, ломая инвариант независимости сторон.
  3. Банковский овердрафт: DebitAccount.withdraw() запрещает отрицательный баланс там, где базовый Account его разрешал (усиление предусловий).
  4. Коллекции JDK: Collections.unmodifiableList().add() выбрасывает исключение в рантайме.

Чего избегать (Red Flags):

  • ❌ Утверждать, что наследование допустимо, если совпадает большинство полей (наследование связывает поведение, а не структуру хранения).
  • ❌ Думать, что нарушение LSP — это только выброс исключения (тихое искажение вычислений или побочные эффекты еще опаснее).

Связанные темы: