open atlas
↑ К треку
Паттерны и качество кода CP · 05 · 03

Подстановка Лисков

Подтип должен подставляться вместо супертипа, не удивляя вызывающих. LSP — про поведенческие контракты, а не совместимость типов: компилятор принимает подкласс, рантайм его предаёт. Признак нарушения — частный случай через `instanceof` на месте вызова.

CP Senior ◷ 19 min
Уровень
ОсновыJuniorMiddleSenior

Square extends Rectangle. Компилируется. Все тесты на Rectangle проходят, когда передаёшь Rectangle. Потом функция, которая меняет размер прямоугольника — setWidth(5); setHeight(4); assert(area === 20) — получает Square, и assert падает: квадрат заставил высоту следовать за шириной, так что площадь равна 16. Ничто не было неправильно типизировано. Система типов была довольна. Программа всё равно неверна.

Этот разрыв — между компилятор это принимает и вызывающий действительно может на это положиться — и есть то, о чём принцип подстановки Лисков. LSP — не правило про совпадение типов. Это правило про совпадение поведения, и структурный проверяльщик TypeScript не может обеспечить его за тебя.

Цель

После этого урока ты можешь сформулировать LSP как контракт поведенческого подтипирования (не усиливать предусловия, не ослаблять постусловия, не бросать новых исключений, сохранять инварианты); распознавать два классических нарушения (Rectangle/Square и read-only-коллекция, чей add бросает исключение); читать частный случай instanceof Sub на месте вызова как признак того, что подтип сломал подставляемость; и выбирать композицию вместо наследования, когда подкласс не может соблюсти контракт родителя.

1

LSP: подтип должен подставляться вместо своего супертипа, не удивляя никакой код, который держит супертип. Формулировка Барбары Лисков: если S — подтип T, то объекты типа T могут быть заменены объектами типа S без изменения каких-либо желаемых свойств программы. Практическое прочтение: каждая функция, написанная против T — использующая только то, что обещает контракт T, — должна продолжать работать, получив S, без всякого специального знания о том, что присутствует S.

Это ограничение на автора подкласса, уплачиваемое вызывающему, которого он никогда не встретит. Вызывающий писал код против Rectangle. Автор подкласса обязан сделать так, чтобы Square вёл себя как Rectangle во всём, на что этот вызывающий мог законно опираться. Если он не может — Square не является подтипом Rectangle, что бы ни говорило ключевое слово extends.

2

У контракта четыре пункта; наруши любой — и подстановка ломается. Поведенческое подтипирование точно проговаривает, что значит «вести себя как супертип»:

abstract class Account {
  // precondition: amount > 0
  // postcondition: balance decreases by exactly `amount`; never throws for a valid amount
  abstract withdraw(amount: number): void;
}
  • Не усиливай предусловия. Подтип не вправе требовать от вызывающих большего, чем требовал родитель. Если Account.withdraw принимает любую положительную сумму, подтип, отвергающий суммы свыше 100, ломает вызывающих, которые обоснованно передали 500.
  • Не ослабляй постусловия. Подтип обязан выдать как минимум то, что обещал родитель. Если withdraw гарантирует, что баланс уменьшится на amount, подтип, который иногда уменьшает его меньше, солгал каждому вызывающему.
  • Не бросай новых исключений, которые контракт родителя никогда не объявлял. Вызывающий, обработавший задокументированные ошибки withdraw, не поймает внезапную NotSupportedError.
  • Сохраняй инварианты, которые поддерживает родитель (например, «баланс никогда не отрицателен»). Подтип наследует обязанность держать их истинными.

Это единственные рычаги. Каждое нарушение LSP — один из этих четырёх, ставший конкретным.

3

Rectangle/Square: подтип усиливает инвариант, которого у родителя не было, и это ломает вызывающих. Хрестоматийный случай. Rectangle позволяет ширине и высоте меняться независимо — эта независимость часть его контракта, хотя ни один метод её не объявляет.

class Rectangle {
  constructor(protected w: number, protected h: number) {}
  setWidth(w: number) { this.w = w; }
  setHeight(h: number) { this.h = h; }
  area() { return this.w * this.h; }
}

