Зарегистрируйтесь, чтобы продолжить обучение

Паттерн Состояние (State) JS: Полиморфизм

Паттерн «Состояние» — яркий пример замены условных конструкций на полиморфизм подтипов. Он довольно широко используется и способен по-настоящему снизить сложность кода. Разберем его на примере поведения экранов телефонов.

Не все телефоны ведут себя одинаковым образом, но для урока надо было выбрать конкретный пример

Всего у телефона три базовых состояния:

  1. Телефон выключен. Экран не реагирует на прикосновения.
  2. Телефон включен, но экран выключен. Экран реагирует только на прикосновение (но не на смахивание) и включается.
  3. Телефон включен и экран тоже. Реакция на прикосновения и жесты зависит от активного приложения.

Смоделируем эту логику в классе, отвечающем за экран, и добавим туда два события: прикосновение (touch) и смахивание (swipe).

class MobileScreen {
  constructor() {
    // В самом начале телефон выключен
    this.powerOn = false;
    this.screenOn = false;
  }

  // Включение питания
  powerOn() {
    this.powerOn = true;
  }

  // Прикосновение
  touch() {
    // Если питание выключено, то ничего не происходит
    if (!this.powerOn) {
      return;
    }

    // Если экран был выключен, то его надо включить
    if (!this.screenOn) {
      this.screenOn = true;
    }

    // На событие должно реагировать текущее активное приложение
    this.notify('touch');
  }

  // Смахивание
  swipe() {
    // Если выключено питание или экран, то ничего не происходит
    if (!this.powerOn || !this.screenOn) {
      return;
    }

    // На событие должно реагировать текущее активное приложение
    this.notify('swipe');
  }
}

Событий всего два, а уже сколько условных конструкций. В реальности событий было бы гораздо больше, и все они должны учитывать состояние активности телефона и экрана.

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

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

Явно выделенное состояние

Текущая реализация экрана опирается на флаги. В программировании так называют переменные содержащие булевы значения.

constructor() {
  this.powerOn = false;
  this.screenOn = false;
}

Флаги часто (но не всегда!) — признак плохой архитектуры. Они имеют тенденцию множиться и пересекаться. Логика, завязанная на комбинации разных флагов, усложняет анализ кода:

if (!this.powerOn || !this.screenOn) {
  return;
}

Такой стиль программирования имеет свое название: «флаговое программирование». Так говорят про код, в котором трудно разобраться из-за наличия логики, завязанной на комбинацию флагов. А наличие флагов почти наверняка к этому приведет. Все дело в том, что количество состояний у систем, как правило, больше чем два. То есть одного флага никогда не будет достаточно.

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

  • Power Off: Питание отключено (а значит и экран выключен).
  • Screen Disabled: Экран выключен (но питание включено).
  • Screen On: Экран включен.

Следующий шаг, заменить флаги на одну переменную, которая хранит текущее состояние системы:

class MobileScreen {
  constructor() {
    this.stateName = 'powerOff';
  }

  powerOn() {
    this.stateName = 'screenDisabled';
  }

  touch() {
    if (this.stateName === 'powerOff') {
      return;
    }

    if (this.stateName === 'screenDisabled') {
      this.stateName = 'screenOn';
    }

    this.notify('touch');
  }

  swipe() {
    if (this.stateName !== 'screenOn') {
      return;
    }

    // На событие должно реагировать текущее активное приложение
    this.notify('swipe');
  }
}

Главное, что произошло в коде выше – пропали проверки на комбинацию флагов. Это не отменяет возможности проверок сразу по нескольким состояниям, но состояния системы понимать гораздо проще, чем наборы флагов.

Классы Состояний

Для избавления от условных конструкций понадобится полиморфизм. На базе чего его строить? Благодаря наличию явно выделенного состояния легко увидеть зависимость поведения от состояния. Именно состояния должны трансформироваться в классы со своим собственным поведением, специфичным для данного состояния.

Экран, в свою очередь, избавится от всех проверок и начнет взаимодействовать с состояниями:

import PowerOffState from './states/PowerOffState.js';
import ScreenDisabledState from './states/ScreenDisabledState.js';
import ScreenOnState from './states/ScreenOnState.js';

