В реальной жизни

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

Индикатор такой проблемы — проверки на принадлежность типу или классу перед выполнением какой-то операции или перед возвращением результата.

LSP помогает выявлять проблемные абстракции при проектировании и строить иерархию сущностей с учётом подобных проблем.

Как и OCP, LSP подводит к выводу, что большие и сложные иерархии сущностей, основанные на наследовании, — это хрупкий и опасный инструмент, вместо которого лучше использовать композицию.

Иерархия пользователей

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

В проекте был класс User, который описывал сущность пользователя приложения. В нём были методы для работы с сессией, определением прав этого пользователя и обновлением профиля:

class User {
  constructor() {
    // ...
  }

  getSessionID(): ID {
    return this.sessID
  }

  hasAccess(action: Actions): boolean {
    // ...
    return access
  }

  updateProfile(data: Profile): CommandStatus {
    // ...
    return status
  }
}

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

В какой-то момент в приложении появился «гостевой режим». У гостей были ограниченные права, и не было профиля. Из-за отсутствия профиля в классе Guest метод updateProfile усиливал своё предусловие:

// гости наследуются от пользователей
class Guest extends User {
  constructor() {
    super()
  }

  hasAccess(action: Actions): boolean {
    // тут всё ок, описываем логику доступов для гостей
    return access
  }

  updateProfile(data: Profile): CommandStatus {
    // а вот тут проблема: у гостей профиля нет,
    // из-за чего приходится выбрасывать исключение;
    // гостевой режим как бы заставляет нас учитывать большее количество
    // обстоятельств, прежде чем выполнить обновление профиля
    throw new Error(`Guests don't have profiles`)
  }
}

Применяем LSP

Попробуем решить проблему, применив LSP. Согласно принципу Guest должен быть заменяем на класс, от которого он наследуется, а приложение при этом не должно взрываться.

Введём общий интерфейс User, который будет содержать всё общее, что есть у гостей и пользователей.

interface User {
  getSessionID(): ID
}

Для описания доступов и работы с данными профиля создадим отдельные интерфейсы: UserWithAccess и UserWithProfile:

// здесь всё, что относится к доступам
interface UserWithAccess {
  hasAccess(action: Actions): boolean
}

// здесь — к профилю
interface UserWithProfile {
  updateProfile(data: Profile): CommandStatus
}

Опишем базовый класс; от него будут наследоваться остальные классы гостей и пользователей:

class BaseUser implements User {
  constructor() {
    // ...
  }

  getSessionID(): ID {
    return this.sessID
  }
}

// у обычных пользователей добавляем методы
// для работы с профилем и для работы с доступами
class RegularUser extends BaseUser implements UserWithAccess, UserWithProfile {
  constructor() {
    super()
  }

  hasAccess(action: Actions): boolean {
    // ...
    return access
  }

  updateProfile(data: Profile): CommandStatus {
    // ...
    return status
  }
}

// для гостей же достаточно описать только доступы
class Guest extends BaseUser implements UserWithAccess {
  constructor() {
    super()
  }

  hasAccess(action: Actions): boolean {
    // ...
    return access
  }
}

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

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

ООП — не про наследование и классы, а про отношение между сущностями и их поведение. В нём вполне успешно можно применять композицию — когда разные свойства объектов сочетаются в новом объекте.

При описании класса RegularUser в примере выше мы указали, что он реализует два интерфейса UserWithAccess и UserWithProfile. Каждый из интерфейсов отвечает за какую-то часть функциональности, которую мы сочетаем в RegularUser — это и есть композиция.

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

React и JSX

Ещё один пример LSP — это React-компоненты, а точнее их реализация в JSX. Синтаксис JSX построен таким образом, что мы можем писать разметку компонентов в HTML-подобном виде:

interface ComponentProps {
  title: string
}

// пример React-компонента
const ExampleReactComponent: FunctionComponent<ComponentProps> = ({ title }) => (
  <div>
    <h1>{title}</h1>
    <OtherComponent />
  </div>
)

В примере выше можно заметить, что наряду с «обычными HTML-тегами» (<div> и <h1>) также рендерится и <OtherComponent /> — другой React-компонент.

Возможность использовать компоненты точно так же, как «обычные теги», — это тоже реализация принципа подстановки Лисков. Мы можем заменить «обычный тег» на компонент, потому что и те и другие — это реализация ReactElement, который описывает, как именно они должны себя вести.

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