Pull to refresh

Прекрасные конечные автоматы на Rust

Reading time 16 min
Views 13K

Перевод статьи Andrew Hobden "Pretty State Machine Patterns in Rust". Ссылка на оригинал в конце.


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


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


Один интересный шаблон, часто применяемый к таким проблемам — "Конечный автомат". Предлагаю потратить немного времени, чтобы понять, что именно имеется ввиду под этим словосочетанием, и почему же это так интересно.


На протяжении всей статьи вы можете запускать все примеры на Rust Playground, я обычно использую Nightly версию по привычке.


Обосновываем наши идеи


В Интернете существует огромное количество ресурсов и тематических статей о конечных автоматах. Более того, существует множество их реализаций.


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


Итак, конечный автомат — это любой "автомат", который имеет набор "состояний" и "переходов" между ними.


Когда мы говорим о автомате, мы имеем в виду абстрактную концепцию того, что что-то делает. Например, ваша функция "Привет, мир!" — автомат. Он включается и в конечном итоге производит того, что мы ожидаем. Также ведут себя и модели, с помощью которых вы взаимодействуете с вашей базой данных. Мы рассмотрим наш базовый автомат как обыкновенную структуру, которую можно создать и уничтожить.


struct Machine;

fn main() {
    let my_machine = Machine; // Создание.
    // `my_machine` будет уничтожена, когда выйдет за пределы области видимости.
}

Состояния — это способ объяснить, в каком месте процесса находится конечный автомат. Например, мы можем представить автомат, заполняющий бутылки. Этот автомат находится в состоянии "ожидание", когда ожидает новую бутылку. Как только он обнаруживает бутылку, то переходит в состояние "заполнение". Сразу после заполнения бутылки нужным количеством воды автомат переходит в состояние "выполнено". Он возвращается в состояние "ожидание", как только бутылку забирают.


Главный вывод из этого состоит в том, что ни одно состояние не имеет никакой информации, которая относится к другим состояниям. Состояние "заполнение" не заботится о том, насколько долго автомат был в состоянии "ожидание". Состояние "выполнено" не заботится о степени заполненности бутылок. Каждое состояние имеет строго определенные обязанности и проблемы. Естественный способ рассмотрения этих вариантовenum.


enum BottleFillerState {
    Waiting { waiting_time: std::time::Duration },
    Filling { rate: usize },
    Done
}

struct BottleFiller {
    state: BottleFillerState
}

Использование enum таким образом означает, что состояния взаимоисключающие, вы можете находится только в одном состоянии в конкретный момент времени. "Fat enums" в Rust позволяют каждому состоянию хранить в себе необходимую информацию. До тех пор, пока наше определение объявлено таким образом, всё в полном порядке.


Но существует одна маленькая проблема. Когда мы описывали наш автомат выше, мы описали три перехода между состояниями: Ожидание → Заполнение, Заполнение → Выполнено и Выполнено → Ожидание. Мы не учитывали Ожидание → Выполнено или Выполнено → Заполнение, они просто не имеют смысла!


Это подводит нас к идеи о переходах. Одна из самых приятных особенностей истинного конечного автомата — это то, что нам никогда не придется заботиться о таких переходах как Выполнено -> Заполнение. Шаблон проектирования конечного автомата должен обеспечить невозможность такого перехода. В идеале это произойдет еще до того, как мы запустим наш автомат — в момент компиляции программы.


Давайте еще раз рассмотрим наши переходы в диаграмме:


  +----------+   +------------+   +-----------+
  |          |   |            |   |           |
  | Ожидание +-->+ Заполнение +-->+ Выполнено |
  |          |   |            |   |           |
  +----+-----+   +------------+   +--+--------+
       ^                             |
       +-----------------------------+

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


Это означает, что переход между состоянием "Ожидание" в состояние "Заполнение" должен удовлетворять определенному правилу. В нашем примере это правило может иметь вид "Бутылка установлена на место". В случае TCP потока это будет "Мы получили FIN-пакет", что означает, что нам нужно завершить передачу, закрыв поток.


