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) — это объектно-ориентированный шаблон проектирования. Суть шаблона в том, что мы определяем набор состояний, которые значение может иметь внутри. Состояния представлены набором объектов состояния, а поведение значения меняется в зависимости от его состояния. Мы разберем пример структуры записи блога, у которой есть поле для хранения ее состояния; это состояние будет объектом состояния из набора «черновик», «проверка» или «опубликовано».

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

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

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

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

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

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

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

Существует бесконечно много способов структурировать код для решения одной и той же задачи, и у каждого свои компромиссы. Реализация в этом разделе больше похожа на традиционный объектно-ориентированный стиль, который можно написать в Rust, но он не использует некоторые сильные стороны 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());
}
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>.

Имя файла: 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.

Имя файла: 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 возвращал пустой строковый срез, потому что запись все еще находится в состоянии черновика, как показывает первый assert_eq! в листинге 18-11. Пока реализуем метод content самым простым способом, который выполнит это требование: всегда возвращая пустой строковый срез. Мы изменим это позже, когда реализуем возможность изменять состояние записи, чтобы ее можно было опубликовать. Пока записи могут быть только в состоянии черновика, поэтому содержимое записи должно всегда быть пустым. Листинг 18-14 показывает эту временную реализацию.

Имя файла: 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 до первого assert_eq! включительно работает как задумано.

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

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

Имя файла: 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 теперь работает до второго вызова 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 {
    // --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.

Имя файла: 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>, сработает приведение через разыменование для & и 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 {
    // --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. Хотя это удобно, наличие метода content у State, определяющего содержимое Post, размывает границы между ответственностью State и ответственностью Post.

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

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

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

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

Оценка шаблона «Состояние»

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

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

С шаблоном «Состояние» методам 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:

Имя файла: 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);
    }
}
Listing 18-19: Post с методом content и DraftPost без метода content

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

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

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

Итак, как получить опубликованную запись? Мы хотим обеспечить правило, что черновик записи должен пройти проверку и быть одобрен, прежде чем его можно будет опубликовать. Запись в состоянии ожидания проверки все еще не должна отображать никакого содержимого. Давайте реализуем эти ограничения, добавив еще одну структуру, PendingReviewPost, определив метод request_review для DraftPost, который возвращает PendingReviewPost, и определив метод approve для PendingReviewPost, который возвращает 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 {
    // --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.

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