TL; DR; Любой объект с полем статуса - это конечный автомат. Делая конечные автоматы явными в коде, вы можете исключить плохое состояние и определенные классы ошибок, делая программы более понятными для человека. Есть открытые вопросы о явном явлении конечных автоматов в коде как в языковой разработке, так и во фреймворках. JS Promises - это тупик.

Везде, где у вас есть поле с именем status, у вас есть конечный автомат. Государственные машины везде. Но какие они? Как мне узнать один, когда я кодирую его? Что НЕ является государственной машиной? Что наиболее важно, когда я кодирую один, как мне сделать их явными для будущих разработчиков? Каковы распространенные ошибки и компромиссы при создании государственной машины? Существуют ли языки или библиотеки, которые упрощают написание, изменение и понимание моего кода?

Что такое государственный автомат?

Для целей написания:

конечный автомат: объект или процесс со статусом. Этот статус может измениться в зависимости от ввода.

Вот несколько примеров:

Вот еще один пример JavaScript: редуктор redux. Redux использует относительно явный шаблон конечного автомата.

Переходы

Переход - это реакция конечного автомата на ввод. На самом деле это означает, что вы изменили статус, вероятно, потому, что кто-то что-то щелкнул или тикали часы.

Во всех этих примерах вызов publish () переводит состояние в состояние опубликовано, а вызов unpublish () переводит состояние в состояние «не опубликовано». Однако с этим может возникнуть проблема. Мы не можем отменить публикацию () черновиков. Что худшего может случиться?

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

Диаграммы конечных автоматов

Если бы мы составили это для вышеупомянутого поста, мы бы сразу увидели переход от черновика к неопубликованному. Это более ясно? Как можно перенести эту относительную ясность на код, по своей сути линейный носитель?

В JavaScript самый известный конечный автомат - это обещание. Обещания имеют три состояния (ожидает, разрешено / выполнено и отклонено) и два перехода (разрешить / выполнить () и отклонить ()). Вот отличная иллюстрация состояний и переходов обещания JavaScript.

Каждое обещание начинается с ожидания. Тогда это может быть выполнено () - ed или reject () - ed. Однажды выполненная или отвергнутая, она никогда не переходит снова. Обратите внимание, что стрелки переходов односторонние.

Приемлемые и недопустимые состояния

Двойные кружки обозначают «приемлемые» состояния. Диаграмма выше неверна; так как обещание может оставаться в состоянии «Pending» вечно, «Pending» действительно является приемлемым состоянием, и его следует обвести в двойной кружок. Итак, что такое недопустимое состояние?

Практически приемлемое состояние не требует очистки; машину можно просто прекратить. Неприемлемые состояния требуют очистки с помощью другого перехода; возможно, необходимо закрыть соединение с БД, отменить запрос или завершить анимацию. Непризнание неприемлемых состояний - огромный источник ошибок. Это сложно, поскольку 95% состояний в повседневном коде приемлемы. Вместо того, чтобы просто обводить неприемлемые состояния, было бы лучше, если бы они были подчеркнуты тройным обводом красных маркеров, желтой предупреждающей лентой и вертолетов с прожекторами.

Вы знаете, что происходит, когда вы предполагаете…

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

  1. Объект находится в одном и только одном состоянии. Объект никогда не находится «между» состояниями; изменение состояния атомарно.
  2. Вы знаете, в каких состояниях может находиться машина (читайте: я могу перечислить, какие состояния может иметь объект).
  3. Методы и функции могут изменять свое поведение (или быть недействительными) в зависимости от того, в каком состоянии находится машина.

К сожалению, эти предположения часто неверны для «неявных» конечных автоматов.

Обычно сложно определить, какие состояния допустимы и в каких состояниях находится объект. Часто объект имеет несколько флагов, таких как isPublished, isDraft или isUnpublished, и ожидается, что каждый метод, устанавливающий эти флаги, снимет другие соответствующие флаги. Мы делаем ошибки и не всегда обновляем все флаги, тем более что количество состояний увеличивается. Это переводит объект в «плохое состояние». Кроме того, остаются без ответа дизайнерские решения: может ли сообщение быть черновым и неопубликованным? Они в одном штате? Структура состояний важна для понимания программы и ее рассуждений, и она должна быть более явной, чем небольшое разбросание isDraft == true && isPublished == false.

