Ошибки, сложный материал, вопросы >
Нашли опечатку или неточность?

Выделите текст, нажмите ctrl + enter и отправьте его нам. В течение нескольких дней мы исправим ошибку или улучшим формулировку.

Что-то не получается или материал кажется сложным?

Загляните в раздел «Обсуждение»:

  • задайте вопрос нашим менторам. Вы быстрее справитесь с трудностями и прокачаете навык постановки правильных вопросов, что пригодится и в учёбе, и в работе программистом;
  • расскажите о своих впечатлениях. Если курс слишком сложный, подробный отзыв поможет нам сделать его лучше;
  • изучите вопросы других учеников и ответы на них. Это база знаний, которой можно и нужно пользоваться.
Об обучении на Хекслете
JS: Полиморфизм

Паттерн Состояние (State)

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

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

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

  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. Конечные автоматы

<span class="translation_missing" title="translation missing: ru.web.courses.lessons.mentors.mentor_avatars">Mentor Avatars</span>

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

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

Для полного доступа к курсу нужна профессиональная подписка

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

Получить доступ
115
курсов
892
упражнения
2241
час теории
3196
тестов

Зарегистрироваться

или войти в аккаунт

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

  • 115 курсов, 2000+ часов теории
  • 800 практических заданий в браузере
  • 250 000 студентов

Отправляя форму, вы соглашаетесь c «Политикой конфиденциальности» и «Условиями оказания услуг».

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

Логотип компании Альфа Банк
Логотип компании Rambler
Логотип компании Bookmate
Логотип компании Botmother

Есть вопрос или хотите участвовать в обсуждении?

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

Отправляя форму, вы соглашаетесь c «Политикой конфиденциальности» и «Условиями оказания услуг».