Определяем, что мы хотим


Теперь, когда мы знаем, что такое конечный автомат, как нам реализовать его в Rust? Для начала, давайте подумаем о том, чего мы хотим.


В идеале, мы хотели бы увидеть следующие характеристики:


  • Может находиться только в одном состоянии в каждый момент времени.
  • Каждое состояние должно иметь свои данные, если это необходимо.
  • Переход между состояниями должен иметь определенную семантику.
  • Должна быть возможность иметь некоторое общее состояние.
  • Должны быть разрешены только явно определенные переходы.
  • Смена одно состояния на другое должна поглотить состояние, если оно больше не может использоваться.
  • Мы не должны выделять память для всех состояний. Потребление памяти должно быть по крайней мере не больше, чем размер наибольшего состояния.
  • Каждое сообщение об ошибке должно быть легким для понимания.
  • Мы не должны прибегать к использовании кучи. Все должно быть размещено на стеке.
  • Система типов должна рассматриваться как наше сильнейшее преимущество.
  • Как можно больше ошибок должно выявляться на этапе компиляции.

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


Исследуем возможные реализации


С такой мощной и гибкой системой типов, как в Rust, мы должны быть способны реализовать это. Истина такова: есть несколько способов, каждый из которых предлагает нам определенные преимущества и преподает нам урок.


Вторая попытка с Enum


Как мы уже знаем, самым естественным способом являются enum, но мы уже замечали, что не можем запрещать переходы в этом случае. Но можем ли мы всего лишь обернуть их в структуру? Конечно можем! Взгляните:


enum State {
    Waiting { waiting_time: std::time::Duration },
    Filling { rate: usize },
    Done
}

struct StateMachine { state: State }

impl StateMachine {
    fn new() -> Self {
        StateMachine {
            state: State::Waiting { waiting_time: std::time::Duration::new(0, 0)}
        }
    }
    fn to_filling(&mut self) {
        self.state = match self.state {
            // Только переход "Ожидание" -> "Заполнение" возможен
            State::Waiting { .. } => State::Filling { rate: 1},
            // Остальные вызовут ошибку
            _ => panic!("Invalid state transition!")
        }
    }
    // ...
}

fn main() {
    let mut state_machine = StateMachine::new();
    state_machine.to_filling();
}

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


  • Ошибка из-за запрещенного перехода произойдет во время выполнения, что ужасно!
  • Это предотвращает только неверные переходы снаружи модуля, потому что приватные поля могут быть свободно изменены изнутри модуля. Например, state_machine.state = State::Done безусловно действует внутри модуля.
  • Каждая наша функция, которая работает с состояниями, должна иметь match выражение.

Однако этот подход имеет и некоторые преимущества:


  • Память, требуемая для представления конечного автомата — размер наибольшего состояния. Это результат использования fat enum, размер которого соответствует размеру наибольшего варианта.
  • Вся память выделяется в стеке, куча не задействована.
  • Переходы между состояниями имеют определенные правила. Это или работает или выдает ошибку!

Сейчас вы можете подумать: "Позвольте, Hoverbear, вы же можете обернуть вывод to_filling() в Result<T,E> или добавить в enum опцию InvalidState!". Но давайте посмотрим правде в глаза: это не намного улучшит ситуацию, если вообще улучшит. Даже если мы избавимся от сбоев во время выполнения, нам все равно придется иметь дело с неуклюжими выражениями сопоставления с образцом, и наши ошибки по прежнему будут обнаружены только после запуска программы! Фу! Мы можем сделать лучше, я обещаю.


Так что продолжим поиски!


Структуры с переходами


Что, если мы просто будем использовать набор структур? Мы можем определить для каждой из них набор типажей, общих для каждого состояния. Мы можем использовать специальные функции, превращающие один тип в другой! Как это будет выглядеть?