class MobileScreen {
  constructor() {
    // Список состояний нужен для переключений между ними
    // Иначе возможно появление циклических зависимостей внутри состояний
    this.states = {
      powerOff: PowerOffState,
      screenDisabled: ScreenDisabledState,
      screenOn: ScreenOnState,
    }
    // Начальное состояние
    // Внутрь передается текущий объект
    // Это нужно для смены состояний (примеры ниже)
    this.state = new this.states.powerOff(this);
  }

  powerOn() {
    // Предыдущее состояние нас не волнует
    // Все данные хранятся в самом экране
    // Объекты-состояния не имеют своих данных
    this.state = new this.states.screenDisabled(this);
  }

  touch() {
    this.state.touch();
  }

  swipe() {
    this.state.swipe();
  }
}

// Обратите внимание что с точки зрения внешнего кода (пользователя экрана)
// ничего не изменилось.

Теперь экран не делает ровным счетом ничего. Весь его код — это инициализация начального состояния и передача управления текущему активному состоянию. Как же выглядят классы состояний?

class PowerOffState {
  constructor(screen) {
    this.screen = screen;
  }

  touch() {
    // ничего не происходит
  }

  swipe() {
    // ничего не происходит
  }
}

Проще всех устроено состояние выключенного телефона. В этом состоянии нет никакой реакции, поэтому методы пустые. Посмотрим ScreenDisabledState:

class ScreenDisabledState {
  constructor(screen) {
    this.screen = screen;
  }

  touch() {
    // Включаем экран. В конструктор нужно передать сам экран.
    this.screen.state = new this.screen.states.screenOn(this.screen);
    // Оповещаем текущую программу об активации
    this.screen.notify('touch');
  }

  swipe() {
    // ничего не происходит
  }
}

Прикосновение к экрану оживляет его. Для этого состояние ScreenDisabledState должно выполнить переход в состояние ScreenOnState. Именно поэтому внутрь каждого состояния передавался сам экран. Иначе невозможно было бы его изменять.

И последнее состояние ScreenOnState. Это единственное состояние, в котором происходит взаимодействие с программами

class ScreenOnState {
  constructor(screen) {
    this.screen = screen;
  }

  touch() {
    this.screen.notify('touch');
  }

  swipe() {
    this.screen.notify('swipe');
  }
}

Это невероятно, но в коде больше не осталось ни одной условной конструкции. Стало легко видеть поведение телефона на все события в конкретном состоянии. Достаточно открыть нужный класс. Цена за такое удобство — большее количество файлов и кода.

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


Дополнительные материалы

  1. Конечные автоматы

Аватары экспертов Хекслета

Остались вопросы? Задайте их в разделе «Обсуждение»

Вам ответят команда поддержки Хекслета или другие студенты

Для полного доступа к курсу нужен базовый план

Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.

Получить доступ
1000
упражнений
2000+
часов теории
3200
тестов

Открыть доступ

Курсы программирования для новичков и опытных разработчиков. Начните обучение бесплатно

  • 130 курсов, 2000+ часов теории
  • 1000 практических заданий в браузере
  • 360 000 студентов
Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»

Наши выпускники работают в компаниях:

Логотип компании Альфа Банк
Логотип компании Aviasales
Логотип компании Yandex
Логотип компании Tinkoff
Рекомендуемые программы
профессия
от 25 000 ₸ в месяц
Разработка фронтенд-компонентов для веб-приложений
10 месяцев
с нуля
Старт 26 декабря
профессия
от 39 525 ₸ в месяц
Разработка фронтенд- и бэкенд-компонентов для веб-приложений
16 месяцев
с нуля
Старт 26 декабря
профессия
от 25 000 ₸ в месяц
Разработка бэкенд-компонентов для веб-приложений
10 месяцев
с нуля
Старт 26 декабря

Используйте Хекслет по-максимуму!

  • Задавайте вопросы по уроку
  • Проверяйте знания в квизах
  • Проходите практику прямо в браузере
  • Отслеживайте свой прогресс

Зарегистрируйтесь или войдите в свой аккаунт

Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»