Важно: для комфортного прочтения статьи нужно уметь читать исходный код на 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<...>>!
Спасибо за внимание, до скорых встреч.
