Перевод статьи Andrew Hobden "Pretty State Machine Patterns in Rust". Ссылка на оригинал в конце. Последнее время я много размышлял о шаблонах проектирования и приемах, которые мы используем в программировании. Это и в самом деле прекрасно — начать исследовать проект и видеть знакомые шаблоны и стили, которые ты уже не раз встречал. Это облегчает понимание проекта и дает возможность ускорить работу. Иногда ты работаешь над новым проектом и понимаешь, что тебе нужно сделать что-то также, как ты делал это в прошлом проекте. Это может быть не часть функционала или библиотека, это может быть то, что нельзя обернуть в изящный макрос или маленький контейнер. Это может быть просто шаблон проектирования или структурная концепция, которые хорошо решают проблему. Один интересный шаблон, часто применяемый к таким проблемам — "Конечный автомат". Предлагаю потратить немного времени, чтобы понять, что именно имеется ввиду под этим словосочетанием, и почему же это так интересно. На протяжении всей статьи вы можете запускать все примеры на Rust Playground, я обычно использую Nightly версию по привычке. В Интернете существует огромное количество ресурсов и тематических статей о конечных автоматах. Более того, существует множество их реализаций. Вы использовали один их них, просто чтобы попасть на эту страницу. Вы можете смоделировать протокол TCP с помощью конечного автомата. Вы также можете моделировать HTTP запросы с его помощью. Вы можете смоделировать любой регулярный язык как конечный автомат, например язык регулярных выражений (REGEX). Они везде, спрятанные внутри вещей, которые мы используем каждый день. Итак, конечный автомат — это любой "автомат", который имеет набор "состояний" и "переходов" между ними. Когда мы говорим о автомате, мы имеем в виду абстрактную концепцию того, что что-то делает. Например, ваша функция "Привет, мир!" — автомат. Он включается и в конечном итоге производит того, что мы ожидаем. Также ведут себя и модели, с помощью которых вы взаимодействуете с вашей базой данных. Мы рассмотрим наш базовый автомат как обыкновенную структуру, которую можно создать и уничтожить. Состояния — это способ объяснить, в каком месте процесса находится конечный автомат. Например, мы можем представить автомат, заполняющий бутылки. Этот автомат находится в состоянии "ожидание", когда ожидает новую бутылку. Как только он обнаруживает бутылку, то переходит в состояние "заполнение". Сразу после заполнения бутылки нужным количеством воды автомат переходит в состояние "выполнено". Он возвращается в состояние "ожидание", как только бутылку забирают. Главный вывод из этого состоит в том, что ни одно состояние не имеет никакой информации, которая относится к другим состояниям. Состояние "заполнение" не заботится о том, насколько долго автомат был в состоянии "ожидание". Состояние "выполнено" не заботится о степени заполненности бутылок. Каждое состояние имеет строго определенные обязанности и проблемы. Естественный способ рассмотрения этих вариантов — enum. Использование enum таким образом означает, что состояния взаимоисключающие, вы можете находится только в одном состоянии в конкретный момент времени. "Fat enums" в Rust позволяют каждому состоянию хранить в себе необходимую информацию. До тех пор, пока наше определение объявлено таким образом, всё в полном порядке. Но существует одна маленькая проблема. Когда мы описывали наш автомат выше, мы описали три перехода между состояниями: Ожидание → Заполнение, Заполнение → Выполнено и Выполнено → Ожидание. Мы не учитывали Ожидание → Выполнено или Выполнено → Заполнение, они просто не имеют смысла! Это подводит нас к идеи о переходах. Одна из самых приятных особенностей истинного конечного автомата — это то, что нам никогда не придется заботиться о таких переходах как Выполнено -> Заполнение. Шаблон проектирования конечного автомата должен обеспечить невозможность такого перехода. В идеале это произойдет еще до того, как мы запустим наш автомат — в момент компиляции программы. Давайте еще раз рассмотрим наши переходы в диаграмме: Как мы можем увидеть, существует конечное число состояний, и конечное число переходов между состояниями. На данный момент мы можем совершенно законно совершать переходы между каждым состоянием в любое другое состояние, но в большинстве случаев это неправда. Это означает, что переход между состоянием "Ожидание" в состояние "Заполнение" должен удовлетворять определенному правилу. В нашем примере это правило может иметь вид "Бутылка установлена на место". В случае TCP потока это будет "Мы получили FIN-пакет", что означает, что нам нужно завершить передачу, закрыв поток. Теперь, когда мы знаем, что такое конечный автомат, как нам реализовать его в Rust? Для начала, давайте подумаем о том, чего мы хотим. В идеале, мы хотели бы увидеть следующие характеристики: Итак, если бы мы имели шаблон, удовлетворяющий всем этим требованиям, это было бы поистине фантастично. Ну а шаблон, подходящий только под часть требований, будет тоже неплох. С такой мощной и гибкой системой типов, как в Rust, мы должны быть способны реализовать это. Истина такова: есть несколько способов, каждый из которых предлагает нам определенные преимущества и преподает нам урок. Как мы уже знаем, самым естественным способом являются enum, но мы уже замечали, что не можем запрещать переходы в этом случае. Но можем ли мы всего лишь обернуть их в структуру? Конечно можем! Взгляните: На первый взгляд все в порядке. Но замечаете ли вы некоторые проблемы? Однако этот подход имеет и некоторые преимущества: Сейчас вы можете подумать: "Позвольте, Hoverbear, вы же можете обернуть вывод to_filling() в Result<T,E> или добавить в enum опцию InvalidState!". Но давайте посмотрим правде в глаза: это не намного улучшит ситуацию, если вообще улучшит. Даже если мы избавимся от сбоев во время выполнения, нам все равно придется иметь дело с неуклюжими выражениями сопоставления с образцом, и наши ошибки по прежнему будут обнаружены только после запуска программы! Фу! Мы можем сделать лучше, я обещаю. Так что продолжим поиски! Что, если мы просто будем использовать набор структур? Мы можем определить для каждой из них набор типажей, общих для каждого состояния. Мы можем использовать специальные функции, превращающие один тип в другой! Как это будет выглядеть? Черт возьми, сколько кода! Таким образом, идея заключалась в том, что все состояния имеют как общие для всех состояний данные, так и свои собственные. Как вы можете заметить, функция to_filling() поглотит состояние "Ожидание" и совершит переход в состояние "Заполнение". Давайте кратко изложим все: Существуют и некоторые недостатки: Как вы можете заметить, это не очень удобно. Мы все ближе к тому, чего хотим. Идея перехода между определенными типами кажется большим шагом вперед! Прежде чем мы попробуем что-нибудь совершенно иное, давайте поговорим о том, как изменить наш пример, который может упростить дальнейшие размышления. Стандартная библиотека Rust предоставляет два очень важных типажа: From и Into, которые чрезвычайно полезны и заслуживают упоминания. Важно заметить, что реализация одного из них автоматически реализует другой. В целом реализация From предпочтительнее, так как она немного более гибкая. Мы можем реализовать их очень легко для нашего предыдущего примера: Это не только дает нам общую функцию перехода, но еще и гораздо проще для чтения, когда встречаешь такое в исходном коде! Это уменьшает психологическую нагрузку и облегчает восприятие читателям. Вместо реализации собственных функций мы используем уже существующий шаблон. Основание наших шаблонов на базе уже существующих — отличное решение. Так что это круто, на как нам справится с раздражающим повторением кода и shared_value повсюду? Давайте изучим еще немного! Сейчас мы соберем вместе уроки и идеи из первых двух способов, добавим немного новых идей, и получим нечто более приятное. Суть этого метода заключается в использовании силы обобщенных типов. Давайте рассмотрим довольно базовую структуру: Итак, мы фактически встраиваем состояние конечного автомата в сигнатуру BottleFillingMachine. Конечный автомат в состоянии "Заполнение" будет BottleStateMachine<Filling>, что просто великолепно, потому что, когда мы видим этот тип как часть сообщения об ошибке или чего-то подобного, мы сразу же знаем текущее состояние автомата. Мы можем продолжить и реализовать From<T> для некоторых определенных вариантов, примерно вот так: Определение исходного состояния автомата выглядит так: А как же выглядит смена состояний? Вот так: В случае, если вы делаете это внутри функции, сигнатура которой ограничивает выходной тип: А что насчет вида сообщений об ошибках на этапе компиляции? Вполне понятно, что здесь не так. Сообщение об ошибке даже подсказывает нам верные переходы! Итак, что же нам дает такой подход? По прежнему есть недостатки: Можете поиграться с этим примером здесь Примечание переводчика: перевод этого заголовка, любезно предоставленный Google Translator, настолько великолепен, что я предпочел оставить его именно таким. Как же мы можем организовать родительскую структуру для хранения состояния конечного автомата без жутких проблем со взаимодействием? Ну, это откатит нас назад к первой идее с enum. Если вы помните, основная проблема с нашим первым подходом заключалась в том, что у нас не было возможности обеспечить переходы, и все ошибки проявляли себя во время выполнения. На данный момент ваша первая реакция скорее всего "Черт, Hoverbear, посмотри на эти длинные, ужасные объявления типов". Вы совершенно правы! Честно говоря, они действительно длинные, но я выбирал максимально понятные названия типов! Вы можете использовать все ваши любимые аббревиатуры и псевдонимы в вашем коде.
Смотрите! Опять же вы можете заметить, что это работает за счет поглощения, а не изменения. Используя match, мы перемещаем val и, таким образом, позволяем .into() использовать его и поглотить предыдущее состояние. Но если вы предпочитаете изменять значения, можете реализовать #[derive(Clone)] или даже Copy для ваших состояний. Несмотря на то, что это несколько менее удобно и приятно для работы, мы по прежнему имеем обеспеченные системой типов переходы и все гарантии, которые приходят с ними. Вы можете заметить, что этот способ вынуждает вас обработать все возможные состояния во время манипуляций с конечным автоматом, и это имеет смысл. Если вы владеете структурой с конечным автоматом и управляете им, вы должны определить действия для каждого состояния, в которых может быть конечный автомат. Или вы можете просто вызвать panic!(), если действительно этого хотите. Но если вы хотите просто panic'овать, то почему бы не использовать самый первый подход? Вы можете увидеть полностью рабочий пример здесь Это именно тот случай, когда примеры не бывают лишними. Так что я собрал пару рабочих примеров ниже и снабдил их комментариями. Этот пример очень похож на нашу заполняющую бутылки машину, но он в самом деле работает, хоть и довольно тривиально. Этот конечный автомат получает строку и возвращает число слов в ней.
Ссылка на Rust Playground Если вы следили за записями в моем блоге в последнее время, вы, возможно, знаете, что я предпочитаю писать о Raft. Именно Raft, а также общение с @argorak подтолкнули меня к проведению этого исследования. Raft несколько сложнее предыдущих примеров, потому что переходы между состояниями не линейны, как A->B->C. Вот диаграмма состояний и переходов для этого конечного автомата. → Ссылка на Rust Playground Я видел интересный комментарий от I-impv с Reddit, который показывал подход, основанный на наших предыдущих примерах. Вот что он сказал: Мне в самом деле нравится идея представлять входные данные в переходах! Rust позволяет нам представлять конечные автоматы красиво и удобно. В идеале мы могли бы создать enum с ограниченными переходами между вариантами, но это невозможно. Вместо этого мы можем использовать мощь обобщенных типов и систему владения, чтобы создать нечто выразительное, безопасное и понятное. Если у вас есть любые комментарии или предложения по поводу этой статьи, я предлагаю вам посмотреть на нижний колонтитул для получения контактов. Я также тусуюсь в IRC Mozilla под ником Hoverbear. Примечание переводчика:
Автор оригинала: Andrew Hobden
Ссылка на оригинал Rust Developer