В идеальном мире

Вернёмся к примеру с классами Rectangle и Square из введения.

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

У нас есть несколько способов решить эту проблему.

Абстрактный класс-родитель

Первый способ — переделать иерархию так, чтобы Square не наследовался от Rectangle. Мы можем ввести новый класс, чтобы и квадрат, и прямоугольник наследовались от него.

Создадим абстрактный класс RightAngleShape, чтобы описать фигуры с прямым углом:

abstract class RightAngleShape {
  // используется для изменения ширины или высоты,
  // доступен только внутри класса и наследников:
  protected setSide(size: number, side?: 'width' | 'height'): void {}

  abstract areaOf(): number
}

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

class Square extends RightAngleShape {
  edge: number

  constructor(size: number) {
    super()
    this.edge = size
  }

  // переопределяем изменение стороны квадрата...
  protected setSide(size: number): void {
    this.edge = size
  }

  setWidth(size: number): void {
    this.setSide(size)
  }

  // ...и вычисление площади
  areaOf(): number {
    return this.edge ** 2
  }
}

class Rectangle extends RightAngleShape {
  width: number
  height: number

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

  // переопределяем изменение ширины и высоты...
  protected setSide(size: number, side: 'width' | 'height'): void {
    this[side] = size
  }

  setWidth(size: number) {
    this.setSide(size, 'width')
  }

  setHeight(size: number) {
    this.setSide(size, 'height')
  }

  // ...и вычисление площади
  areaOf(): number {
    return this.width *  this.height
  }
}

Теперь поведение наследников не конфликтует с поведением базового класса. Это позволит использовать и Rectangle, и Square там, где объявлено использование RightAngleShape.

Интерфейс

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

Мы можем превратить родительский класс RightAngleShape в интерфейс Shape с описанным методом areaOf, а также описать интерфейсы для фигур, у которых есть ширина (WidthfulShape) и высота (HeightfulShape):

interface Shape {
  areaOf(): number
}

interface WidthfulShape {
  setWidth(size: number): void
}

interface HeightfulShape {
  setHeight(size: number): void
}

Классы Rectangle и Square тогда могут реализовать их так:

// указываем, что необходимо реализовать в этом классе
type SquareShape = Shape & WidthfulShape

class Square implements SquareShape {
  edge: number

  constructor(size: number) {
    this.edge = size
  }

  protected setSide(size: number): void {
    this.edge = size
  }

  // указываем метод, меняющий ширину (описан в WidthfulShape)...
  setWidth(size: number) {
    this.setSide(size)
  }

  // ...и метод, который считает площадь (описан в Shape)
  areaOf(): number {
    return this.edge ** 2
  }
}


// для прямоугольника, кроме площади и ширины, необходимо указать и высоту,
// поэтому добавляем интерфейс HeightfulShape...
type RectShape = Shape & WidthfulShape & HeightfulShape
type ShapeSide = 'width' | 'height'

class Rectangle implements RectShape {
  width: number
  height: number

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

  protected setSide(size: number, side: ShapeSide): void {
    this[side] = size
  }

  setWidth(size: number) {
    this.setSide(size, 'width')
  }

  // ...и реализуем метод, описанный в HeightfulShape
  setHeight(size: number) {
    this.setSide(size, 'height')
  }

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

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

Следуя же LSP, мы проектируем поведение сущностей так, чтобы оно не конфликтовало с базовой абстракцией. Это позволяет нам использовать любой из классов Rectangle или Square там, где заявлено использование как Shape, так и WidthfulShape.

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

Вопросы