// Общая для каждого состояния функциональность
trait SharedFunctionality {
    fn get_shared_value(&self) -> usize;
}

struct Waiting {
    waiting_time: std::time::Duration,
    // Данные, общие для всех состояний
    shared_value: usize
}

impl Waiting {
    fn new() -> Self {
        Waiting {
            waiting_time: std::time::Duration::new(0,0),
            shared_value: 0
        }
    }
    // Поглощаем данные!
    fn to_filling(self) -> Filling {
        Filling {
            rate: 1,
            shared_value: 0
        }
    }
}
impl SharedFunctionality for Waiting {
    fn get_shared_value(&self) -> usize {
        self.shared_value
    }
}

struct Filling {
    rate: usize,
    // Общие для всех состояний данные
    shared_value: usize,
}
impl SharedFunctionality for Filling {
    fn get_shared_value(&self) -> usize {
        self.shared_value
    }
}

// ...

fn main() {
    let in_waiting_state = Waiting::new();
    let in_filling_state = in_waiting_state.to_filling();
}

Черт возьми, сколько кода! Таким образом, идея заключалась в том, что все состояния имеют как общие для всех состояний данные, так и свои собственные. Как вы можете заметить, функция to_filling() поглотит состояние "Ожидание" и совершит переход в состояние "Заполнение". Давайте кратко изложим все:


  • Ошибки переходов определяются во время компиляции! Например, вы не сможете даже создать состояние "Заполнение" случайно без предварительного создания состояния "Ожидание". (На самом деле вы можете, но это не относится к делу)
  • Обязателен переход между состояниями.
  • Во время перехода между состояниями старое значение поглощается вместо простого изменения.
    Правда, мы могли сделать то же и с помощью enum из нашей первой попытки.
  • Нам не нужны постоянные match.
  • Потребление памяти по прежнему не вызывает нареканий. Нам требуется только размер текущего
    состояния.

Существуют и некоторые недостатки:


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

enum State {
    Waiting(Waiting),
    Filling(Filling),
    Done(Done)
}

fn main() {
    let in_waiting_state = State::Waiting(Waiting::new());
    // Это не будет работать, так как `Waiting` обернута в `enum`!
    // Мы должны использовать `match` чтобы получить желаемое
    let in_filling_state = State::Filling(in_waiting_state.to_filling());
}

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


Стандартная библиотека Rust предоставляет два очень важных типажа: From и Into, которые чрезвычайно полезны и заслуживают упоминания. Важно заметить, что реализация одного из них автоматически реализует другой. В целом реализация From предпочтительнее, так как она немного более гибкая. Мы можем реализовать их очень легко для нашего предыдущего примера:


// ...
impl From<Waiting> for Filling {
    fn from(val: Waiting) -> Filling {
        Filling {
            rate: 1,
            shared_value: val.shared_value,
        }
    }
}
// ...

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


Так что это круто, на как нам справится с раздражающим повторением кода и shared_value повсюду? Давайте изучим еще немного!


Почти идеально


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


struct BottleFillingMachine<S> {
    shared_value: usize,
    state: S
}

// Следующие состояния могут быть `S` в StateMachine<S>

struct Waiting {
    waiting_time: std::time::Duration
}

struct Filling {
    rate: usize
}

struct Done;

Итак, мы фактически встраиваем состояние конечного автомата в сигнатуру BottleFillingMachine. Конечный автомат в состоянии "Заполнение" будет BottleStateMachine<Filling>, что просто великолепно, потому что, когда мы видим этот тип как часть сообщения об ошибке или чего-то подобного, мы сразу же знаем текущее состояние автомата.


Мы можем продолжить и реализовать From<T> для некоторых определенных вариантов, примерно вот так:


impl From<BottleFillingMachine<Waiting>> for BottleFillingMachine<Filling> {
    fn from(val: BottleFillingMachine<Waiting>) -> BottleFillingMachine<Filling> {
        BottleFillingMachine {
            shared_value: val.shared_value,
            state: Filling {
                rate: 1
            }
        }
    }
}

