Введение

Одна из частых ошибок проектирования программных систем — это попытка полностью скопировать иерархию объектов из реального мира.

Моделируя систему, мы описываем поведение её компонентов, отношения их друг с другом, а не иерархию. Иерархия — удобный инструмент для моделирования, но иногда она приводит к неправильному описанию поведения.

Классический пример

Представим, что есть класс Rectangle, который описывает прямоугольник:

class Rectangle {
  width: number
  height: number

  constructor(width: number, height: number) {
    this.width = width
    this.height = height
  }

  setWidth(width: number) {
    this.width = width
  }

  setHeight(height: number) {
    this.height = height
  }

  areaOf(): number {
    return this.width * this.height
  }
}

Квадрат — тоже прямоугольник, мы можем использовать наследование, чтобы описать его:

class Square extends Rectangle {
  width: number
  height: number

  constructor(size: number) {
    super(size, size)
  }

  setWidth(width: number) {
    this.width = width
    this.height = width
  }

  setHeight(height: number) {
    this.width = height
    this.height = height
  }
}

Дальше в коде мы используем квадрат. Кажется, что всё в порядке:

declare const square: Square

square.setWidth(20) // Меняет ширину и высоту, всё верно.
square.setHeight(40) // Тоже меняет ширину и высоту, ок.

Но если мы используем класс Rectangle в качестве интерфейса, а работаем с конкретным классом Square, то могут возникнуть проблемы:

function testShapeSize(figure: Rectangle) {
  figure.setWidth(10)
  figure.setHeight(20)
  assert(figure.areaOf() === 200)

  // Условие не сработает, если `figure` —
  // экземпляр класса `Square`.
}

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

Математически — да, квадрат всё ещё прямоугольник, но он ведёт себя иначе, чем прямоугольник.

Принцип подстановки Барбары Лисков

Принцип подстановки Барбары Лисков (Liskov Substitution Principle, LSP) решает эту проблему, вводя ограничения для иерархии объектов.

Звучит он так: функции, которые используют базовый тип, должны иметь возможность использовать подтипы базового типа, не зная об этом.

Простыми словами — классы-наследники не должны противоречить базовому классу. Например, они не могут предоставлять интерфейс ýже базового. Поведение наследников должно быть ожидаемым для функций, которые используют базовый класс.

Немного удобнее думать об LSP в терминах «абстракция — реализация». Абстрактный класс или интерфейс играют роль базового типа, но вместе с этим — роль контракта на поведение.

abstract class Disposable {
  protected source: Optional<number>;
  constructor(fn: AnyFunction, delay: TimeIntervalMS) {}

  dispose(): void {}
}

Они гарантируют, что экземпляр любого конкретного класса будет содержать указанные поля и методы.

class Interval extends Disposable {
  constructor(fn: AnyFunction, delay: Milliseconds) {
    super(fn, delay);
    this.source = setInterval(fn, delay);
  }

  dispose = () => clearInterval(this.source);
}

class Timer extends Disposable {
  constructor(fn: AnyFunction, delay: Milliseconds) {
    super(fn, delay);
    this.source = setTimeout(fn, delay);
  }

  dispose = () => clearTimeout(this.source);
}

Это значит, что модуль, использующий этот абстрактный класс или интерфейс сможет работать с любой его реализацией. Например, функции cleanup будет неважно, экземпляр какого конкретного класса мы передадим:

function cleanup(disposable: Disposable): void {
  disposable.dispose();
}

const interval = new Interval(() => console.log('Hey!'), 1000);
const timer = new Timer(() => alert('Hey!'), 1000);

cleanup(interval);
cleanup(timer);

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

  • предусловия не могут быть усилены в подклассе;
  • постусловия не могут быть ослаблены в подклассе.

Снова пример

В примере с Rectangle и Square последний ослабляет постусловие для методов setWidth и setHeight. Разберём, что это за постусловие.

Если мы работаем с методом setHeight класса Rectangle, то после вызова метода будем наблюдать ситуацию, когда:

const oldHeight = figure.height
figure.setWidth(newWidth)

assert((figure.width === newWidth) && (figure.height === oldHeight))

Но в случае с квадратом это не так. Постусловие — свойства или состояние после выполнения метода — ослабляется:

const oldHeight = figure.height
figure.setWidth(newWidth)

// Постусловие ослаблено,
// абстракция неправильная:

assert((figure.width === newWidth))

Из-за этого использовать Rectangle вместо Square без дополнительных проверок или изменения уже существующих компонентов невозможно.

Принцип подстановки Лисков требует использовать общий интерфейс для обоих классов и не наследовать Square от Rectangle.

Общий интерфейс должен быть таким, чтобы в классах, реализующих его, предусловия не были более сильными, а постусловия не были более слабыми.

Наследование и композиция

Следовать LSP проще, если не строить больших и глубоких иерархий. Обычно, для связи модулей достаточно интерфейса и его реализаций.

Длинные цепочки иерархий типов — хрупкие. Вместо иерархий типов лучше использовать композицию, чтобы собирать сущности из необходимой функциональности.

Например, наследование предполагает проектирование от общего к частному в виде иерархии:

Животные → Млекопитающие → Человек.

Но такие иерархии не всегда правильно отражают связи сущностей в проектируемой системе. Иногда иерархий вовсе может быть несколько одновременно, тогда наследование зайдёт в тупик — непонятно по какой иерархии наследоваться и как.

Композиция же подразумевает проектирование от частного к общему в виде совокупности нескольких обособленных наборов «фич»:

Человек = Скелет + Нервная система + Иммунная система + Сердечно-сосудистая система + ...

Таким образом мы не ограничиваем себя рамками одной иерархии, а значит нам необязательно подводить каждый аспект бизнес-логики под эту иерархию.

Более того, часто в отношениях сущностей, которые мы моделируем, в принципе нет никакой иерархии. В таком случае наследование сущностей наоборот лишь навредит.

Коротко

Принцип подстановки Барбары Лисков:

  • помогает проектировать систему, опираясь на поведение модулей;
  • вводит ограничения и правила наследования объектов, чтобы их потомки не противоречили базовому поведению;
  • делает поведение модулей последовательным и предсказуемым;
  • помогает избегать дублирования, выделять общую для нескольких модулей функциональность в общий интерфейс;
  • позволяет выявлять при проектировании проблемные абстракции и скрытые связи между сущностями.

Материалы к разделу

Вопросы