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

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

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

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

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

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

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

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

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

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

Filename: 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());
}
Listing 18-11: Код, демонстрирующий желаемое поведение, которое мы хотим, чтобы наш крейт blog имел

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

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

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

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

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

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

Filename: 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 {}
Listing 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.

Filename: 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);
    }
}

trait State {}

struct Draft {}

impl State for Draft {}
Listing 18-13: Реализация метода add_text для добавления текста в content записи

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

Обеспечение того, что содержимое черновика записи пусто

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

Filename: 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 {
        ""
    }
}

trait State {}

struct Draft {}

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

С этим добавленным методом content всё в Листинге 18-11 до строки 7 работает как задумано.

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

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

Filename: 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 {
        ""
    }

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

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

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

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

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

Добавление approve для изменения поведения content

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

Filename: 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 {
        ""
    }

    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
    }
}
Listing 18-16: Реализация метода approve на Post и типаже State

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

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

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

Filename: 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
    }
}
Listing 18-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, и там мы поместим логику того, какое содержимое возвращать в зависимости от того, какое состояние у нас есть, как показано в Листинге 18-18:

Filename: 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 {
    // --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
    }
}
Listing 18-18: Добавление метода content в типаж State

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

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

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

Почему не использовать перечисление?

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

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

Мы показали, что 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; однако это не сработало бы: при использовании State как объекта типажа типаж не знает, каким будет конкретный self точно, поэтому тип возврата не известен на этапе компиляции. (Это одно из правил совместимости dyn, упомянутых ранее.)

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

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

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

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

Давайте рассмотрим первую часть main в Листинге 18-11:

Filename: 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, а также методы для каждого.

Filename: 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);
    }
}
Listing 18-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, как показано в Листинге 18-20.

Filename: 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 {
    // --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,
        }
    }
}
Listing 18-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 показан в Листинге 18-21.

Filename: 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());
}
Listing 18-21: Изменения в main для использования новой реализации рабочего процесса записи блога

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

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

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

Резюме

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

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