Реализация ООП шаблона проектирования

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

Использование шаблона состояние означает, что при изменении бизнес-требований программы не нужно изменять код объекта, содержащего состояние или код использующий такой объект. Нам нужно только обновить код внутри одного из значений состояния, чтобы изменить его правила или, возможно, добавить больше значений состояния. Давайте рассмотрим пример проектирования шаблона состояние и как его использовать в Rust.

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

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

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

Листинг 17-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());
}

Листинг 17-11: Код, демонстрирующий желаемое поведение, которое мы хотим получить в крейте blog

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

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

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

Определение Post и создание нового экземпляра в состоянии черновика

Приступим к реализации библиотеки! Мы знаем, что нам нужна публичная структура Post, которая содержит некоторое содержимое, поэтому мы начнём с определения структуры и связанную с ней публичную функцию new для создания экземпляра Post, как показано в листинге 17-12. Мы также сделаем приватный типаж State. Затем Post будет содержать типаж-объект Box<dyn State> внутри Option<T> в приватном поле с названием state. Вскоре вы поймёте, почему Option<T> необходим. Файл: src/lib.rs

Листинг 17-12: Определение структуры Post и функции new, которая создаёт новый экземпляр Post, типаж State и структура Draft


#![allow(unused)]
fn main() {
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 {}
}

Листинг 17-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.

Хранение текста содержимого публикации

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

Файл: src/lib.rs


#![allow(unused)]
fn main() {
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    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 {}
}

Листинг 17-13: Реализация метода add_text для добавления текста в content публикации

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

Гарантирование пустого содержания черновика сообщения

Даже после того, как мы вызвали add_text и добавили некоторый контент в наше сообщение, мы хотим чтобы метод content возвращал пустой фрагмент строки, потому что публикация находится ещё в черновом состоянии, как это показано в строке 7 листинга 17-11. А пока давайте реализуем метод content с самым простым функционалом, который будет выполнять это требование: всегда возвращать пустой фрагмент строки. Мы изменим код позже, как только реализуем возможность изменить состояние сообщения, чтобы оно могло быть опубликовано. Пока что сообщения могут находиться только в черновом состоянии, поэтому содержимое сообщения всегда должно быть пустым. Листинг 17-14 показывает пустую реализацию:

Файл: src/lib.rs


#![allow(unused)]
fn main() {
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    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 {}
}

Листинг 17-14: Добавление пустой реализации метода content у структуры Post, который всегда возвращает пустой фрагмент строки

Благодаря этому добавленному методу content в листинге 17-11 все до строки 7 работает как и задумано.

Запрос проверки публикации меняет её состояние

Далее нам нужно добавить функциональность запроса рецензии публикации, который должен изменить её состояние с Draft на PendingReview. Листинг 17-15 показывает этот код:

Файл: src/lib.rs


#![allow(unused)]
fn main() {
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    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
    }
}
}

Листинг 17-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 как результат этой операции.

Нам нужно временно установить state в None, а не устанавливать его напрямую с помощью кода вроде self.state = self.state.request_review(); для получения владения значения поля state. Это гарантирует, что Post не сможет использовать старое значение state после того, как мы преобразовали его в новое состояние.

Метод request_review в Draft должен вернуть новый "упакованный" экземпляр новой структуры PendingReview, которая предоставляет состояние, когда публикация ожидает рецензии. Структура PendingReview также реализует метод request_review, но не выполняет никаких преобразований. Она возвращает сам себя, потому что когда мы запрашиваем рецензию публикации, она уже в состоянии PendingReview и должна продолжать оставаться в состоянии PendingReview.

Теперь мы можем увидеть преимущества шаблона состояния: метод request_review для Post одинаков независимо от значения его state. Каждое состояние несёт ответственность за свои правила.

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

Добавление метода approve который изменяет поведение content

Метод approve будет аналогичен методу request_review: он будет устанавливать у state значение, которое должна иметь публикация при её одобрении, как показано в листинге 17-16:

Файл: src/lib.rs


#![allow(unused)]
fn main() {
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    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 {
    // --snip--
    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 {
    // --snip--
    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
    }
}
}

Листинг 17-16: Реализация метода approve для Post и типажа State

Мы добавляем метод approve в типаж State, добавляем новую структуру, которая реализует этот типаж State и структуру для состояния Published.

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

Теперь нам нужно обновить метод content в экземпляре Post: мы хотим вернуть значение из поля content, если состояние публикации Published; в противном случае мы хотим вернуть пустой фрагмент строки, как показано в листинге 17-17:

Файл: src/lib.rs

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

impl Post {
    // --snip--
    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)
    }
    // --snip--

    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
    }
}

Листинг 17-17: Обновление метода content в структуре Post для делегирования вызова методу content у структуры State

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

Мы вызываем метод as_ref у Option потому что нам нужна ссылка на значение внутри 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>, в действие вступает принудительное приведение (deref coercion) для & и Box, поэтому в конечном итоге метод content будет вызываться для типа, который реализует типаж State. Это означает, что нам нужно добавить метод content в определение типажа State и именно там мы поместим логику для того, какое содержание возвращать в зависимости от состояния, которое мы имеем как показано в листинге 17-18:

Файл: src/lib.rs


#![allow(unused)]
fn main() {
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 {
    // --snip--
    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 {
        ""
    }
}

// --snip--

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 {
    // --snip--
    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
    }
}
}

Листинг 17-18: Добавление метода content в типаж State

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

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

Мы закончили, теперь все из листинга 17-11 работает! Мы внедрили шаблон состояния с помощью правил процесса публикации блога. Логика, связанная с правилами, живёт в объектах состояний, а не разбросана по всей структуре Post.

Компромиссы шаблона состояние

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

Если бы нужно было создать альтернативную реализацию не используя шаблон состояние, то мы могли бы использовать выражения match в методах структуры Post или даже в коде main, который проверяет состояние публикации и изменяет поведение в этих местах. Это означало бы, что нам пришлось бы искать в нескольких местах, чтобы понять как сообщение попадает в опубликованное состояние! Это могло бы только увеличить число добавленных нами состояний: каждому из этих выражений match потребовались бы ещё внутренние рукава.

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

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

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

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

Другим недостатком является то, что мы продублировали некоторую логику. Чтобы устранить некоторое дублирование, мы могли бы попытаться сделать реализации по умолчанию для методов request_review и approve типажа State, которые возвращают self; однако это нарушило бы безопасность объекта, потому что типаж не знает, какой конкретно будет self. Мы хотим иметь возможность использовать State в качестве типаж-объекта, поэтому нам нужно, чтобы его методы были безопасны для объекта.

Другое дублирование включает в себя аналогичные реализации методов request_review и approve у Post. Оба метода делегируют реализации одного и того же метода значению поля state типа Option и устанавливают результатом новое значение поля state. Если бы у Post было много методов, которые следовали этому шаблону, мы могли бы рассмотреть определение макроса для устранения повторения (смотри раздел "Макросы" в главе 19).

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

Кодирование состояний и поведения как типы

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

Давайте рассмотрим первую часть main в листинге 17-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. Таким образом, если мы попытаемся получить содержимое черновика, мы получим ошибку компилятора, сообщающую, что метод не существует. В результате мы не сможем случайно отобразить черновик содержимого публикации, потому что этот код даже не скомпилируется. В листинге 17-19 показано определение структур Post и DraftPost, а также методов для каждой из них:

Файл: src/lib.rs


#![allow(unused)]
fn main() {
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);
    }
}
}

Листинг 17-19: Структура Post с методом content и структура DraftPost без метода content

Обе структуры Post и DraftPost имеют приватное поле content в котором хранится текст сообщения блога. Структуры больше не имеют поля state, потому что мы перемещаем функционал состояния в типы структур. Структура Post будет представлять опубликованную публикацию и у неё есть метод content, который возвращает content.

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

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

Реализация переходов как преобразований в другие типы

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

Файл: src/lib.rs


#![allow(unused)]
fn main() {
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 {
    // --snip--
    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,
        }
    }
}
}

Листинг 17-20: Тип PendingReviewPost который создаётся путём вызова request_review экземпляра DraftPost и метод approve, который превращает PendingReviewPost в опубликованный Post.

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

Но мы также должны внести небольшие изменения в main. Методы request_review и approve возвращают новые экземпляры, а не изменяют структуру к которой они обращаются, поэтому нам нужно добавить больше выражений let post = затеняя присваивания для сохранения возвращаемых экземпляров. Мы также не можем использовать утверждения для черновика и ожидающей рецензии публикации сравнивая содержимое с пустыми строками, сейчас они нам не нужны: мы больше не сможем скомпилировать код, который пытается использовать содержимое сообщений в этих состояниях. Обновлённый код в main показан в листинге 17-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());
}

Листинг 17-21: Изменения в main для использования новой реализации процесса публикации блога

Изменения, которые нам нужно было внести в main чтобы переназначить post означают, что эта реализация не совсем следует объектно-ориентированному шаблону состояний: преобразования между состояниями больше не инкапсулированы полностью внутри реализации Post. Тем не менее выгода в том, что недопустимые состояния теперь невозможны из-за системы типов и проверки типов, которая происходит во время компиляции! Это гарантирует, что определённые ошибки, такие как отображение содержимого неопубликованной публикации, будут обнаружены до того, как они поступят в производство.

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

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

Итоги

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

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