Контрольно-пропускной пункт

Хорошо, пока мы должны согласиться с тем, что:

  • При написании или проверке кода мысль о слове «статус» должна вызвать подавляющую реакцию, чтобы крикнуть «СТАТУС МАШИНА» настолько громко, насколько вы можете (заставляя ваш личный менеджер взглянуть на вас, предположить, что это какой-то аниме-сериал, и вернуться к работе).
  • Наличие нескольких флагов isX - плохая идея, если и только если есть недопустимые комбинации. Скорее всего, в вашем коде и недопустимых состояниях «прячется» конечный автомат.

Неявные проблемы с конечным автоматом

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

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

Для объекта с заданным статусом (читай: с конечным автоматом в заданном состоянии) только определенные действия и переходы «разрешены». Часто, чтобы действительно знать, действует ли метод или функция только в определенных состояниях, вам обычно нужно обратиться к этому методу. Особенно, если ваше состояние не является явным (например, isApproved && publishDate ‹now () &&! IsUnpublished), знание КОГДА метод что-то делает, может потребовать настоящей умственной гимнастики. Вероятно, лучше всего упростить эти условные выражения до "есть ли у этого объекта конечный автомат с этим состоянием?"

Изменится ли статус объекта после выполнения метода над объектом? Разобраться в этом сложно и требует проверки реализации метода. Я рассматриваю это как ответственность в дополнение ко всему, что делает метод. Скорее всего, перемещение конечного автомата и одновременное выполнение того, что делает метод, является нарушением принципа единой ответственности. Кроме того, если при публикации сообщения происходят две вещи, какая из них отвечает за переход или установку состояния публикации? В каком порядке? Имеет ли значение порядок? Есть ли скрытое недопустимое состояние? Это все, что мы должны учитывать, желательно во время разработки, и должны явно знать, когда читаем хороший код.

Где мы размещаем методы, которые работают с конечными автоматами?

В приведенных выше примерах мы помещаем их в сам объект или в метод, который вернул новый объект. Какова вероятность случайного вызова post.publish () уже опубликованного сообщения? У всех нас была ошибка двойной отправки. В лучшем случае это идемпотентно, в худшем - вы случайно удвоите свои расходы на CDN или наполовину загрузите его.

Допустим, вы перемещаете эти методы с объекта в класс или библиотеку Publisher. Это позволяет избежать предыдущей проблемы. Если метод возвращает новый объект с новым состоянием, вы даже можете назвать этот метод «функциональным», хотя я бы назвал его просто неизменным. Но что ты потерял? Вы больше не знаете, какие методы действуют в сообщении. Каждый метод, который хочет опубликовать сообщение, должен знать, какие методы вызывать, когда они это делают. Кроме того, вы больше не знаете, выполняет ли уже переход состояния какой-либо другой метод: в многопоточной среде оба метода могут устанавливать состояние, вызывая состояние гонки.

Кроме того, когда методы несут ответственность за переход состояния, добавление нового состояния может нарушить весь существующий код. Просмотрите любое обсуждение отменяемых обещаний, чтобы увидеть, как наличие неявного конечного автомата сделало почти невозможным добавление нового состояния / перехода отмены. ES2016 async / await будет иметь те же проблемы. Если бы вместо обещаний JS имел языковую поддержку для определения конечных автоматов, присоединения к переходам и вложения конечных автоматов, добавление отмены перехода / состояния было бы намного проще (а события выполнения были бы ортогональными, а также более простой в реализации).

Что делает конечный автомат явным в коде

Когда конечный автомат явным образом указан в коде, верно следующее:

  1. Объект находится в одном и только одном состоянии
  2. Я знаю, какие статусы может иметь объект (то есть я знаю, какие состояния находятся в конечных автоматах объекта)
  3. Я точно знаю, какие методы разрешены в каких состояниях
  4. Я могу предположить, что объект никогда не находится «между» состояниями; изменение состояния атомарно
  5. Я знаю, какие методы всегда изменят состояние
  6. Я знаю, какие методы могут изменить состояние и на каком основании