impl From<BottleFillingMachine<Filling>> for BottleFillingMachine<Done> {
    fn from(val: BottleFillingMachine<Filling>) -> BottleFillingMachine<Done> {
        BottleFillingMachine {
            shared_value: val.shared_value,
            state: Done
        }
    }
}

Определение исходного состояния автомата выглядит так:


impl BottleFillingMachine<Waiting> {
    fn new(shared_value: usize) -> Self {
        BottleFillingMachine {
            shared_value: shared_value,
            state: Waiting {
                waiting_time: std::time::Duration::new(0, 0)
            }
        }
    }
}

А как же выглядит смена состояний? Вот так:


fn main() {
    let in_waiting = BottleFillingMachine::<Waiting>::new(0);
    let in_filling = BottleFillingMachine::<Filling>::from(in_waiting);
}

В случае, если вы делаете это внутри функции, сигнатура которой ограничивает выходной тип:


fn transition_the_states(val: BottleFillingMachine<Waiting>) -> BottleFillingMachine<Filling> {
    val.into() // Мило, не правда ли?
}

А что насчет вида сообщений об ошибках на этапе компиляции?


error[E0277]: the trait bound `BottleFillingMachine<Done>: std::convert::From<BottleFillingMachine<Waiting>>` is not satisfied
  --> <anon>:50:22
   |
50 |     let in_filling = BottleFillingMachine::<Done>::from(in_waiting);
   |                      ^^^^^^^^^^^^^^^^^^^^^^^^^^
   |
   = help: the following implementations were found:
   = help:   <BottleFillingMachine<Filling> as std::convert::From<BottleFillingMachine<Waiting>>>
   = help:   <BottleFillingMachine<Done> as std::convert::From<BottleFillingMachine<Filling>>>
   = note: required by `std::convert::From::from`

Вполне понятно, что здесь не так. Сообщение об ошибке даже подсказывает нам верные переходы!


Итак, что же нам дает такой подход?


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

По прежнему есть недостатки:


  • Наши реализации From<T> страдают от некоторой "захламленности типами". Однако, это достаточно мелкое неудобство.
  • Каждый BottleFillingMachine<S> имеет разный размер, поэтому мы по прежнему должны использовать enum. И все же это не является серьезным недостатком из-за нашей структуры.

Можете поиграться с этим примером здесь


Грязные отношения с родителями


Примечание переводчика: перевод этого заголовка, любезно предоставленный Google Translator, настолько великолепен, что я предпочел оставить его именно таким.


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


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


enum BottleFillingMachineWrapper {
    Waiting(BottleFillingMachine<Waiting>),
    Filling(BottleFillingMachine<Filling>),
    Done(BottleFillingMachine<Done>)
}
struct Factory {
    bottle_filling_machine: BottleFillingMachineWrapper
}
impl Factory {
    fn new() -> Self {
        Factory {
            bottle_filling_machine: BottleFillingMachineWrapper::Waiting(BottleFillingMachine::new(0))
        }
    }
}

На данный момент ваша первая реакция скорее всего "Черт, Hoverbear, посмотри на эти длинные, ужасные объявления типов". Вы совершенно правы! Честно говоря, они действительно длинные, но я выбирал максимально понятные названия типов! Вы можете использовать все ваши любимые аббревиатуры и псевдонимы в вашем коде.
Смотрите!


impl BottleFillingMachineWrapper {
    fn step(&mut self) -> Self {
        match self {
            BottleFillingMachineWrapper::Waiting(val) => BottleFillingMachineWrapper::Filling(val.into()),
            BottleFillingMachineWrapper::Filling(val) => BottleFillingMachineWrapper::Done(val.into()),
            BottleFillingMachineWrapper::Done(val) => BottleFillingMachineWrapper::Waiting(val.into())
        }
    }
}

