Простые стейт-машины на службе у разработчика

в 10:08, , рубрики: framework, FSM, GoF, KISS, reflection, XML, конечный автомат, Программирование, метки: , , , , , , ,

Представьте на минутку обычного программиста. Допустим, его зовут Вася и ему нужно сделать анимированную менюшку на сайт/десктоп приложение/мобильный апп. Знаете, которые выезжают сверху вниз, как меню у окна Windows или меню с яблочком у OS X. Вот такое.

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

var opened = false;

Вроде, работает. Но, если быстро кликать по кнопке, меню начинает моргать, открываясь и закрываясь не успев доанимироваться в конечное состояние. Вася добавляет флаг animating. Теперь код у нас такой:

var opened = false;
var animating = false;

function onClick(event) {
  if (animating) return;
  if (opened) close();
  else open();
}

Через какое-то время Васе говорят, что меню может быть полностью выключено и неактивно. Не вопрос! Мы-то с вами тут программисты опытные, все понимаем, что… нужно добавить ЕЩЕ ОДИН ФЛАГ! И, всего-то через пару дней разработки, код меню уже пестрит двустрочными IF-ами типа вот такого:

if (enabled && opened && !animating && !selected && finishedTransition && !endOfTheWorld && ...) { ... }

Вася начинает задаваться вопросами: как вообще может быть, что animating == true и enabled == false; почему у него время от времени все глючит; как тут вообще поймешь в каком состоянии находится меню. Ага! Состояния... О них дальше и пойдет речь.

Знакомьтесь, это Вася.

Простые стейт машины на службе у разработчика

Состояние

Вася уже начинает понимать, что многие комбинации флагов не имеют смысла, а остальные можно легко описать парой слов, например: Disabled, Idle, Animating, Opened. Все мы тут программисты опытные, сразу вспоминаем про state machines. Но, для Васи придется рассказать что это и зачем. Простым языком, без всяких математических терминов.

У нас есть объект, например, вышеупомянутая менюшка. Объект всегда находится в каком-то одном состоянии и реагируя на различные события может между этими состояниями переходить. Обычно состояния, события и переходы удобно описывать вот такими схемами (кружочками обозначены начальное и конечные состояния):

Простые стейт машины на службе у разработчика

Из схемы понятно, что из состояния Inactive в Active можно попасть только по событию Begin, а из состояния Paused можно попасть как и в Active, так и в Inactive. Такую простую концепцию почему-то называют «Конечный Автомат» или «Finite State Machine», что очень пугает обычных людей.

По завету ООП, состояния должны быть скрыты внутри объекта и просто так снаружи не доступны. Например, у объекта во время работы может быть 20 разных состояний, но внешнее API на вопрос «чо как дела?» отвечает «ничо так» на 19 из них и только на 1 ругается матом, что проср*ли все полимеры.

Следуя концепции стейт машин, очень легко структурировать код так, что всегда будет ясно что и как делает тот или иной объект. Всегда будет понятно, что что-то пошло не так, если система вдруг попыталась перейти в недоступное из данного состояния состояние. А события, которые вдруг посмели прийти в неправильное время, можно смело игнорировать и не бояться, что что-нибудь сломается.

Простые стейт машины на службе у разработчика

Самая простая в мире стейт машина

Допустим, теперь Вася делает проект на C# и ему нужна простая стейт машина для одного типа объектов. Он пишет что-то типа такого:

private enum State { Disabled, Idle, Animating }

private State state;
 
void setState(State value) {
    state = value;
    switch (state) {
        case State.Disabled:
            ...
        case State.Idle:
            ...
        case State.Animating :
            ...
        break;
    }
}

А вот так обрабатывает события в зависимости от текущего состояния:

void event1Handler() {
    switch (state) {
        case State.Idle:
            ...
        break;
    }
}

Но, мы-то с вами тут программисты опытные, все понимаем, что метод setState в итоге разрастется на пару десятков страниц, что (как написано в учебниках) не есть хорошо.

State Pattern

Погуглив пару часов, Вася решает, что State Pattern идеально подходит в данной ситуации. Тем более, что старшие программисты все время соревнуются кто больше паттернов запихнет в свой апп, так что, решает Вася, паттерны это дело важное.

Например, для State Pattern можно сделать интерфейс IState:

public interface IState {
    void Event1();
    void Event2();
}

И по отдельному классу для каждого состояния, которые этот интерфейс имплементят. В теории выглядит красиво и 100% по учебнику.

Но, во-первых, для каждой несчастной мелкой стейт машины нужно городить уйму классов, что само по себе небыстро. Во-вторых, рано или поздно начнутся проблемы с доступом к общим данным. Где их хранить? В основном классе? А как классы-состояния получат к ним доступ? А как мне тут за 15 минут перед дедлайном впилить быстро мелкий хак в обход правил? И подобные проблемы взаимодействия, которые будут сильно тормозить разработку.

Реализация на основе особенностей языка

Некоторые языки программирования облегчают решение тех или иных задач. В Ruby, например, так вообще есть целый DSL (и не один) для создания конечных автоматов. А в C# конечный автомат можно упростить через Reflection. Вот как-то так:

  1. наследуемся от класса FiniteStateMachine,
  2. создаем методы с названием stateName_eventName(), которые автоматически вызываются при переходе по состояниям и при обработке событий

Лишнего кода писать действительно приходится сильно меньше.

Реализовав систему описанную выше, Вася понимает, что у нее тоже больше минусов, чем плюсов:

  • Нужно наследоваться от класса FiniteStateMachine,
  • В реакциях на кастомные события также нужно писать большие switch конструкции,
  • Нет возможности передать параметры при изменении состояния.

