Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Реализация объектно-ориентированного паттерна

Паттерн «Состояние» (state pattern) — объектно-ориентированный шаблон проектирования. Его суть в том, что мы определяем набор состояний, в которых может находиться значение. Состояния представлены набором объектов-состояний (state objects), а поведение значения меняется в зависимости от его состояния. Мы разберём пример структуры записи в блоге (post) с полем для состояния — объекта-состояния из набора «черновик», «на проверке» и «опубликовано».

У объектов-состояний общая функциональность: в Rust, конечно, для этого служат структуры и трейты, а не объекты и наследование. Каждый объект-состояние отвечает за собственное поведение и за то, когда переходить в другое состояние. Значение, которое хранит объект-состояние, ничего не знает ни о разном поведении состояний, ни о том, когда переходить между ними.

Преимущество паттерна «Состояние» в том, что при изменении бизнес-требований не нужно менять ни код значения, хранящего состояние, ни код, который это значение использует. Достаточно поправить код внутри одного из объектов-состояний, чтобы поменять его правила, или добавить новые объекты-состояния.

Сначала мы реализуем паттерн «Состояние» в более традиционном объектно-ориентированном стиле. Затем воспользуемся подходом, более естественным для Rust. Давайте шаг за шагом реализуем процесс публикации записи в блоге с помощью этого паттерна.

Итоговая функциональность будет такой:

  1. Запись в блоге создаётся как пустой черновик.
  2. Когда черновик готов, запрашивается проверка записи.
  3. Когда запись одобрена, она публикуется.
  4. Только опубликованные записи возвращают текст для вывода, чтобы неодобренные записи нельзя было случайно опубликовать.

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

Попытка в традиционном объектно-ориентированном стиле

Устроить код для решения одной и той же задачи можно бесконечным числом способов, и у каждого свои компромиссы. Реализация в этом разделе выдержана в традиционном объектно-ориентированном стиле: на Rust так писать можно, но некоторые его сильные стороны остаются неиспользованными. Позже мы покажем другое решение, которое всё ещё использует объектно-ориентированный паттерн, но устроено так, что программистам с опытом ООП может показаться менее привычным. Сравнив два решения, мы прочувствуем, чем проектирование кода на Rust отличается от проектирования на других языках.

В листинге 18-11 этот процесс записан в виде кода: это пример использования API, который мы реализуем в библиотечном крейте blog. Пока код не скомпилируется — крейт blog ещё не написан.

Файл: src/main.rs
use blog::Post;

fn main() {
    let mut post = Post::new();

    post.add_text("I ate a salad for lunch today");
    assert_eq!("", post.content());

    post.request_review();
    assert_eq!("", post.content());

    post.approve();
    assert_eq!("I ate a salad for lunch today", post.content());
}
Листинг 18-11: Код, демонстрирующий поведение, которого мы хотим от крейта blog

Мы хотим, чтобы пользователь мог создать новый черновик записи через Post::new и добавить в запись текст. Если сразу же, до одобрения, попытаться получить содержимое записи, текста быть не должно: запись ещё черновик. assert_eq! мы добавили в код для наглядности. Отличным модульным тестом было бы проверить, что черновик возвращает из метода content пустую строку, но тесты для этого примера мы писать не будем.

Дальше мы хотим разрешить запросить проверку записи, и пока идёт проверка, content должен возвращать пустую строку. Когда запись одобрят, она должна быть опубликована — то есть вызов content будет возвращать текст записи.

Заметьте, что из всего крейта мы работаем только с типом Post. Этот тип будет использовать паттерн «Состояние» и хранить значение — один из трёх объектов-состояний, соответствующих возможным состояниям записи: черновик, на проверке или опубликовано. Переходы из одного состояния в другое управляются внутри типа Post. Состояния меняются в ответ на вызовы методов, которые пользователи библиотеки делают у экземпляра Post, но управлять переходами напрямую им не нужно. И ошибиться с состояниями — например, опубликовать запись до проверки — пользователи не смогут.

Определяем Post и создаём новый экземпляр

Приступим к реализации библиотеки! Мы знаем, что нужна публичная структура Post, хранящая содержимое. Начнём с определения структуры и публичной ассоциированной функции new, создающей экземпляр Post, как в листинге 18-12. Ещё сделаем приватный трейт State, который задаёт поведение, общее для всех объектов-состояний записи Post.

Post будет хранить объект-состояние в приватном поле state — трейт-объект Box<dyn State> внутри Option<T>. Зачем нужен Option<T>, станет ясно чуть позже.

Файл: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }
}

trait State {}

struct Draft {}

impl State for Draft {}
Листинг 18-12: Определение структуры Post и функции new, создающей новый экземпляр Post, трейта State и структуры Draft

Трейт State задаёт поведение, общее для разных состояний записи. Объекты-состояния — Draft (черновик), PendingReview (ожидает проверки) и Published (опубликовано), и все они будут реализовывать трейт State. Пока у трейта нет методов, и мы начнём с определения только состояния Draft: именно в нём запись должна начинать жизнь.

При создании нового Post мы записываем в поле state значение Some с Box внутри. Этот Box указывает на новый экземпляр структуры Draft. Так любой новый экземпляр Post начинает с состояния черновика. Поле state у Post приватное, поэтому создать Post в каком-либо другом состоянии невозможно! В функции Post::new поле content получает новую пустую String.

Храним текст записи

В листинге 18-11 мы видели, что хотим вызывать метод add_text и передавать ему &str, который добавляется к тексту записи. Мы делаем это методом, а не открываем поле content через pub, чтобы потом реализовать метод, управляющий тем, как читаются данные поля content. Метод add_text довольно простой, так что добавим его реализацию из листинга 18-13 в блок impl Post.

Файл: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --пропуск--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

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

trait State {}

struct Draft {}

impl State for Draft {}
Листинг 18-13: Реализуем метод add_text, который добавляет текст в content записи

Метод add_text принимает изменяемую ссылку на self, потому что меняет экземпляр Post, у которого его вызвали. Затем у String в content вызываем push_str и передаём аргумент text, чтобы дописать его к сохранённому content. Это поведение не зависит от состояния записи, поэтому к паттерну «Состояние» не относится. Метод add_text вообще не трогает поле state, но это часть поведения, которое мы хотим поддерживать.

У черновика содержимое пустое

Даже после того, как мы вызвали add_text и добавили в запись текст, метод content должен возвращать пустой строковый срез — ведь запись всё ещё черновик, как показывает первый assert_eq! в листинге 18-11. Пока реализуем метод content самым простым способом, удовлетворяющим этому требованию: всегда возвращаем пустой строковый срез. Поменяем это позже, когда реализуем изменение состояния записи и её публикацию. Пока запись может быть только черновиком, так что её содержимое всегда должно быть пустым. Эта реализация-заглушка — в листинге 18-14.

Файл: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --пропуск--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

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

    pub fn content(&self) -> &str {
        ""
    }
}

trait State {}

struct Draft {}

impl State for Draft {}
Листинг 18-14: Добавляем для метода content у Post заглушку, которая всегда возвращает пустой строковый срез

С этим методом content всё в листинге 18-11 вплоть до первого assert_eq! работает как задумано.

Запрос проверки меняет состояние записи

Теперь нужно добавить запрос проверки записи, который переводит её из состояния Draft в PendingReview. Этот код — в листинге 18-15.

Файл: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --пропуск--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

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

    pub fn content(&self) -> &str {
        ""
    }

    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }
}

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

struct Draft {}

impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }
}

struct PendingReview {}

impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }
}
Листинг 18-15: Реализуем методы request_review у Post и у трейта State

Даём Post публичный метод request_review, который принимает изменяемую ссылку на self. Внутри вызываем внутренний метод request_review у текущего состояния Post, и этот второй request_review поглощает текущее состояние и возвращает новое.

Метод request_review мы добавляем в трейт State; теперь все типы, реализующие этот трейт, обязаны реализовать request_review. Обратите внимание: первым параметром метода стоит не self, &self или &mut self, а self: Box<Self>. Такая запись означает, что метод можно вызвать, только если тип лежит в Box. Метод забирает владение Box<Self>, делая старое состояние недействительным, чтобы значение состояния Post могло превратиться в новое.

Чтобы поглотить старое состояние, методу request_review нужно забрать владение значением состояния. Тут и пригождается Option в поле state структуры Post: методом take мы забираем значение Some из поля state, а на его месте оставляем None — Rust не допускает в структурах незаполненных полей. Так значение state можно переместить из Post, а не позаимствовать. Затем записываем в поле state записи результат операции.

Почему нельзя просто написать self.state = self.state.request_review();? Потому что нам нужно забрать владение значением state, а для этого на его место приходится временно поставить None. Зато так Post гарантированно не сможет пользоваться старым значением state после того, как мы превратили его в новое состояние.

Метод request_review у Draft возвращает новый экземпляр структуры PendingReview в коробке — это состояние, когда запись ждёт проверки. Структура PendingReview тоже реализует метод request_review, но никаких превращений не делает, а возвращает саму себя: если запросить проверку у записи, которая уже в состоянии PendingReview, она должна в нём и остаться.

Теперь начинают проявляться преимущества паттерна «Состояние»: метод request_review у Post один и тот же, каким бы ни было значение state. Каждое состояние отвечает за свои правила.

Метод content у Post оставим как есть — он возвращает пустой строковый срез. Теперь Post может быть и в состоянии PendingReview, и в Draft, но в PendingReview поведение должно быть таким же. Листинг 18-11 теперь работает вплоть до второго вызова assert_eq!!

approve меняет поведение content

Метод approve (одобрить) будет похож на request_review: он записывает в state то значение, которое, по словам текущего состояния, должно быть после одобрения, как в листинге 18-16.

Файл: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --пропуск--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

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

    pub fn content(&self) -> &str {
        ""
    }

    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }

    pub fn approve(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.approve())
        }
    }
}

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

struct Draft {}

impl State for Draft {
    // --пропуск--
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

struct PendingReview {}

impl State for PendingReview {
    // --пропуск--
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        Box::new(Published {})
    }
}

struct Published {}

impl State for Published {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}
Листинг 18-16: Реализуем метод approve у Post и у трейта State

Добавляем метод approve в трейт State и новую структуру, реализующую State, — состояние Published.

Как и request_review у PendingReview, вызов approve у Draft ни на что не повлияет: approve вернёт self. А вызов approve у PendingReview вернёт новый экземпляр структуры Published в коробке. Структура Published реализует трейт State и в обоих методах — и request_review, и approve — возвращает саму себя: в этих случаях запись должна оставаться в состоянии Published.

Теперь нужно обновить метод content у Post. Мы хотим, чтобы значение, которое возвращает content, зависело от текущего состояния Post, поэтому пусть Post делегирует работу методу content, определённому у его state, как в листинге 18-17.

Файл: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --пропуск--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

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

    pub fn content(&self) -> &str {
        self.state.as_ref().unwrap().content(self)
    }
    // --пропуск--

    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }

    pub fn approve(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.approve())
        }
    }
}

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

struct Draft {}

impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

struct PendingReview {}

impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        Box::new(Published {})
    }
}

struct Published {}

impl State for Published {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}
Листинг 18-17: Обновляем метод content у Post, чтобы он делегировал работу методу content у State

Наша цель — держать все эти правила внутри структур, реализующих State, поэтому мы вызываем метод content у значения в state и передаём аргументом экземпляр записи (то есть self). А возвращаем то, что вернул метод content у значения state.

У Option мы вызываем метод as_ref, потому что нам нужна ссылка на значение внутри Option, а не владение им. state имеет тип Option<Box<dyn State>>, поэтому as_ref вернёт Option<&Box<dyn State>>. Не вызови мы as_ref, была бы ошибка: нельзя переместить state из заимствованного параметра &self.

Затем вызываем метод unwrap, который, как мы знаем, никогда не запаникует: методы Post гарантируют, что по их завершении в state всегда лежит Some. Это один из случаев, о которых мы говорили в разделе «Когда вы знаете больше компилятора» главы 9: мы знаем, что None здесь невозможен, хотя компилятор понять этого не может.

Теперь, когда мы вызываем content у &Box<dyn State>, неявное разыменование срабатывает и для &, и для Box, так что метод content в итоге вызовется у типа, реализующего трейт State. Значит, нужно добавить content в определение трейта State — туда мы и поместим логику, какое содержимое возвращать в зависимости от состояния, как в листинге 18-18.

Файл: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

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

    pub fn content(&self) -> &str {
        self.state.as_ref().unwrap().content(self)
    }

    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }

    pub fn approve(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.approve())
        }
    }
}

trait State {
    // --пропуск--
    fn request_review(self: Box<Self>) -> Box<dyn State>;
    fn approve(self: Box<Self>) -> Box<dyn State>;

    fn content<'a>(&self, post: &'a Post) -> &'a str {
        ""
    }
}

// --пропуск--

struct Draft {}

impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

struct PendingReview {}

impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        Box::new(Published {})
    }
}

struct Published {}

impl State for Published {
    // --пропуск--
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn content<'a>(&self, post: &'a Post) -> &'a str {
        &post.content
    }
}
Листинг 18-18: Добавляем метод content в трейт State

Мы добавляем для метода content реализацию по умолчанию, которая возвращает пустой строковый срез. Значит, реализовывать content для структур Draft и PendingReview не нужно. Структура Published переопределит метод content и вернёт значение из post.content. Это удобно, но граница между обязанностями State и Post при этом размывается: что возвращать в качестве содержимого Post, теперь решает метод content у State.

Обратите внимание: у этого метода нужны аннотации времени жизни, как мы обсуждали в главе 10. Мы принимаем аргументом ссылку на post и возвращаем ссылку на часть этого post, так что время жизни возвращаемой ссылки связано со временем жизни аргумента post.

Готово — весь листинг 18-11 теперь работает! Мы реализовали паттерн «Состояние» с правилами процесса публикации записи. Логика, связанная с правилами, живёт в объектах-состояниях, а не разбросана по Post.

А почему не перечисление?

Возможно, вы задумались, почему мы не использовали перечисление с возможными состояниями записи в качестве вариантов. Это, безусловно, тоже решение — попробуйте и сравните результаты, чтобы понять, что вам больше нравится! Один из недостатков перечисления в том, что в каждом месте, где проверяется его значение, нужно выражение match или что-то подобное, чтобы обработать все возможные варианты. Повторений может оказаться больше, чем в решении с трейт-объектами.

Оценка паттерна «Состояние»

Мы показали, что на Rust можно реализовать объектно-ориентированный паттерн «Состояние» и инкапсулировать разное поведение записи в каждом состоянии. Методы Post ничего не знают о разных вариантах поведения. Благодаря такой организации кода, чтобы узнать, как может вести себя опубликованная запись, достаточно посмотреть в одно место — в реализацию трейта State для структуры Published.

Если бы мы сделали альтернативную реализацию без паттерна «Состояние», то, возможно, использовали бы выражения match в методах Post или даже в коде main, проверяя состояние записи и меняя поведение прямо там. Тогда, чтобы понять все последствия того, что запись опубликована, пришлось бы заглядывать в несколько мест.

С паттерном «Состояние» ни методам Post, ни местам, где используется Post, выражения match не нужны, а чтобы добавить новое состояние, достаточно добавить новую структуру и реализовать методы трейта для неё одной — в одном месте.

Реализацию на паттерне «Состояние» легко расширять. Чтобы убедиться, насколько просто поддерживать такой код, попробуйте выполнить несколько заданий:

  • Добавьте метод reject (отклонить), который возвращает запись из состояния PendingReview обратно в Draft.
  • Сделайте так, чтобы для перехода в Published требовалось два вызова approve.
  • Разрешите пользователям добавлять текст, только когда запись в состоянии Draft. Подсказка: пусть объект-состояние отвечает за то, что может меняться в содержимом, но не отвечает за изменение самого Post.

Один из недостатков паттерна «Состояние» в том, что переходы между состояниями реализуют сами состояния, а значит, некоторые состояния связаны друг с другом. Если добавить ещё одно состояние между PendingReview и Published, например Scheduled (запланировано), придётся изменить код PendingReview, чтобы он переходил в Scheduled. Было бы проще, если бы PendingReview не приходилось менять при добавлении нового состояния, но это означало бы переход на другой паттерн.

Другой недостаток — часть логики дублируется. Чтобы убрать часть дублирования, можно попробовать сделать в трейте State реализации методов request_review и approve по умолчанию, возвращающие self. Но так не выйдет: когда State используется как трейт-объект, трейт не знает, каким именно будет конкретный self, так что возвращаемый тип неизвестен при компиляции. (Это одно из правил dyn-совместимости, о которых говорилось раньше.)

Есть и другое дублирование: похожие реализации методов request_review и approve у Post. Оба метода применяют Option::take к полю state структуры Post и, если state — это Some, делегируют работу одноимённому методу обёрнутого значения и записывают результат в поле state. Будь у Post много таких методов, можно было бы определить макрос, чтобы убрать повторы (см. раздел «Макросы» главы 20).

Реализуя паттерн «Состояние» в точности так, как он определён для объектно-ориентированных языков, мы используем сильные стороны Rust не в полной мере. Посмотрим, как изменить крейт blog, чтобы недопустимые состояния и переходы стали ошибками компиляции.

Состояния и поведение как типы

Покажем, как переосмыслить паттерн «Состояние» и получить другой набор компромиссов. Вместо того чтобы полностью инкапсулировать состояния и переходы, скрыв их от внешнего кода, закодируем состояния в виде разных типов. Тогда система проверки типов Rust не даст использовать черновики там, где допустимы только опубликованные записи, — выдаст ошибку компиляции.

Вспомним первую часть main из листинга 18-11:

Файл: src/main.rs
use blog::Post;

fn main() {
    let mut post = Post::new();

    post.add_text("I ate a salad for lunch today");
    assert_eq!("", post.content());

    post.request_review();
    assert_eq!("", post.content());

    post.approve();
    assert_eq!("I ate a salad for lunch today", post.content());
}

Мы по-прежнему позволяем создавать новые записи в состоянии черновика через Post::new и добавлять текст в содержимое записи. Но вместо метода content у черновика, возвращающего пустую строку, сделаем так, чтобы у черновиков метода content вообще не было. Тогда попытка получить содержимое черновика приведёт к ошибке компиляции: такого метода нет. В итоге случайно показать содержимое черновика в продакшене станет невозможно — такой код просто не скомпилируется. В листинге 18-19 — определения структур Post и DraftPost и методов каждой.

Файл: src/lib.rs
pub struct Post {
    content: String,
}

pub struct DraftPost {
    content: String,
}

impl Post {
    pub fn new() -> DraftPost {
        DraftPost {
            content: String::new(),
        }
    }

    pub fn content(&self) -> &str {
        &self.content
    }
}

impl DraftPost {
    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }
}
Листинг 18-19: Post с методом content и DraftPost без метода content

У обеих структур, Post и DraftPost, есть приватное поле content, в котором хранится текст записи. Поля state у структур больше нет: состояние теперь кодируется типами структур. Структура Post будет представлять опубликованную запись, и у неё есть метод content, возвращающий content.

Функция Post::new по-прежнему есть, но возвращает она не экземпляр Post, а экземпляр DraftPost. Поле content приватное, и функций, возвращающих Post, нет, так что создать экземпляр Post сейчас невозможно.

У структуры DraftPost есть метод add_text, так что добавлять текст в content можно, как и раньше, но, заметьте, метода content у DraftPost нет! Теперь программа гарантирует, что любая запись начинается как черновик, а содержимое черновика показать нельзя. Любая попытка обойти эти ограничения приведёт к ошибке компиляции.

Как же получить опубликованную запись? Мы хотим обеспечить правило: черновик должен пройти проверку и быть одобрен, прежде чем его опубликуют. Запись, ожидающая проверки, тоже не должна показывать содержимое. Реализуем эти ограничения: добавим ещё одну структуру, PendingReviewPost, определим у DraftPost метод request_review, возвращающий PendingReviewPost, а у PendingReviewPost — метод approve, возвращающий Post, как в листинге 18-20.

Файл: src/lib.rs
pub struct Post {
    content: String,
}

pub struct DraftPost {
    content: String,
}

impl Post {
    pub fn new() -> DraftPost {
        DraftPost {
            content: String::new(),
        }
    }

    pub fn content(&self) -> &str {
        &self.content
    }
}

impl DraftPost {
    // --пропуск--
    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn request_review(self) -> PendingReviewPost {
        PendingReviewPost {
            content: self.content,
        }
    }
}

pub struct PendingReviewPost {
    content: String,
}

impl PendingReviewPost {
    pub fn approve(self) -> Post {
        Post {
            content: self.content,
        }
    }
}
Листинг 18-20: PendingReviewPost создаётся вызовом request_review у DraftPost, а метод approve превращает PendingReviewPost в опубликованный Post

Методы request_review и approve забирают владение self, то есть поглощают экземпляры DraftPost и PendingReviewPost и превращают их соответственно в PendingReviewPost и опубликованный Post. Так после вызова request_review у DraftPost никаких «повисших» экземпляров DraftPost не остаётся, и так далее. У структуры PendingReviewPost нет метода content, так что попытка прочитать её содержимое, как и у DraftPost, приводит к ошибке компиляции. Единственный способ получить опубликованный экземпляр Post, у которого метод content есть, — вызвать approve у PendingReviewPost. А единственный способ получить PendingReviewPost — вызвать request_review у DraftPost. Тем самым мы закодировали процесс публикации записи в системе типов.

Но в main тоже придётся внести небольшие изменения. Методы request_review и approve возвращают новые экземпляры, а не меняют структуру, у которой их вызвали, поэтому нужно добавить ещё затеняющих присваиваний let post =, чтобы сохранять возвращённые экземпляры. Проверки, что содержимое черновика и записи на проверке — пустые строки, тоже придётся убрать, да они и не нужны: код, который пытается использовать содержимое записей в этих состояниях, больше не скомпилируется. Обновлённый код main — в листинге 18-21.

Файл: src/main.rs
use blog::Post;

fn main() {
    let mut post = Post::new();

    post.add_text("I ate a salad for lunch today");

    let post = post.request_review();

    let post = post.approve();

    assert_eq!("I ate a salad for lunch today", post.content());
}
Листинг 18-21: Изменения в main для новой реализации процесса публикации записи

Раз в main приходится заново присваивать post, эта реализация уже не совсем следует объектно-ориентированному паттерну «Состояние»: переходы между состояниями больше не инкапсулированы целиком в реализации Post. Зато мы выиграли в другом: благодаря системе типов и проверке типов при компиляции недопустимые состояния теперь невозможны! Это гарантирует, что некоторые баги — например, показ содержимого неопубликованной записи — будут найдены ещё до того, как код попадёт в продакшен.

Попробуйте выполнить задания из начала этого раздела для крейта blog в том виде, в каком он стал после листинга 18-21, и оцените, как вам дизайн этой версии кода. Учтите, что некоторые задания в таком дизайне, возможно, уже выполнены.

Мы увидели, что, хотя на Rust можно реализовывать объектно-ориентированные паттерны, в нём доступны и другие — например, кодирование состояния в системе типов. У этих паттернов разные компромиссы. Даже если вам хорошо знакомы объектно-ориентированные паттерны, переосмысление задачи с учётом возможностей Rust может дать преимущества — например, предотвращать некоторые баги ещё при компиляции. Объектно-ориентированные паттерны не всегда будут лучшим решением в Rust, потому что в нём есть возможности вроде владения, которых нет в объектно-ориентированных языках.

Итоги

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

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