Підрозділ 20.4
LSP — Принцип підстановки Ліскова
20.4. LSP — Принцип підстановки Ліскова Liskov Substitution Principle LSP сформулювала Барбара Ліскова Barbara Liskov у 1987 році у доповіді «Data abstraction and hierarchy». Формальне формулювання доволі матем
20.4. LSP — Принцип підстановки Ліскова
Liskov Substitution Principle (LSP) сформулювала Барбара Ліскова (Barbara Liskov) у 1987 році у доповіді «Data abstraction and hierarchy». Формальне формулювання доволі математичне: «Якщо q(x) — деяка властивість, що є істинною для об'єктів x типу T, то q(y) має бути істинною для об'єктів y типу S, де S є підтипом T». На практиці це перефразовують простіше: об'єкт підкласу має бути взаємозамінним з об'єктом базового класу без порушення коректності програми.
Іншими словами: якщо десь у програмі очікується MedicalRecord, то передача Diagnosis (підкласу MedicalRecord) не повинна спричинити жодних несподіванок. Клієнтський код не повинен знати, який саме підклас він отримав — він знає лише контракт базового класу, і цього достатньо.
LSP — це, по суті, перевірка правильності ієрархії успадкування. Порушення LSP означає, що ієрархія побудована неправильно: або підклас не є справжнім «різновидом» базового класу, або базовий клас взяв на себе більше обіцянок, ніж може виконати кожен підклас.
Контракт методу: передумови, постумови, інваріанти
Щоб зрозуміти LSP глибоко, треба знати поняття контракту методу або класу. Контракт складається з трьох частин:
Передумови (preconditions) — умови, що мають виконуватися перед викликом методу. Наприклад: patientId не може бути від'ємним, date не може бути в минулому. Підклас не може посилювати передумови: якщо базовий клас приймає будь-яке ціле число, підклас не може раптом вимагати лише числа більше нуля — клієнт про це не знає і не перевіряє.
Постумови (postconditions) — гарантії щодо стану після виклику методу. Наприклад: метод Book гарантує, що після виклику запис існує. Підклас не може послаблювати постумови: якщо базовий клас гарантує додавання запису — підклас не може «вирішити» цього не робити.
Інваріанти (invariants) — умови, що завжди мають виконуватися для об'єкта протягом усього його існування. Наприклад: Patient.Id завжди додатне, Appointment.Duration завжди більше нуля. Підклас зобов'язаний зберігати інваріанти базового класу.
LSP у термінах контракту: підклас може послаблювати передумови і посилювати постумови, але не навпаки.
Класичний приклад порушення LSP: прямокутник і квадрат
Найвідоміший приклад порушення LSP — «проблема прямокутника і квадрата». Квадрат математично є прямокутником (кожен квадрат — прямокутник), тому здається логічним успадкувати Square від Rectangle. Але це ламає LSP:
Код SetAndPrint нічого не знає про Square. Він встановлює Width=5, Height=3 і очікує площу 15. Але Square «тихо» змінює обидва значення на останнє присвоєне. Поведінка змінилась — контракт порушено. Клієнт отримує несподіваний результат без жодного повідомлення про помилку.
Рішення: прибрати хибну ієрархію. Rectangle і Square — це дві незалежні форми, що не перебувають у відношенні «є» в контексті цього коду. Обидва можуть реалізовувати інтерфейс IShape, але не успадковувати одне від одного.
Порушення LSP у клінічній системі
Розглянемо реалістичний приклад порушення LSP у нашій системі. Є ієрархія MedicalRecord з підкласами Diagnosis, LabResult, Prescription. Новий розробник вирішує додати ReadOnlyRecord — «медичний запис тільки для читання» (наприклад, для архіву):

Виправлення: правильна ієрархія
Якщо ReadOnlyRecord не може виконати контракт MedicalRecord — він не повинен бути підкласом MedicalRecord. Рішення: або введення окремої ієрархії, або інтерфейсів, що розділяють читання і запис.
Тепер замість runtime-вибуху — помилка компіляції: спробувати передати ArchivedRecord туди, де очікується IEditableMedicalRecord, компілятор не дозволить. Це принципова перевага: помилка виявлена раніше, і жодного несподіваного стану у виробництві.

Ознаки порушення LSP
Практично LSP порушується в кількох характерних ситуаціях:
Виняток у перевизначеному методі: підклас перевизначає метод і кидає NotSupportedException, InvalidOperationException або NotImplementedException. Це класичне «пустий метод — порушення LSP».
Перевірка типу в клієнтському коді: якщо код пише if (record is ReadOnlyRecord) — це ознака того, що ієрархія неправильна. Клієнт не повинен знати про підкласи.
Ослаблення постумов: базовий метод гарантує, що після виклику Save(entity) дані збережені. Підклас перевизначає Save і нічого не робить — «тихе» порушення без жодного виключення.
Посилення передумов: базовий клас приймає будь-який рядок як ім'я. Підклас раптом вимагає, щоб ім'я не перевищувало 50 символів. Клієнти базового класу про це не знають.
Жодного switch, жодного is-перевірки. Кожен підклас виконує контракт базового — GetSummary() завжди повертає не-null рядок, GetStatus() завжди повертає статус. PrescriptionRecord лише розширює постумову GetStatus(), уточнюючи «Активний» або «Прострочений» — але не звужує і не ламає.
LSP і тестування: правило «замінюваності»
Практичний тест на дотримання LSP: якщо у вас є тест, що перевіряє поведінку базового класу, він повинен проходити і для кожного підкласу без змін. Це «правило замінюваності».
Якщо всі підкласи проходять однаковий тест контракту — LSP дотримано. Якщо хоч один підклас порушує — є проблема в ієрархії.