Важно: для комфортного прочтения статьи нужно уметь читать исходный код на Rust и понимать, почему оборачивать всё в Rc<RefCell<...>> — плохо.
Введение
Rust не принято считать объектно-ориентированным языком: в нём нет наследования реализации; инкапсуляции на первый взгляд тоже нет; наконец, столь привычные ООП-адептам графы зависимостей мутабельных объектов здесь выглядят максимально уродливо (вы только посмотрите на все эти Rc<RefCell<...>> и Arc<Mutex<...>>!)
Правда, наследование реализации уже как несколько лет считают вредным, а гуру ООП говорят очень правильные вещи вроде "хороший объект — иммутабельный объект". Вот мне и стало интересно: насколько хорошо объектное мышление и Rust сочетаются друг с другом на самом деле?
Первым подопытным кроликом станет паттерн State, чистой реализации которого и посвящена эта статья.
Он был выбран не просто так: этому же паттерну посвящена глава из The Rust Book. Цель той главы была в том, чтобы показать, что объектно-ориентированный код на Rust пишут только плохие мальчики и девочки: здесь вам и лишний Option, и тривиальные реализации методов нужно копипастить во все реализации типажа. Но стоит применить пару трюков, и весь бойлерплейт пропадёт, а читаемость — повысится.
Масштаб работ
В оригинальной статье моделировался workflow поста в блоге. Проявим фантазию и адаптируем исходное описание под суровые русские реалии:
- Любая статья на Хабре когда-то была пустым черновиком, который автор должен был наполнить содержимым.
- Когда статья готова, она отправляется на модерацию.
- Как только модератор одобрит статью, она публикуется на Хабре.
- Пока статья не опубликована, пользователи не должны видеть её содержимое.
Любые нелегальные действия со статьей не должны иметь эффекта (например, нельзя опубликовать из песочницы не одобренную статью).
Листинг ниже демонст��ирует код, соответствующий описанию выше.
// 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<...>>!
Спасибо за внимание, до скорых встреч.