fn main() {
    let mut the_factory = Factory::new();
    the_factory.bottle_filling_machine = the_factory.bottle_filling_machine.step();
}

Опять же вы можете заметить, что это работает за счет поглощения, а не изменения. Используя match, мы перемещаем val и, таким образом, позволяем .into() использовать его и поглотить предыдущее состояние. Но если вы предпочитаете изменять значения, можете реализовать #[derive(Clone)] или даже Copy для ваших состояний.


Несмотря на то, что это несколько менее удобно и приятно для работы, мы по прежнему имеем обеспеченные системой типов переходы и все гарантии, которые приходят с ними.


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


Или вы можете просто вызвать panic!(), если действительно этого хотите. Но если вы хотите просто panic'овать, то почему бы не использовать самый первый подход?


Вы можете увидеть полностью рабочий пример здесь


Рабочие примеры


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


Три состояния, два перехода


Этот пример очень похож на нашу заполняющую бутылки машину, но он в самом деле работает, хоть и довольно тривиально. Этот конечный автомат получает строку и возвращает число слов в ней.
Ссылка на Rust Playground


fn main() {
    // Здесь подразумевается <StateA>. Мы не нуждаемся в явных объявлениях типов!
    let in_state_a = StateMachine::new("Бла бла бла".into());

    // Сейчас все впорядке, но после смены состояния такое уже будет невозможно
    in_state_a.some_unrelated_value;
    println!("Стартовое значение: {}", in_state_a.state.start_value);

    // Переход в новое состояние. Старое состояние поглощается
    // Здесь нам нужно объявление типов
    let in_state_b = StateMachine::<StateB>::from(in_state_a);

    // Это не работает! Значение было перемещено при переходе!
    // in_state_a.some_unrelated_value;
    // Однако мы можем получить это же значение из нового состояния
    in_state_b.some_unrelated_value;

    println!("Промежуточное значение: {:?}", in_state_b.state.interm_value);

    // И наше заключительное состояние
    let in_state_c = StateMachine::<StateC>::from(in_state_b);

    // И это тоже не работает! Этого значения нет в текущем состоянии!
    // in_state_c.state.start_value;

    println!("Конечное значение: {}", in_state_c.state.final_value);
}

// Наш милый конечный автомат
struct StateMachine<S> {
    some_unrelated_value: usize,
    state: S
}

// Он начинает работу в состоянии А
impl StateMachine<StateA> {
    fn new(val: String) -> Self {
        StateMachine {
            some_unrelated_value: 0,
            state: StateA::new(val)
        }
    }
}

// Состояние А запускает конечный автомат со строкой
struct StateA {
    start_value: String
}
impl StateA {
    fn new(start_value: String) -> Self {
        StateA {
            start_value: start_value,
        }
    }
}

// Состояние B разбивает строку на слова
struct StateB {
    interm_value: Vec<String>,
}
impl From<StateMachine<StateA>> for StateMachine<StateB> {
    fn from(val: StateMachine<StateA>) -> StateMachine<StateB> {
        StateMachine {
            some_unrelated_value: val.some_unrelated_value,
            state: StateB {
                interm_value: val.state.start_value.split(" ").map(|x| x.into()).collect(),
            }
        }
    }
}

// Наконец, состояние С вычисляет нам длину вектора, или количество слов в исходной строке
struct StateC {
    final_value: usize,
}
impl From<StateMachine<StateB>> for StateMachine<StateC> {
    fn from(val: StateMachine<StateB>) -> StateMachine<StateC> {
        StateMachine {
            some_unrelated_value: val.some_unrelated_value,
            state: StateC {
                final_value: val.state.interm_value.len(),
            }
        }
    }
}

Raft


Если вы следили за записями в моем блоге в последнее время, вы, возможно, знаете, что я предпочитаю писать о Raft. Именно Raft, а также общение с @argorak подтолкнули меня к проведению этого исследования.


Raft несколько сложнее предыдущих примеров, потому что переходы между состояниями не линейны, как A->B->C. Вот диаграмма состояний и переходов для этого конечного автомата.