В реальных системах полезно, чтобы методы «подключались» к изменениям состояния; то есть, когда сообщение опубликовано () - ed или unpublish () - ed, кеш становится недействительным, а временные рамки обновляются. Это одна из полезных частей аспектно-ориентированного программирования (АОП) Java (хотя ею часто злоупотребляют, потому что без соответствующих подключаемых модулей IDE важные побочные эффекты часто упускаются из виду или их трудно осмыслить). В области JavaScript метод then () в Promise используется для объединения двух конечных автоматов, что делает успех линейным, а сбой обрабатывается в разных точках останова.

Языки, фреймворки и библиотеки, поддерживающие явные конечные автоматы

Я нахожу удивительно трудным определять явные конечные автоматы в большинстве языков.

Erlang - это язык, который в значительной степени спроектирован вокруг конечных автоматов. Каждый процесс является собственным конечным автоматом, занимает место в куче и имеет почтовый ящик. Вызов метода - это сообщение, отправленное другому процессу (который может или не может отправлять ответ). Таймауты встроены в язык! Erlang разработан так, чтобы быть максимально устойчивым к ошибкам, поскольку он рассчитан на отказ и в результате минимизирует плохие состояния. (Спасибо, что напомнили мне об этом Скотт Мессинджер)

Что касается языка ООП, Ruby имеет лучшую из известных мне библиотек конечных автоматов - aasm. К сожалению, его непросто перенести на другие языки ООП, поскольку он во многом полагается на работу прокси (динамическое создание методов на лету, когда пользователи вызывают их).

Чтобы добиться той же строгости без прокси, динамические языки передают атомы состояния (также известные как глобальные константы) и используют большие операторы switch. Недавно я столкнулся с приличной библиотекой для redux, фреймворка javascript, которая допускала явные конечные автоматы с именем redux-machine. Redux сам по себе является одним большим конечным автоматом, хотя и без redux-machine, не имеет возможности игнорировать действия без специального кода для каждого обработчика действий и перекладывает эту ответственность на уровень диспетчера (где функции, использующие redux-thunk и redux-saga вынуждены получать статус или сохранять его сами).

Типизированные функциональные языки часто используют неизменяемые структуры данных, типы объединения и сопоставление с образцом, чтобы сделать конечные автоматы «явными», хотя уровень явности спорен, особенно если сопоставление с образцом не обязательно должно быть исчерпывающим.

Вложенность конечных автоматов

Часто в объект в одном состоянии встроен другой конечный автомат. Рассмотрим загрузку страницы. Возможно, перед загрузкой пользователь нажимает на другую страницу. Было ли состояние загрузки страницы? Вызвало ли действие пользователя переход? Нам нужно отменить или повторить анимацию перехода? Есть ли у этой анимации собственный конечный автомат?

Правильное разделение вложенных конечных автоматов сводится к тому, чтобы делать то, что делает его максимально простым для следующего человека. Предположим, что любой метод, работающий с объектом, должен понимать КАЖДОЕ возможное состояние.

Мне, вероятно, следует написать несколько явных примеров вложенных конечных автоматов, но я оставлю это позже (если люди попросят).

Еще кое-что, что вам следует знать

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

Вопросы

Есть ли лучшие методы для явного определения конечных автоматов, чем подходы, используемые erlang, redux / redux-machine или aasm? Мое чутье говорит да. Потребуется ли языковая поддержка и как это будет выглядеть? Эй, я не знаю. Ближайшее, что я смог найти, это STRIPS, но я не сразу увидел способ перенести это на более традиционные языки программирования.

Ничто из того, что мы сделаем, не сделает код с конечными автоматами доказуемо правильным (https://www.youtube.com/watch?v=dWdy_AngDp0 - это обязательное условие для просмотра, хотя и супер пьянящее). Все это предназначено для упрощения работы людей. Действительно ли создание явного конечного автомата снизит сложность, или процедурное мышление станет более человечным и менее сложным по своей сути? PHP - хороший язык или это плохой пример?