Що таке принцип підстановки Лісков (LSP)?
Принцип підстановки Лісков (LSP) каже: будь-який об'єкт підкласу має замінювати об'єкт базового класу так, щоб програма продовжувала давати коректний результат.
Теорія
TL;DR
- Підтип має дотримуватись поведінкового контракту базового класу, а не тільки збігатись за сигнатурами методів
- Аналогія: якщо твоя "качка" плаває та крякає нормально, але виклик
fly()змушує її поводитись як курча - це порушення LSP - Три формальні правила: підтип не може посилювати передумови (вимагати більше), послаблювати постумови (гарантувати менше), і зобов'язаний зберігати інваріанти
- Практичний тест: заміни кожен екземпляр базового класу підтипом і запусти тести. Якщо щось зламалось - переробляй
Швидкий приклад
class Rectangle {
constructor() { this.width = 0; this.height = 0; }
setWidth(w) { this.width = w; }
setHeight(h) { this.height = h; }
getArea() { return this.width * this.height; }
}
class Square extends Rectangle {
setWidth(w) { this.width = this.height = w; } // зв'язує обидва виміри
setHeight(h) { this.width = this.height = h; }
}
function useShape(shape) {
shape.setWidth(5);
shape.setHeight(4);
return shape.getArea(); // клієнт очікує 20
}
console.log(useShape(new Rectangle())); // 20 ✅
console.log(useShape(new Square())); // 25 ❌ порушення LSPRectangle має один ключовий інваріант: setWidth не чіпає height, і навпаки. Square порушує це, зв'язуючи їх. Помилки немає. Код повертає 25 замість 20. Саме тому LSP-баги такі болючі в пошуку.
Чому відношення "є-різновидом" недостатньо
Геометрично квадрат є прямокутником. Але в коді Square не є взаємозамінним з Rectangle. Саме цей розрив і формалізує LSP.
"Є-різновидом" описує реальний світ. LSP описує поведінкову сумісність під час виконання. Клас може задовольняти семантичне відношення і водночас порушувати поведінковий контракт.
Формальне визначення Барбари Ліскової (1987): якщо S є підтипом T, то об'єкти типу T у програмі можна замінювати об'єктами типу S без зміни коректності програми. З цього випливають три правила.
Передумови (те, що виклик зобов'язаний надати) можуть тільки залишатись такими самими або послаблюватись у підтипі. Постумови (те, що метод гарантує після виконання) можуть тільки залишатись або посилюватись. Інваріанти (твердження про об'єкт, що завжди мають виконуватись) зобов'язані зберігатись.
Коли перевіряти на порушення LSP
- Щоразу, коли пишеш
extendsдля класу, у якого вже є клієнти - Коли функція приймає базовий тип, а ти додаєш новий підтип
- При проектуванні бібліотеки: якщо відкриваєш базовий тип назовні, всі реалізації мають замінювати одна одну
- Тест при рефакторингу: замінити кожен базовий екземпляр підтипом і запустити весь test suite
Як TypeScript з цим працює
TypeScript перевіряє сигнатури методів під час компіляції. Він відкине підтип, який звужує тип повернення або приймає менше параметрів, ніж база. Але поведінкові порушення інваріантів він не побачить.
Rectangle і Square мають однакові сигнатури: setWidth(w: number), setHeight(h: number), getArea(): number. TypeScript бачить два структурно сумісних типи і мовчить. Зламаний інваріант невидимий для компілятора. Він проявляється лише в рантаймі як неправильне число.
У звичайному JavaScript немає жодних компіляційних перевірок. Порушення LSP виходять на поверхню як баги або тихі хибні результати. Тести - єдиний захист. Я бачив цей самий патерн Rectangle/Square в продакшн-дашборді, де площі рендерились неправильно кілька місяців, і жоден інструмент не попереджав.
Типові помилки
Помилка 1: Override, що мутує непов'язаний стан
class BankAccount {
balance = 0;
deposit(amount: number) { this.balance += amount; }
}
class SavingsAccount extends BankAccount {
deposit(amount: number) {
super.deposit(amount);
this.feeAccount.charge(1); // мутує зовнішній стан, якого клієнти не очікують
}
}Клієнти очікують, що deposit впливає тільки на balance. Прихована транзакція до feeAccount порушує постумову. Рішення: винести логіку комісії в декоратор або окремий сервісний метод.
Помилка 2: Кидати виняток там, де база обробляла тихо
class Parser {
parse(input: string | null) { return input || ''; }
}
class StrictParser extends Parser {
parse(input: string | null) {
if (!input) throw new Error('Input is required');
return input;
}
}База гарантує "завжди повертає рядок." StrictParser може кинути виняток. Будь-який виклик без try/catch зламається. Якщо потрібна суворість, зроби базу теж суворою або використай тип Result<string, Error>.
Помилка 3: Посилення передумов у підтипі
class BaseNotifier {
notify(payload: { id: string }): void {
console.log(`Notifying ${payload.id}`);
}
}
class EmailNotifier extends BaseNotifier {
notify(payload: { id: string }): void {
if (!('email' in payload)) throw new Error('Missing email');
// ...
}
}Контракт бази: приймати будь-що з id. EmailNotifier додатково вимагає email. Будь-хто, хто передає { id: '1' }, отримає рантаймову помилку. Рішення: перемістити вимогу до типового параметра, щоб TypeScript перевіряв це під час компіляції.
Помилка 4: Зміна типу винятку
Якщо база кидає ValidationError для невалідного вводу, а підтип кидає TypeError, блоки catch, написані для ValidationError, не перехоплять його. Підтип зобов'язаний кидати винятки, сумісні з контрактом бази.
Де зустрічається на практиці
- React: підкласи
React.Component<Props>мають приймати всі задекларовані props. Дочірній компонент, що вимагає додатковий обов'язковий prop, порушує LSP для кожного батьківського компонента. - Express: підтипи
RequestHandlerне можуть додавати передумову "обов'язково наявнийreq.user" без того, щоб зламати всі route handlers, які його не встановлюють. - Node.js streams: власні реалізації
Readableмають дотримуватись контрактуread(size?)та повертатиBufferабоnull. Будь-що інше ламає всіх споживачів потоку. - Redux: редюсери обробляють дії як
AnyAction. Редюсер, який кидає виняток на дії без певного поля, порушує базовий контракт. - Альтернатива: якщо LSP важко витримати через наслідування, композиція або інтерфейси зазвичай дають чистіше рішення.
Питання на співбесіді
Q: Чому Square, що розширює Rectangle, - це порушення LSP формально?
A: Rectangle має інваріант: setWidth(5) не змінює height. Square встановлює обидва в 5, порушуючи цей інваріант. Клієнти, що покладаються на незалежність розмірів, отримують неправильний результат без жодної помилки.
Q: Чим LSP відрізняється від відношення "є-різновидом"?
A: "Є-різновидом" - семантика: квадрат є прямокутником у геометрії. LSP - поведінка: чи може Square замінити Rectangle в будь-якому контексті без зміни результату? В цьому прикладі - ні.
Q: TypeScript перевіряє типи структурно. Чи означає це, що LSP гарантований?
A: Ні. TypeScript перевіряє сигнатури, а не поведінку. Square і Rectangle мають однакові сигнатури, тому компілятор задоволений. Порушення інваріанту проявляється в рантаймі. Тести - це те, що реально забезпечує LSP.
Q: Наведи приклад передумови та постумови.
A: Базовий метод divide(a: number, b: number = 1) має передумову: b можна не передавати. Підтип, що вимагає b завжди явно, посилює передумову - це порушення LSP.
Q: У мікросервісній архітектурі зі спільними proto-контрактами - як забезпечити LSP між сервісами?
A: Визначити BaseMessage proto-схему як контракт. Всі сервіси валідують вхідні payload проти неї перед обробкою. Використовувати Pact для consumer-driven contract testing: споживачі публікують очікування, провайдери запускають їх як тести на кожному деплої.
Q: Який зв'язок між LSP та Open-Closed Principle?
A: Open-Closed Principle каже: відкритий для розширення, закритий для модифікації. LSP - це те, що робить безпечне розширення можливим. Якщо підтипи ламають існуючі контракти, гарантія OCP розвалюється.
Приклади
Базовий: Rectangle і Square - правильне рішення
// Замість наслідування - окремий клас зі своїм контрактом
class Rectangle {
constructor() { this.width = 0; this.height = 0; }
setWidth(w) { this.width = w; }
setHeight(h) { this.height = h; }
getArea() { return this.width * this.height; }
}
class Square {
constructor() { this.side = 0; }
setSide(s) { this.side = s; }
getArea() { return this.side * this.side; }
}
const rect = new Rectangle();
rect.setWidth(5);
rect.setHeight(4);
console.log(rect.getArea()); // 20 ✅
const sq = new Square();
sq.setSide(5);
console.log(sq.getArea()); // 25 ✅Прибрати наслідування - правильне рішення. Square не може дотримуватись контракту Rectangle, тому не повинен його розширювати. Геометрична схожість не означає поведінкову сумісність.
Середній рівень: Logger у Express middleware
interface Logger {
setLevel(level: string): void;
setTimestamp(enabled: boolean): void;
log(message: string): string;
}
class ConsoleLogger implements Logger {
private level = 'info';
private timestamp = true;
setLevel(l: string) { this.level = l; }
setTimestamp(t: boolean) { this.timestamp = t; }
log(msg: string): string {
const ts = this.timestamp ? new Date().toISOString() : '';
return `${ts} [${this.level}] ${msg}`;
}
}
class FileLogger implements Logger {
private level = 'info';
private timestamp = true;
setLevel(l: string) { this.level = l; }
setTimestamp(t: boolean) { this.timestamp = t; }
log(msg: string): string {
// Без прихованих мутацій: стабільний і передбачуваний результат
const ts = this.timestamp ? new Date().toISOString() : '';
return `${ts} [${this.level}] ${msg}`;
}
}
function createLogMiddleware(logger: Logger) {
return (req: any, res: any, next: () => void) => {
logger.setLevel('debug');
logger.setTimestamp(false);
console.log(logger.log(`${req.method} ${req.path}`));
next();
};
}
createLogMiddleware(new ConsoleLogger());
createLogMiddleware(new FileLogger()); // однакова поведінка, взаємозамінніОбидва логери дотримуються контракту Logger без прихованих побічних ефектів. Будь-який з них можна підставити в будь-який middleware без зміни формату виводу.
Просунутий рівень: Generic notifier з перевіркою передумов на рівні типів
// Базовий контракт: сповістити будь-який payload з id
class BaseNotifier<T extends { id: string }> {
notify(payload: T): void {
console.log(`Notifying ${payload.id}`);
}
}
// НЕПРАВИЛЬНО: перевірка передумови в рантаймі
class EmailNotifierBad<T extends { id: string }> extends BaseNotifier<T> {
notify(payload: T): void {
if (!('email' in payload)) {
throw new Error('email field is required'); // посилення передумови в рантаймі
}
console.log(`Sending to ${(payload as any).email}`);
}
}
// ПРАВИЛЬНО: вимога до 'email' в типовому параметрі
class EmailNotifierGood<T extends { id: string; email: string }> extends BaseNotifier<T> {
notify(payload: T): void {
console.log(`Sending to ${payload.email}`); // email гарантований типом T
}
}
function notifyAll<T extends { id: string }>(
notifier: BaseNotifier<T>,
items: T[]
): void {
items.forEach(item => notifier.notify(item));
}
const users = [{ id: '1', name: 'Alice' }];
// notifyAll(new EmailNotifierBad(), users); // TypeScript: ok, рантайм: crash ❌
// notifyAll(new EmailNotifierGood(), users); // TypeScript: помилка, 'email' відсутній ✅Правильна версія переміщує вимогу з рантайм-перевірки до типового параметра. TypeScript виловлює відсутній email до запуску коду. Ось як виглядає виконання LSP на рівні типів.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.