Введение

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

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

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

Представим, что есть класс 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) решает эту проблему, вводя ограничения для иерархии объектов.

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

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

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

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

Снова пример

В примере с 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.

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

Коротко

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

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

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

Вопросы