class Square extends Rectangle {
  // a square must keep w === h, so each setter mutates both
  setWidth(w: number) { this.w = w; this.h = w; }
  setHeight(h: number) { this.w = h; this.h = h; }
}

// a caller written against Rectangle's contract
function grow(r: Rectangle) {
  r.setWidth(5);
  r.setHeight(4);
  return r.area(); // a Rectangle reader expects 20
}

grow(new Rectangle(1, 1)); // 20  ✅
grow(new Square(1, 1));    // 16  ❌  — setHeight clobbered the width

grow не сделал ничего плохого; он использовал только публичный контракт Rectangle. Square усилил его новым инвариантом (w === h), который родитель никогда не обещал держать, и этот лишний инвариант ослабил постусловие setWidth (он больше не оставляет высоту в покое). Геометрически квадрат является прямоугольником; поведенчески мутабельный Square не является подставляемым Rectangle. Интуиция is-a — это ловушка.

4

Read-only-коллекция: подтип бросает новое исключение, поэтому вызывающие не могут обращаться с ним как с супертипом. Вторая каноническая форма, и та, что чаще встречается в реальном коде.

class List<T> {
  protected items: T[] = [];
  add(item: T) { this.items.push(item); }
  get(i: number) { return this.items[i]; }
}

class ReadOnlyList<T> extends List<T> {
  add(_item: T): never {
    throw new Error("ReadOnlyList is immutable"); // a NEW exception the parent never threw
  }
}

function appendAuditEntry(log: List<string>) {
  log.add("user logged in"); // valid against List's contract — but throws for ReadOnlyList
}

ReadOnlyList совместим по типу — у него есть каждый метод, что есть у List. Компилятор доволен. Но он нарушает пункт «никаких новых исключений»: appendAuditEntry, держа List, вызывает контрактно-валидный add и получает исключение, о котором контракт List никогда не предупреждал. ReadOnlyList — это более узкое поведение, носящее более широкий тип. Исправление — перевернуть иерархию: read-only-интерфейс становится супертипом, а мутабельный список расширяет его, добавляя add — ты никогда не наследуешь возможность, чтобы затем её отнять.

5

Признак: когда вызывающие начинают писать if (x instanceof Sub), подтип уже сломал LSP. Подтип, который по-настоящему не подставляется, вынуждает вызывающего узнать его реальный тип и ветвиться по нему:

function grow(r: Rectangle) {
  if (r instanceof Square) {
    // special-case the thing that was supposed to be substitutable
    r.setWidth(5);
    return r.area();
  }
  r.setWidth(5);
  r.setHeight(4);
  return r.area();
}

В тот момент, когда появляется этот instanceof, абстракция провалилась: вызывающий теперь знает о подклассе, а это ровно та связанность, которую полиморфизм был призван убрать. Каждый новый подтип добавляет сюда ещё одну ветку (заодно и нарушение Open/Closed — функция больше не закрыта для новых типов). Так что instanceof Sub внутри кода, который держит супертип, — не просто уродство; это невидимое компилятору нарушение LSP, ставшее видимым. Воспринимай его как сигнал тревоги о провале подстановки, а не как мелкую придирку к стилю.

Разбор примера

Реальное нарушение и исправление композицией. Платёжная система моделирует «бесплатный» способ оплаты как подкласс CreditCardPayment, потому что хотела переиспользовать код чека и логирования:

// BEFORE — inheritance chosen for reuse, not substitutability
class CreditCardPayment {
  charge(amountCents: number): ChargeResult {
    if (amountCents <= 0) throw new Error("amount must be positive"); // parent's precondition
    return this.gateway.charge(amountCents);
  }
}

class FreeTrialPayment extends CreditCardPayment {
  charge(amountCents: number): ChargeResult {
    if (amountCents !== 0) throw new Error("free trial must be 0"); // STRENGTHENED precondition
    return { ok: true, id: "free" };
  }
}

function checkout(p: CreditCardPayment, cents: number) {
  return p.charge(cents); // valid for any cents > 0 against the parent's contract
}

checkout(new FreeTrialPayment(), 999); // throws — the subtype demands MORE of the caller