+----------+    +-----------+    +--------+
|          +---->           |    |        |
| Follower |    | Candidate +----> Leader |
|          <----+           |    |        |
+--------^-+    +-----------+    +-+------+
         |                         |
         +-------------------------+

Ссылка на Rust Playground


// Можете поиграться с этой функцией
fn main() {
    let is_follower = Raft::new(/* ... */);
    // Как правило, используется 3, 5 или 7 узлов Raft. Но для примера обойдемся одним :)

    // Симулируем этот узел для начала
    let is_candidate = Raft::<Candidate>::from(is_follower);

    // Он победил! Как неожиданно
    let is_leader = Raft::<Leader>::from(is_candidate);

    // Затем он терпит неудачу и вновь становится Follower
    let is_follower_again = Raft::<Follower>::from(is_leader);

    // И идет на выборы...
    let is_candidate_again = Raft::<Candidate>::from(is_follower_again);

    // Но в этот раз неудачно!
    let is_follower_another_time = Raft::<Follower>::from(is_candidate_again);
}

// Это наш конечный автомат
struct Raft<S> {
    // ... общие данные
    state: S
}

// Три состояния, в которых может быть узел Raft

// Если узел является лидером кластера, то он обрабатывает запросы
struct Leader {
    // ... определенные данные состояния
}

// Если это Кандидат, он пытается стать лидером после истечения таймаута или во время инициализации
struct Candidate {
    // ... определенные данные состояния
}

// Иначе узел копирует состояние, которое получает
struct Follower {
    // ... определенные данные состояния
}

// Raft начинает в состоянии Follower
impl Raft<Follower> {
    fn new(/* ... */) -> Self {
        // ...
        Raft {
            // ...
            state: Follower { /* ... */ }
        }
    }
}

// Далее мы определяем переходы между состояниями

// Когда у узла срабатывает таймаут, он начинает выборную компанию
impl From<Raft<Follower>> for Raft<Candidate> {
    fn from(val: Raft<Follower>) -> Raft<Candidate> {
        // ... Логика перехода в новое состояние
        Raft {
            // ... attr: val.attr
            state: Candidate { /* ... */ }
        }
    }
}

// Если он не получает достаточное количество голосов, то вновь становится обычным узлом
impl From<Raft<Candidate>> for Raft<Follower> {
    fn from(val: Raft<Candidate>) -> Raft<Follower> {
        // ... логика перехода в новое состояние
        Raft {
            // ... attr: val.attr
            state: Follower { /* ... */ }
        }
    }
}

// В случае победы он становится лидером
impl From<Raft<Candidate>> for Raft<Leader> {
    fn from(val: Raft<Candidate>) -> Raft<Leader> {
        // ... логика перехода в новое состояние
        Raft {
            // ... attr: val.attr
            state: Leader { /* ... */ }
        }
    }
}

// Если лидер отключается, он может переподключиться чтобы обнаружить, что его сместили
impl From<Raft<Leader>> for Raft<Follower> {
    fn from(val: Raft<Leader>) -> Raft<Follower> {
        // ... логика перехода в новое состояние
        Raft {
            // ... attr: val.attr
            state: Follower { /* ... */ }
        }
    }
}

Альтернативные подходы из отзывов


Я видел интересный комментарий от I-impv с Reddit, который показывал подход, основанный на наших предыдущих примерах. Вот что он сказал:


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

Мне в самом деле нравится идея представлять входные данные в переходах!


Заключительные мысли


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


Если у вас есть любые комментарии или предложения по поводу этой статьи, я предлагаю вам посмотреть на нижний колонтитул для получения контактов. Я также тусуюсь в IRC Mozilla под ником Hoverbear.


Примечание переводчика:
Автор оригинала: Andrew Hobden
Ссылка на оригинал

Tags:
Hubs:
+45
Comments 2
Comments Comments 2

Articles