Реалізація патернів об'єктноорієнтованого програмування

Патерн "Стан" - це об'єктноорієнтований шаблон проєктування. Сенс патерну полягає в тому, що ми визначаємо набір станів, в яких може знаходитися значення. Стани представлені набором об'єктів стану, а поведінка значення змінюється в залежності від його стану. Розглянемо на прикладі структури допису в блозі, що має поле для збереження її стану, яке буде об'єктом стану з набору "чернетка" (draft), "очікування перевірки" (review) або "опубліковано" (published).

Об'єкти стану мають спільну функціональність: звісно в 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! в коді для демонстрації цілей. Ідеальним модульним (unit) тестом для цього було б твердження, що чернетка допису повертає порожній рядок з методу content, але ми не будемо писати тести для цього прикладу.

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

Зверніть увагу, що єдиний тип з крейту, з яким ми взаємодіємо - це тип Post. Цей тип буде використовувати патерн "Стан" і буде містить значення, яке буде одним з трьох об'єктів станів, які представляють різні стани, в яких може знаходитися допис: "чернетка", "очікування перевірки", або "опубліковано". Керування переходом з одного стану в інший буде здійснюватися внутрішньою логікою типа Post. Стани будуть перемикатися в результаті реакції на виклик методів екземпляру Post користувачами нашої бібліотеки, але користувачі не повинні керувати зміною станів напряму. Крім того, користувачі не повинні мати можливість помилитися зі станами, наприклад, опублікувати повідомлення до його перевірки.

Визначення Post та створення нового екземпляру в стані чернетки

Нумо почнімо реалізовувати бібліотеку! Ми знаємо, що нам потрібна публічна структура Post, яка зберігає деякий вміст, тому ми почнемо з визначення структури та пов'язаною з нею публічною функцією new для створення екземпляра Post, як показано в Блоці коду 17-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 {}

Блок коду 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 досить простий, тому додаймо його реалізацію в блок impl Post у Блоці коду 17-13:

Файл: 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 {}

Блок коду 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

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

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 обгорнутої в Box, яка є станом, коли допис очікує на перевірку. Структура 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

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 для PendingReview, якщо ми викличемо метод approve для Draft, це не буде мати ніякого ефекту, тому що approve поверне self. Коли ми викликаємо метод approve для PendingReview, він повертає новий, обгорнутий у Box, екземпляр структури Published. Структура Published реалізує трейт State, і як для методу request_review, так і для методу approve вона повертає себе, тому що в цих випадках допис повинен залишатися в стані Published.

Тепер нам потрібно оновити метод content для Post. Ми хочемо, щоб значення, яке повертається з content, залежало від поточного стану Post, тому ми збираємося делегувати частину функціональності Post в метод content, визначений для state, як показано в Блоці коду 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

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.

Чому не перерахунок (enum)?

Можливо, вам було цікаво, чому ми не використовували 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; однак, це б порушило безпечність об'єкта, тому що трейт не знає, яким конкретно буде 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

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

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 =, затіняючи присвоювання для збереження екземплярів, що повертаються. Ми також не можемо використовувати твердження (assertions) для чернетки та допису, який очікує на перевірку, що вміст повинен бути пустим рядком, бо вони нам більше не потрібні: тепер ми не зможемо скомпілювати код, який намагається використовувати вміст дописів, що знаходяться в цих станах. Оновлений код в 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, що дає більше гнучкості. Ми трохи зустрічалися з ними впродовж всієї книги, але все ще не проаналізували всі їх можливості. Вперед до нових можливостей!