Что такое принцип Liskov Substitution
Если у вас есть базовый класс (или интерфейс) A и его наследник B, то любая часть программы, ожидающая объект типа A, должна без ошибок и непредсказуемых сюрпризов работать и с...
🟢 Junior Level
Принцип подстановки Лисков (Liskov Substitution Principle, LSP) — это третий принцип SOLID, сформулированный Барбарой Лисков в 1987 году:
“Объекты подкласса должны быть заменяемы объектами базового класса без нарушения корректности работы программы.”
Что это означает простыми словами
Если у вас есть базовый класс (или интерфейс) A и его наследник B, то любая часть программы, ожидающая объект типа A, должна без ошибок и непредсказуемых сюрпризов работать и с объектом типа B.
- Наследник не должен ломать поведение, к которому привык вызывающий код.
- Наследник не должен запрещать то, что разрешал родитель.
- Наследник не должен выбрасывать исключения на вызовы методов, объявленных в базовом типе.
Житейская аналогия
Электрическая розетка европейского стандарта (Schuko):
- Розетка ожидает вилку, удовлетворяющую стандарту (230 В, заземляющие усики, диаметр штырей 4.8 мм).
- Чайник, пылесос или ноутбук — это разные устройства («наследники» концепции штепсельного электроприбора).
- Если производитель выпустит «вилку-сюрприз», у которой штыри квадратные или при подключении которой выбивает пробки в щитке — нарушен контракт, прибор не может подменить стандартную вилку.
Пример нарушения LSP (Страусы и птицы)
// Базовый класс обещает: все птицы летают
public class Bird {
public void fly() {
System.out.println("Лечу в небесах!");
}
}
// Страус - биологически птица, но летать не умеет!
public class Ostrich extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("Страусы не умеют летать!");
}
}
// Клиентский код
public class SanctuaryService {
public void migrateAll(List<Bird> birds) {
for (Bird bird : birds) {
bird.fly(); // Внезапный крах в продакшене при передаче Ostrich!
}
}
}
Почему это нарушение: клиентский код доверял контракту Bird.fly(). Подстановка Ostrich привела к аварийному завершению программы.
Решение: Разделение абстракций по поведению
В ООП иерархия строится не по биологической или бытовой классификации («страус — это птица»), а по поведению (Behavioral Subtyping):
public interface Bird {
void eat();
}
public interface FlyingBird extends Bird {
void fly();
}
public class Sparrow implements FlyingBird {
@Override
public void eat() { System.out.println("Ем зерна"); }
@Override
public void fly() { System.out.println("Лечу!"); }
}
public class Ostrich implements Bird {
@Override
public void eat() { System.out.println("Ем траву"); }
// Метод fly() отсутствует, контракт не нарушен!
}
Клиентский код migrateAll принимает List<FlyingBird>, исключая передачу нелетающих птиц на уровне компиляции.
🟡 Middle Level
Design by Contract (Проектирование по контракту)
Формально принцип LSP базируется на теории Бертрана Мейера и Барбары Лисков Design by Contract (DbC). Любой метод базового типа задает контракт, состоящий из 4 ключевых правил:
| Правило контракта | Формулировка | Что запрещено подклассу |
|---|---|---|
| 1. Preconditions (Предусловия) | Требования к аргументам и состоянию системы до выполнения метода. | Нельзя усиливать в подклассе. Если базовый класс принимает любые int, подкласс не имеет права выбрасывать ошибку на отрицательные числа. |
| 2. Postconditions (Постусловия) | Гарантии результата и состояния системы после выполнения метода. | Нельзя ослаблять в подклассе. Если базовый метод гарантирует возврат неотрицательного числа или закрытие соединения, подкласс обязан это гарантировать. |
| 3. Invariants (Инварианты) | Условия состояния класса, которые остаются истинными всегда (до и после любого метода). | Должны сохраняться. Если инвариант Account: balance >= 0, никакой метод подкласса не может оставлять отрицательный баланс. |
| 4. History Constraint (Ограничение истории) | Состояния, считающиеся неизменяемыми в родителе, не должны мутироваться наследником. | Если базовый объект immutable (например, неизменяемая точка координат), подкласс не имеет права делать ее mutable. |
Классический антипаттерн: Квадрат и Прямоугольник
В школьной геометрии квадрат — это частный случай прямоугольника. Но в изменяемом (mutable) объектно-ориентированном коде наследование Square extends Rectangle грубо нарушает 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; // Нарушение: скрытое изменение ширины!
}
}
Тест, ломающий иллюзию подстановки:
public void resizeAndVerify(Rectangle r) {
r.setWidth(5);
r.setHeight(10);
// Для Rectangle: площадь 50.
// Для Square: r.setHeight(10) сделал ширину 10, площадь стала 100!
assert r.getArea() == 50 : "LSP Violation! Expected 50, but got " + r.getArea();
}
Вывод: Отношение «является» (IS-A) должно проверяться не на уровне статических свойств, а исключительно на уровне поведения мутаций.
Нарушения LSP в стандартной библиотеке Java (JDK)
Даже создатели Java допустили классические нарушения LSP в JDK:
java.util.Collections.unmodifiableList(list):- Возвращает реализацию
List, у которой методыadd(),remove()выбрасываютUnsupportedOperationException. - Клиент, принимающий
List<T>, рассчитывает на контракт изменяемого списка.
- Возвращает реализацию
java.sql.Date extends java.util.Date:java.sql.Dateпредставляет только дату без времени (hours, minutes, seconds).- Вызовы
date.getHours()выбрасываютIllegalArgumentException, нарушая контракт базовогоjava.util.Date.
java.util.Stack extends Vector:- Стек — это LIFO структура. Однако благодаря наследованию от
Vectorклиент может вызватьstack.add(2, element)и вставить элемент в середину стека!
- Стек — это LIFO структура. Однако благодаря наследованию от
🔴 Senior Level
Математическая формулировка Лисков и Винг (1994)
Барбара Лисков и Джаннет Винг в статье “A Behavioral Notion of Subtyping” сформулировали принцип через предикатную логику:
\[\forall x : T, \quad \phi(x) \implies \phi(y) \quad \text{где } y : S \text{ и } S \text{ является подтипом } T\]Пусть $\phi(x)$ — свойство, доказуемое относительно объектов $x$ типа $T$. Тогда $\phi(y)$ должно оставаться истинным для объектов $y$ типа $S$, где $S$ — подтип типа $T$.
Формализация через контракты:
- Предусловия: $Pre(T) \implies Pre(S)$ (Предусловие предка влечет за собой предусловие потомка, то есть потомок требует меньше или столько же).
- Постусловия: $Post(S) \implies Post(T)$ (Постусловие потомка влечет за собой постусловие предка, то есть потомок гарантирует больше или столько же).
- Инварианты: $Inv(S) \implies Inv(T)$.
Ковариантность и контрвариантность в Java
Как система типов языка поддерживает LSP:
- Ковариантность возвращаемых типов (Return Type Covariance):
- Метод подкласса может возвращать более специфичный подтип:
public class BaseService { public CharSequence getName() { return "base"; } } public class SpecificService extends BaseService { @Override public String getName() { return "specific"; } // String - потомок CharSequence. Валидно! } - Это согласуется с LSP: клиент ждал
CharSequence, а получилString, удовлетворяющий всем его методам.
- Метод подкласса может возвращать более специфичный подтип:
- Контрвариантность типов аргументов (Argument Contravariance):
- С точки зрения LSP подкласс имел бы право ослабить требования к аргументу: метод родителя принимает
String, а метод наследника принимаетObject. - В Java контрвариантность аргументов в переопределении методов не поддерживается: компилятор расценит это как перегрузку (overloading), а не переопределение (overriding). Однако контрвариантность поддерживается в дженериках через wildcards (
<? super T>).
- С точки зрения LSP подкласс имел бы право ослабить требования к аргументу: метод родителя принимает
Contract Testing: как гарантировать LSP в CI/CD
В архитектуре микросервисов и Enterprise-библиотек соблюдение LSP верифицируется через Contract Tests (тесты базового контракта):
// Базовый параметризованный тест контракта
public abstract class RepositoryContractTest<T extends Repository> {
protected abstract T createRepository();
@Test
void shouldSaveAndRetrieveEntity() {
T repo = createRepository();
Entity entity = new Entity("test-id");
repo.save(entity);
Optional<Entity> found = repo.findById("test-id");
assertTrue(found.isPresent());
assertEquals("test-id", found.get().id());
}
@Test
void shouldNotThrowExceptionWhenDeletingNonExistent() {
T repo = createRepository();
assertDoesNotThrow(() -> repo.deleteById("unknown-id"));
}
}
// Тест конкретной реализации PostgreSQL
class PostgresRepositoryTest extends RepositoryContractTest<PostgresRepository> {
@Override
protected PostgresRepository createRepository() {
return new PostgresRepository(testDatabasePool);
}
}
// Тест реализации InMemory кэша
class InMemoryRepositoryTest extends RepositoryContractTest<InMemoryRepository> {
@Override
protected InMemoryRepository createRepository() {
return new InMemoryRepository();
}
}
Если разработчик InMemoryRepository решит выбрасывать исключение при удалении несуществующего ключа, он уронит базовый контрактный тест и не пройдет CI pipeline.
4 Tricky Questions
1. Допустимо ли в подклассе выбросить более широкое или принципиально новое Checked-исключение при переопределении метода?
Ответ:
Нет. Компилятор Java строго запрещает объявлять в throws переопределенного метода Checked-исключения, не объявленные в базовом классе (или не являющиеся их наследниками). Это прямая защита LSP на уровне языка: если клиент ловит IOException, появление SQLException сломало бы логику обработки ошибок.
Однако компилятор разрешает выбрасывать любые RuntimeException (Unchecked). И именно бросание неожиданных RuntimeException (например, NullPointerException, UnsupportedOperationException, IllegalStateException) является частым нарушением LSP в продакшене.
2. Почему неизменяемость (Immutability) решает проблему “Квадрат-Прямоугольник”?
Ответ:
Проблема “Квадрат-Прямоугольник” возникает исключительно из-за мутаций состояния (setWidth / setHeight), которые нарушают инвариант независимости сторон прямоугольника.
Если сделать объекты неизменяемыми (record ImmutableRectangle(int width, int height) и record ImmutableSquare(int side)):
- Нет методов модификации по отдельности.
ImmutableSquareможет безопасно реализовывать интерфейсShapeили трансформироваться вImmutableRectangleбез риска скрытых побочных эффектов для других ссылок на этот объект.
3. Нарушает ли LSP паттерн Null Object (объект-заглушка вместо null)?
Ответ:
Если Null Object спроектирован корректно — он является образцовым примером соблюдения LSP! Вместо возврата null (вызывающего NullPointerException у клиента) Null Object реализует контракт интерфейса безопасным нейтральным поведением: возвращает пустые коллекции, выполняет no-op логику, удовлетворяет всем постусловиям базового типа.
Нарушением LSP он станет лишь в том случае, если клиенты базового класса рассчитывают на изменение состояния системы (например, запись в БД), а Null Object молча игнорирует это действие там, где клиент ожидал гарантии сохранения.
4. Является ли проверка if (obj instanceof SpecificClass) в коде сервиса доказательством нарушения LSP?
Ответ:
Практически всегда да (за исключением Pattern Matching по закрытым sealed-иерархиям). Если вызывающий код проверяет конкретный тип объекта для выполнения специфичных действий или обхода дефектов подкласса, это означает, что абстракция базового типа неполна или подкласс не реализует единый ожидаемый контракт. Правильное решение — инкапсулировать вариативное поведение в метод базового интерфейса или использовать паттерн Visitor.
🎯 Шпаргалка для интервью
Главные тезисы (30 секунд на ответ):
- Суть: Подкласс должен корректно заменять базовый класс везде, где ожидается базовый тип, не ломая поведение клиента.
- Правило контракта: Предусловия нельзя усиливать, постусловия нельзя ослаблять, инварианты предка обязаны сохраняться.
- Главные маркеры нарушения:
UnsupportedOperationExceptionв переопределенных методах; пустые методы-заглушки; проверкиinstanceofперед вызовом специфичной логики. - Классические примеры: Наследование
Square extends Rectangle(побочные эффекты сторон);Collections.unmodifiableList(падение наadd()).
Чего избегать (Red Flags):
- ❌ Утверждать, что если код скомпилировался — LSP соблюден (LSP — это семантический поведенческий принцип, компилятор не видит побочных эффектов и бизнес-инвариантов).
- ❌ Оправдывать наследование родством терминов из реального мира (ООП моделирует поведение, а не биологические или геометрические таксономии).
Связанные темы: