Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Що таке принцип підстановки Лісков (LSP)?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Принцип підстановки Лісков (LSP)** каже: будь-який підклас має замінювати базовий клас без того, щоб програма ламалась або давала неправильний результат. Підтип зобов'язаний дотримуватись тих самих передумов, постумов та інваріантів. ```javascript function useShape(shape) { shape.setWidth(5); shape.setHeight(4); return shape.getArea(); // очікується 20 } useShape(new Rectangle()); // 20 ✅ useShape(new Square()); // 25 ❌ інваріант порушено ``` **Головне:** TypeScript перевіряє сигнатури, а не поведінку. Тести - єдиний надійний захист від порушень LSP.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Принцип підстановки Лісков (LSP)** каже: будь-який об'єкт підкласу має замінювати об'єкт базового класу так, щоб програма продовжувала давати коректний результат. ## Теорія ### TL;DR - Підтип має дотримуватись поведінкового контракту базового класу, а не тільки збігатись за сигнатурами методів - Аналогія: якщо твоя "качка" плаває та крякає нормально, але виклик `fly()` змушує її поводитись як курча - це порушення LSP - Три формальні правила: підтип не може посилювати передумови (вимагати більше), послаблювати постумови (гарантувати менше), і зобов'язаний зберігати інваріанти - Практичний тест: заміни кожен екземпляр базового класу підтипом і запусти тести. Якщо щось зламалось - переробляй ### Швидкий приклад ```javascript 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 ❌ порушення LSP ``` `Rectangle` має один ключовий інваріант: `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, що мутує непов'язаний стан** ```typescript 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: Кидати виняток там, де база обробляла тихо** ```typescript 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: Посилення передумов у підтипі** ```typescript 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](/questions/open-closed-principle)? **A:** Open-Closed Principle каже: відкритий для розширення, закритий для модифікації. LSP - це те, що робить безпечне розширення можливим. Якщо підтипи ламають існуючі контракти, гарантія OCP розвалюється. ## Приклади ### Базовий: Rectangle і Square - правильне рішення ```javascript // Замість наслідування - окремий клас зі своїм контрактом 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 ```typescript 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 з перевіркою передумов на рівні типів ```typescript // Базовий контракт: сповістити будь-який 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 на рівні типів.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.