FreeTrialPayment усилил предусловие (charge теперь требует ноль), так что вызывающий, держащий CreditCardPayment и передающий 999 — совершенно законно по контракту родителя, — получает исключение. Чтобы оно работало, checkout пришлось бы сделать частный случай через instanceof FreeTrialPayment. Этот instanceof и есть тревога.

Исправление — перестать наследовать контракт, который не можешь соблюсти, и вместо этого скомпоновать за общим интерфейсом:

// AFTER — a common contract, no fake is-a, no strengthened precondition
interface PaymentMethod {
  charge(amountCents: number): ChargeResult; // contract: handles its own valid range, returns a result
}

class CreditCardPayment implements PaymentMethod {
  constructor(private receipts: ReceiptService) {}
  charge(cents: number) { return this.gateway.charge(cents); }
}

class FreeTrialPayment implements PaymentMethod {
  constructor(private receipts: ReceiptService) {} // reuse by composing the service, not by extending
  charge(_cents: number) { return { ok: true, id: "free" }; }
}

function checkout(p: PaymentMethod, cents: number) {
  return p.charge(cents); // every PaymentMethod honors the same contract — substitutable
}

Теперь нет родительского контракта, который подтип мог бы нарушить: каждый метод удовлетворяет PaymentMethod на своих условиях, код чека разделяется композицией (ReceiptService), а checkout никогда не должен знать, какой способ он держит. Урок: наследование выбрали ради переиспользования кода, но ценой был сломанный контракт. Когда сомневаешься — переиспользуй композицией: она не несёт обязательства подставляемости.

Почему это работает

Почему TypeScript этого не ловит? Потому что подтипирование в TS структурно: Square присваиваем к Rectangle ровно потому, что у него есть все члены Rectangle с совместимыми сигнатурами. Система типов рассуждает о формах — именах методов и типах параметров/возврата, — а поведенческий контракт вроде «ширина и высота меняются независимо» или «add срабатывает» не является частью никакой формы. Ни один мейнстримный язык не кодирует полные поведенческие контракты в своих типах; для этого потребовались бы зависимые типы или проверка контрактов в рантайме. Поэтому LSP живёт в пространстве, которого компилятор не видит: это дисциплина проектирования, проверяемая тестами и ревью, а не tsc. «Оно компилируется» необходимо для подставляемости и далеко не достаточно.

Частая ошибка

Соблазнительная ошибка — тянуться к extends ради переиспользования кода: «унаследую, чтобы не переписывать код логирования/чека/валидации». Наследование даёт тебе переиспользование и обязательство is-a/подставляемости, связанные вместе; ты же хотел только первое. В момент, когда подклассу нужно отказать, ограничить или перебросить что-то, что родитель разрешал, это связанное обязательство ломается, и нарушение протекает в вызывающих. Правило большого пальца: наследуй только тогда, когда подкласс — истинный поведенческий заменитель родителя в каждом контексте, где используется родитель; иначе компонуй. Большую часть «переиспользующего» наследования в реальных кодовых базах следовало бы сделать общим сотрудником или общим интерфейсом.

Проверь себя
Викторина

Коллега добавляет `class ReadOnlyList<T> extends List<T>`, чей `add()` бросает «immutable». Оно компилируется, и он говорит: «проверяльщик типов доволен, значит это валидный подтип». В чём senior-возражение?

Итог

Принцип подстановки Лисков гласит, что подтип должен подставляться вместо своего супертипа не удивляя вызывающих — соблюдай поведенческий контракт: не усиливай предусловия, не ослабляй постусловия, не бросай новых исключений, сохраняй инварианты. Это про поведенческое подтипирование, а не про простую совместимость типов: компилятор проверяет формы и охотно примет Square extends Rectangle или ReadOnlyList, чей add() бросает исключение, тогда как рантайм предаёт контракт, на который положился вызывающий. Надёжный признак — if (x instanceof Sub), появляющийся в коде, который держит супертип: абстракция провалилась, подстановка сломана (а с ней и Open/Closed). Коренная причина почти всегда — наследование, выбранное ради переиспользования кода, а не ради истинной подставляемости; когда сомневаешься, переиспользуй композицией, которая не несёт обязательства is-a, способного сломаться.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.