В реальной жизни
Наследование предполагает иерархическую структуру сущностей, но с такими структурами есть проблемы, например — когда одна из сущностей не вписывается в эту иерархию.
Индикатор такой проблемы — проверки на принадлежность типу или классу перед выполнением какой-то операции или перед возвращением результата.
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 (JavaScript XML). Синтаксис JSX построен таким образом, что мы можем писать разметку компонентов в HTML-подобном виде:
interface ComponentProps {
title: string
}
// Пример React-компонента:
const ExampleReactComponent: FunctionComponent<ComponentProps> = ({ title }) => (
<div>
<h1>{title}</h1>
<OtherComponent />
</div>
)
В примере выше можно заметить, что наряду с «обычными HTML-тегами» (<div>
и <h1>
) также рендерится и <OtherComponent />
— другой React-компонент.
Возможность использовать компоненты точно так же, как «обычные теги», — это тоже реализация принципа подстановки Лисков. Мы можем заменить «обычный тег» на компонент, потому что и те и другие — это реализация ReactElement
, который описывает, как именно они должны себя вести.
Материалы к разделу
- Liskov Substitution Principle, Hackernoon
- Liskov Substitution Principle, Maksim Ivanov
- How does strengthening of preconditions and weakening of postconditions violate Liskov substitution principle?
- Composition over inheritance
- Liskov Substitution Principle and the Composition Root - A Perspective
- Introducing JSX