Важно: для комфортного прочтения статьи нужно уметь читать исходный код на Rust и понимать, почему оборачивать всё в Rc<RefCell<...>> — плохо.


Введение


Rust не принято считать объектно-ориентированным языком: в нём нет наследования реализации; инкапсуляции на первый взгляд тоже нет; наконец, столь привычные ООП-адептам графы зависимостей мутабельных объектов здесь выглядят максимально уродливо (вы только посмотрите на все эти Rc<RefCell<...>> и Arc<Mutex<...>>!)


Правда, наследование реализации уже как несколько лет считают вредным, а гуру ООП говорят очень правильные вещи вроде "хороший объект — иммутабельный объект". Вот мне и стало интересно: насколько хорошо объектное мышление и Rust сочетаются друг с другом на самом деле?


Первым подопытным кроликом станет паттерн State, чистой реализации которого и посвящена эта статья.


Он был выбран не просто так: этому же паттерну посвящена глава из The Rust Book. Цель той главы была в том, чтобы показать, что объектно-ориентированный код на Rust пишут только плохие мальчики и девочки: здесь вам и лишний Option, и тривиальные реализации методов нужно копипастить во все реализации типажа. Но стоит применить пару трюков, и весь бойлерплейт пропадёт, а читаемость — повысится.


Масштаб работ


В оригинальной статье моделировался workflow поста в блоге. Проявим фантазию и адаптируем исходное описание под суровые русские реалии:


  1. Любая статья на Хабре когда-то была пустым черновиком, который автор должен был наполнить содержимым.
  2. Когда статья готова, она отправляется на модерацию.
  3. Как только модератор одобрит статью, она публикуется на Хабре.
  4. Пока статья не опубликована, пользователи не должны видеть её содержимое.

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


Листинг ниже демонстрирует код, соответствующий описанию выше.


// main.rs

use article::Article;

mod article;

fn main() {
    let mut article = Article::empty();

    article.add_text("Rust не принято считать ООП-языком");
    assert_eq!(None, article.content());

    article.send_to_moderators();
    assert_eq!(None, article.content());

    article.publish();
    assert_eq!(Some("Rust не принято считать ООП-языком"), article.content());
}

Article пока выглядит следующим образом:


// article/mod.rs

pub struct Article;

impl Article {
    pub fn empty() -> Self {
        Self
    }

    pub fn add_text(&self, _text: &str) {
        // no-op
    }

    pub fn content(&self) -> Option<&str> {
        None
    }

    pub fn send_to_moderators(&self) {
        // no-op
    }

    pub fn publish(&self) {
        // no-op
    }
}

Это проходит все ассерты, кроме последнего. Неплохо!


Реализация паттерна


Добавим пока пустой типаж State, состояние Draft и пару полей в Article:


// article/state.rs

pub trait State {
    // empty
}

// article/states.rs

use super::state::State;

pub struct Draft;

impl State for Draft {
    // nothing
}

// article/mod.rs

use state::State;
use states::Draft;

mod state;
mod states;

pub struct Article {
    state: Box<dyn State>,
    content: String,
}

impl Article {
    pub fn empty() -> Self {
        Self {
            state: Box::new(Draft),
            content: String::new(),
        }
    }

    // ...
}

Беды с башкой дизайном


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


trait State {
    fn send_to_moderators(&mut self) -> &dyn State;
}

Очевидно, это не подойдёт, потому что единственная валидная ссылка, которую можно будет вернуть из такой функции — это ссылка на себя.


А если хранить состояние в куче?


pub trait State {
    fn send_to_moderators(&mut self) -> Box<dyn State>;
}

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


В оригинальном туториале было выбрано следующее решение:


pub trait State {
    fn send_to_moderators(self: Box<Self>) -> Box<dyn State>;
}

Но у этого решения есть один серьёзный недостаток: мы не можем сделать его автоматическую имплементацию (возвращать self). Потому что для этого нужно, чтобы Self: Sized, т.е. размер объекта был фиксирован и известен на момент компиляции. Но это лишает нас возможности создавать trait object, т.е. никакого динамического диспатча не будет.


Решение


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


P.S.: это решение честно подсмотрено в игровом движке Amethyst.


use crate::article::Article;

pub trait State {
    fn send_to_moderators(&mut self) -> Transit {
        Transit(None)
    }
}

pub struct Transit(pub Option<Box<dyn State>>);

impl Transit {
    pub fn to(state: impl State + 'static) -> Self {
        Self(Some(Box::new(state)))
    }

    pub fn apply(self, article: &mut Article) -> Option<()> {
        article.state = self.0?;
        Some(())
    }
}

Теперь мы, наконец, готовы реализовать эту функцию для Draft:


// article/states.rs

use super::state::{State, Transit};

pub struct Draft;

impl State for Draft {
    fn send_to_moderators(&mut self) -> Transit {
        Transit::to(PendingReview)
    }
}

pub struct PendingReview;

impl State for PendingReview {
    // nothing
}

// article/mod.rs

impl Article {
    // ...
    pub fn send_to_moderators(&mut self) {
        self.state.send_to_moderators().apply(self);
    }
    // ...
}

Осталось совсем чуть-чуть


Добавление состояния для опубликованной статьи тривиально: добавляем структуру Published, реализуем для неё типаж State, добавляем в этот типаж метод publish и переопределяем его для PendingReview. Ещё нужно не забыть вызвать этот метод внутри Article::publish :)


Осталось делегировать управление контентом статьи состояниям. Добавим метод content в типаж State, переопределим реализацию для Published и, собственно, делегируем управление контентом из Article:


// article/mod.rs

impl Article {
    // ...
    pub fn content(&self) -> Option<&str> {
        self.state.content(self)
    }
    // ...
}

// article/state.rs

pub trait State {
    // ...
    fn content<'a>(&self, _article: &'a Article) -> Option<&'a str> {
        None
    }
}

// article/states.rs

impl State for Published {
    fn content<'a>(&self, article: &'a Article) -> Option<&'a str> {
        Some(&article.content)
    }
}

Хмм, почему же ассерт всё ещё вызывает панику? Ах да, мы же забыли само действие добавления текста!


impl Article {
    // ...
    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }
    // ...
}

(Голосом Лапенко) Как говорят в Америке, быстро и грязно.


Все ассерты работают! Работа сделана!


Однако, если бы наш Article публиковался не на Хабре, а на каком-то другом ресурсе, вполне могло бы оказаться, что менять текст уже опубликованной статьи нельзя. Что тогда делать? Делегировать работу состояниям, конечно же! Но это мы оставим в качестве упражнения пытливым читателям.


Вместо заключения


Исходный код можно найти в этом репо.


Как мы видим на примере этой задачи, перенос ООП-паттернов в Rust не только реален, но и приносит не так много головной боли, как может показаться. Достаточно смотреть на вопрос чуть-чуть по-другому.


В следующих статьях, если они будут, я хочу разобрать ещё несколько самых интересных для переноса в Rust паттернов. Например, Observer: я пока вообще без понятия, как там обойтись без Arc<Mutex<...>>!


Спасибо за внимание, до скорых встреч.