Фреймворк

А тем временем, Вася уже вовсю стал вникать в теорию стейт машин и решил, что хорошо бы иметь возможность формально их описывать через API или (о Боже) через XML, что в теории звучит круто. Мы-то с вами тут программисты опытные, все понимаем, что нужно писать свой фреймворк. Потому что другие не подходят, так как у всех у них есть один фатальный недостаток.

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

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

Вот, например, описание конечного автомата фреймворком stateless:

var phoneCall = new StateMachine<State, Trigger>(State.OffHook);

phoneCall.Configure(State.OffHook)
    .Permit(Trigger.CallDialed, State.Ringing);
        
phoneCall.Configure(State.Ringing)
    .Permit(Trigger.HungUp, State.OffHook)
    .Permit(Trigger.CallConnected, State.Connected);
 
phoneCall.Configure(State.Connected)
    .OnEntry(() => StartCallTimer())
    .OnExit(() => StopCallTimer())
    .Permit(Trigger.LeftMessage, State.OffHook)
    .Permit(Trigger.HungUp, State.OffHook)
    .Permit(Trigger.PlacedOnHold, State.OnHold);

Но, пробившись через создание стейт машины, можно воспользоваться полезными функциями, которые предоставляет фреймворк. В основном это: проверка правильности переходов, синхронизация зависимых стейт машин и суб-стейт машин и всяческая защита от дурака.

XML

XML — это отдельное зло. Кто-то когда-то придумал использовать его для написания конфигов. Стадо леммингов java разработчиков длительное время молилось на него. А теперь никто уже и не знает зачем все используют XML, но продолжают бить всех, кто пытается от него избавиться.

Вася тоже загорелся идеей, что можно все сконфигурировать в XML и НЕ ПИСАТЬ НИ СТРОЧКИ КОДА! В итоге в его фреймворке отдельно лежат XML файлы примерно такого содержания:

<fsm name="Vending Machine">
    <states>
        <state name="start">
            <transition input="nickel" next="five" />
            <transition input="dime" next="ten" />
            <transition input="quarter" next="start" action="dispense" />
        </state>
        <state name="five">
            <transition input="nickel" next="ten" />
            <transition input="dime" next="fifteen" />
            <transition input="quarter" next="start" action="dispense" />
        </state>
        <state name="ten">
            <transition input="nickel" next="fifteen" />
            <transition input="dime" next="twenty" />
            <transition input="quarter" next="start" action="dispense" />
        </state>
        ...
    </states>
</fsm>

Класс! И никакого программирования. Но, мы-то с вами тут программисты опытные, все понимаем, что программирование никуда не ушло. Вася заменил кусок императивного кода на кусок декларативного кода, добавив при этом во фреймворк интерпретатор XML, который все еще в пару раз усложнил. А потом попробуй это отдебажить, когда код на разных языках и разбросан по проекту.

Соглашение

И тут Васе все это надоело и он вернулся обратно к самому простому в мире конечному автомату. Он его немного переделал и придумал правила как писать в нем код. Получилось следующее:

/**
* Названия состояний описываются enum или строковыми константами, если язык не поддерживает enums.
*/ 
private enum State { Disabled, Idle, Animating }
 
/**
* Текущее состояние всегда скрыто. Иногда, бывает полезно добавить еще и переменную с предыдущим состоянием.
*/ 
private State state;
 
/**
* Все смены состояний происходят только через вызов методов state<название состояния>().
* В них сперва может быть выполнена логика для выхода из предыдущего состояния.
* После чего выполняется setState(newValue) и специфическая для состояния логика.
*/ 
void stateDisabled() {
    switch (state) {
        case State.Idle:
        break;
    }
    setState(State.Disabled);
    // State Disabled enter logic
}

/**
* У функций смены состояний могут быть параметры.
* stateIdle(0);
*/
void stateIdle(int data) {
    setState(State.Idle);
    // State Idle enter logic
}
 
void stateAnimating() {
    setState(State.Animating);
    // State Animating enter logic
}
 
/**
* Обычно setState состоит только из
* state = value;
* или еще prevState = state; если нужно хранить предыдущее состояние.
* Но, также здесь может быть общая логика для выхода из предыдущего состояния.
*/
void setState(State value) {
    switch ( state ) {
        case State.Animating:
            // state Animating exit logic
        break;
        // other states
    }
    state = value;
}

/**
* Обработчики событий делают только то, что можно в текущем состоянии.
*/
 
void event1Handler() {
    switch (state) {
        case State.Idle:
            // state Idle logic
        break;
        // other states
    }
}

Простое соглашение для написания стейт машин. Оно достаточно гибкое и имеет следующие плюсы:

  • почти вся логика при смене состояний находится в методах stateA(), что позволяет разбить гигантский switch в setState() и сделать код более читаемым,
  • смена состояния происходит только через методы stateA(), что облегчает отладку,
  • новому состоянию легко можно передавать параметры, например, если у книги есть состояние Page, то перейти на новую страницу можно просто сменив состояния вызвав statePage(42)
  • в обработчиках событий всегда понятно какая логика выполняется в каких состояниях,
  • все члены команды знают где писать логику для входа и выхода из состояния,
  • нет необходимости в каком-то фреймворке и предварительной конфигурации конечного автомата,
  • есть возможность грязно все похачить в последний момент, если уж совсем подругому никак.

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

Заключение

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

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

Я надеюсь, что эта статья поможет разработчикам задуматься где и когда стоит использовать паттерны и фреймворки, и что описанное соглашение по оформлению стейт машин окажется кому-то полезным.

Автор: valyard

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js