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

оригинал: Andrew 'hoverbear' Hobden • перевод: Илья Богданов • обучение • поддержите на Patreon

teaser

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

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

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

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

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

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

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

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

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

1
2
3
4
5
6
struct Machine;

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

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

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

1
2
3
4
5
6
7
8
9
enum BottleFillerState {
    Waiting { waiting_time: std::time::Duration },
    Filling { rate: usize },
    Done
}

struct BottleFiller {
    state: BottleFillerState
}

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

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

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

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

1
2
3
4
5
6
7
  +----------+   +------------+   +-----------+
  |          |   |            |   |           |
  | Ожидание +-->+ Заполнение +-->+ Выполнено |
  |          |   |            |   |           |
  +----+-----+   +------------+   +--+--------+
       ^                             |
       +-----------------------------+

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

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

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

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

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

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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();
}

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

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// Общая для каждого состояния функциональность
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() поглотит состояние «Ожидание» и совершит переход в состояние «Заполнение». Давайте кратко изложим все:

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

1
2
3
4
5
6
7
8
9
10
11
12
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 предпочтительнее, так как она немного более гибкая. Мы можем реализовать их очень легко для нашего предыдущего примера:

1
2
3
4
5
6
7
8
9
10
// ...
impl From<Waiting> for Filling {
    fn from(val: Waiting) -> Filling {
        Filling {
            rate: 1,
            shared_value: val.shared_value,
        }
    }
}
// ...

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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> для некоторых определённых вариантов, примерно вот так:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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
        }
    }
}

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

1
2
3
4
5
6
7
8
9
10
impl BottleFillingMachine<Waiting> {
    fn new(shared_value: usize) -> Self {
        BottleFillingMachine {
            shared_value: shared_value,
            state: Waiting {
                waiting_time: std::time::Duration::new(0, 0)
            }
        }
    }
}

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
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`

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

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

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

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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, посмотри на эти длинные, ужасные объявления типов». Вы совершенно правы! Честно говоря, они действительно длинные, но я выбирал максимально понятные названия типов! Вы можете использовать все ваши любимые аббревиатуры и псевдонимы в вашем коде. Смотрите!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
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. Вот диаграмма состояний и переходов для этого конечного автомата.

1
2
3
4
5
6
7
+----------+    +-----------+    +--------+
|          +---->           |    |        |
| Follower |    | Candidate +----> Leader |
|          <----+           |    |        |
+--------^-+    +-----------+    +-+------+
         |                         |
         +-------------------------+

Ссылка на Rust Playground

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
// Можете поиграться с этой функцией
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 andrew@hoverbear.org, @Hoverbear на Github, @andrewhobden на Twitter