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

Реалізація об’єктно-орієнтованого шаблону проєктування

Шаблон стану є об’єктно-орієнтованим шаблоном проєктування. Суть цього шаблону полягає в тому, що ми визначаємо набір станів, які значення може мати внутрішньо. Стани представлені набором об’єктів стану, і поведінка значення змінюється залежно від його стану. Ми розглянемо приклад структури публікації в блозі, яка має поле для зберігання свого стану, і цей стан буде об’єктом стану з набору “draft”, “review” або “published.”

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

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

Спочатку ми реалізуємо шаблон стану у більш традиційний об’єктно-орієнтований спосіб. Потім ми використаємо підхід, який у Rust є трохи природнішим. Розгляньмо покрокову реалізацію робочого процесу публікації в блозі, використовуючи шаблон стану.

Фінальна функціональність виглядатиме так:

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

Будь-які інші спроби зміни публікації не мають жодного ефекту. Наприклад, якщо ми спробуємо схвалити чернетку публікації в блозі до того, як ми запросили перегляд, публікація має залишитися неопублікованою чернеткою.

Спроба традиційного об’єктно-орієнтованого стилю

Існує нескінченно багато способів структурувати код для розв’язання однієї й тієї самої проблеми, кожен із різними компромісами. Реалізація цього розділу є більш традиційним об’єктно-орієнтованим стилем, який можна написати в Rust, але який не використовує деякі сильні сторони Rust. Пізніше ми продемонструємо інше рішення, яке все ще використовує об’єктно-орієнтований шаблон проєктування, але структуроване так, що може здаватися менш знайомим програмістам із об’єктно-орієнтованим досвідом. Ми порівняємо два рішення, щоб відчути компроміси проєктування коду Rust інакше, ніж код у інших мовах.

У лістингу 18-11 показано цей робочий процес у вигляді коду: це приклад використання API, який ми реалізуємо в бібліотечному крейті з назвою blog. Це ще не збереться, тому що ми ще не реалізували крейт blog.

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. Ми хочемо дозволити додавати текст до публікації в блозі. Якщо ми спробуємо отримати вміст публікації одразу, до схвалення, ми не повинні отримати жодного тексту, тому що публікація все ще є чернеткою. Ми додали assert_eq! у коді для демонстраційних цілей. Чудовим unit test для цього було б перевірити, що чернеткова публікація в блозі повертає порожній рядок із методу content, але для цього прикладу ми не будемо писати тести.

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

Зверніть увагу, що єдиний тип, з яким ми взаємодіємо з крейту, — це тип Post. Цей тип використовуватиме шаблон стану та міститиме значення, яке буде одним із трьох об’єктів стану, що представляють різні стани, у яких може перебувати публікація — draft, review або published. Перехід з одного стану в інший буде керуватися всередині типу Post. Зміни станів відбуваються у відповідь на методи, які викликають користувачі нашої бібліотеки на екземплярі Post, але їм не потрібно безпосередньо керувати змінами стану. Також користувачі не можуть припуститися помилки зі станами, наприклад опублікувати публікацію до того, як її переглянули.

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

Почнімо реалізацію бібліотеки! Ми знаємо, що нам потрібна публічна структура Post, яка зберігає деякий вміст, тож ми почнемо з визначення структури та асоційованої публічної функції new для створення екземпляра Post, як показано в лістингу 18-12. Ми також створимо приватний трейт State, який визначатиме поведінку, яку повинні мати всі об’єкти стану для Post.

Потім Post міститиме трейт-об’єкт Box<dyn State> всередині Option<T> у приватному полі з назвою state для зберігання об’єкта стану. Незабаром ви побачите, чому Option<T> є необхідним.

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

Трейт 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.

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

Метод 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 показано цю тимчасову реалізацію.

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

Після додавання цього методу content усе в лістингу 18-11 аж до першого assert_eq! працює так, як задумано.

Запит перегляду, який змінює стан публікації

Далі нам потрібно додати функціональність, щоб запитувати перегляд публікації, що має змінити її стан з Draft на PendingReview. У лістингу 18-15 показано цей код.

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

Ми даємо 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 у 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. Тепер лістинг 18-11 працює аж до другого виклику assert_eq!!

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

Метод approve буде схожий на метод request_review: він встановить state у значення, яке, на думку поточного стану, воно має мати, коли цей стан схвалено, як показано в лістингу 18-16.

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

Ми додаємо метод approve до трейту State і додаємо нову структуру, що реалізує State, — стан Published.

Подібно до того, як працює request_review у PendingReview, якщо ми викликаємо метод approve на Draft, це не матиме жодного ефекту, тому що approve поверне self. Коли ми викликаємо approve на PendingReview, він повертає новий екземпляр Published, загорнутий у Box. Структура Published реалізує трейт State, і для методу request_review, і для методу approve вона повертає себе, тому що публікація має залишатися в стані Published у цих випадках.

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

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

Оскільки мета полягає в тому, щоб зберегти всі ці правила всередині структур, які реалізують 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, коли ці методи завершать роботу. Це один із тих випадків, про які ми говорили в розділі “When You Have More Information Than the Compiler” у розділі 9, коли ми знаємо, що значення None ніколи не можливе, хоча компілятор не може цього зрозуміти.

На цьому етапі, коли ми викликаємо content на &Box<dyn State>, спрацює deref coercion для & і Box, так що метод content у підсумку буде викликано на типі, який реалізує трейт State. Це означає, що нам потрібно додати content до визначення трейту State, і саме там ми розмістимо логіку того, який вміст повертати залежно від того, який стан ми маємо, як показано в лістингу 18-18.

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

Ми додаємо реалізацію за замовчуванням для методу content, яка повертає порожній зріз рядка. Це означає, що нам не потрібно реалізовувати content у структурах Draft і PendingReview. Структура Published перевизначить метод content і поверне значення в post.content. Хоча це зручно, надання методу content у State для визначення вмісту Post розмиває межі між відповідальністю State і відповідальністю Post.

Зверніть увагу, що нам потрібні анотації часу життя для цього методу, як ми обговорювали в розділі 10. Ми беремо посилання на post як аргумент і повертаємо посилання на частину цього post, тож час життя повернутого посилання пов’язаний із часом життя аргументу post.

І на цьому все — тепер працює весь лістинг 18-11! Ми реалізували шаблон стану з правилами робочого процесу публікації в блозі. Логіка, пов’язана з правилами, знаходиться в об’єктах стану, а не розкидана по всьому Post.

Чому не enum?

Можливо, ви замислювалися, чому ми не використали enum із різними можливими станами публікації як варіантами. Це, безумовно, можливе рішення; спробуйте його та порівняйте кінцеві результати, щоб зрозуміти, який варіант вам більше подобається! Один із недоліків використання enum полягає в тому, що кожне місце, яке перевіряє значення enum, потребуватиме виразу match або подібного, щоб обробити кожен можливий варіант. Це може стати більш повторюваним, ніж це рішення з трейт-об’єктом.

Оцінювання шаблону стану

Ми показали, що Rust здатний реалізувати об’єктно-орієнтований шаблон стану, щоб інкапсулювати різні види поведінки, які публікація повинна мати в кожному стані. Методи на Post нічого не знають про різні види поведінки. Через те, як ми організували код, нам потрібно дивитися лише в одне місце, щоб знати різні способи поведінки опублікованої публікації: реалізацію трейту State для структури Published.

Якби ми створили альтернативну реалізацію, яка не використовує шаблон стану, ми могли б натомість використовувати вирази match у методах на Post або навіть у коді main, який перевіряє стан публікації та змінює поведінку в цих місцях. Це означало б, що нам довелося б дивитися в кілька місць, щоб зрозуміти всі наслідки того, що публікація перебуває в стані published.

Із шаблоном стану методам Post і місцям, де ми використовуємо Post, не потрібні вирази match, а щоб додати новий стан, нам потрібно було б лише додати нову структуру і реалізувати методи трейту для цієї однієї структури в одному місці.

Реалізацію з використанням шаблону стану легко розширити, щоб додати більше функціональності. Щоб побачити простоту підтримки коду, який використовує шаблон стану, спробуйте кілька з цих пропозицій:

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

Один із недоліків шаблону стану полягає в тому, що, оскільки стани реалізують переходи між станами, деякі стани зв’язані один з одним. Якщо ми додамо інший стан між PendingReview і Published, наприклад Scheduled, нам довелося б змінити код у PendingReview, щоб він переходив до Scheduled. Було б менше роботи, якби PendingReview не потрібно було змінювати з додаванням нового стану, але це означало б перехід до іншого шаблону проєктування.

Інший недолік полягає в тому, що ми дублювали деяку логіку. Щоб усунути частину дублювання, ми могли б спробувати зробити реалізації за замовчуванням для методів request_review і approve у трейді State, які повертають self. Однак це не спрацювало б: коли State використовується як трейт-об’єкт, трейт не знає точно, яким буде конкретний self, тож тип повернення невідомий на момент компіляції. (Це одне з правил dyn compatibility, згаданих раніше.)

Інше дублювання включає схожі реалізації методів request_review і approve на Post. Обидва методи використовують Option::take для поля state у Post, і якщо state є Some, вони делегують реалізацію однойменному методу в загорнутому значенні та встановлюють нове значення поля state у результат. Якби в нас було багато методів на Post, які слідують цьому шаблону, ми могли б розглянути визначення макросу, щоб усунути повторення (див. розділ “Macros” у розділі 20).

Реалізуючи шаблон стану саме так, як він визначений для об’єктно-орієнтованих мов, ми не використовуємо сильні сторони Rust настільки повно, наскільки могли б. Подивімося на деякі зміни, які ми можемо внести до крейту blog, щоб невалідні стани та переходи стали помилками часу компіляції.

Кодування станів і поведінки як типів

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

Розгляньмо першу частину main у лістингу 18-11:

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, а також методи для кожної з них.

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

І Post, і DraftPost мають приватне поле content, яке зберігає текст публікації в блозі. У структур більше немає поля state, тому що ми переносимо кодування стану до типів структур. Структура Post представлятиме опубліковану публікацію, і вона має метод content, який повертає content.

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

Структура DraftPost має метод add_text, тож ми можемо додавати текст до content, як і раніше, але зверніть увагу, що DraftPost не має визначеного методу content! Отже, тепер програма гарантує, що всі публікації починаються як чернеткові публікації, і вміст чернеткових публікацій недоступний для відображення. Будь-яка спроба обійти ці обмеження призведе до помилки компілятора.

То як же нам отримати опубліковану публікацію? Ми хочемо забезпечити правило, що чернеткову публікацію потрібно переглянути й схвалити, перш ніж її можна буде опублікувати. Публікація в стані pending review також не повинна показувати жодного вмісту. Реалізуймо ці обмеження, додавши ще одну структуру, PendingReviewPost, визначивши метод request_review у DraftPost, який повертатиме PendingReviewPost, і визначивши метод approve у PendingReviewPost, який повертатиме Post, як показано в лістингу 18-20.

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

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

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());
}

Зміни, які нам потрібно було внести до main, щоб повторно присвоїти post, означають, що ця реалізація вже не зовсім слідує об’єктно-орієнтованому шаблону стану: перетворення між станами більше не повністю інкапсульовані всередині реалізації Post. Однак наша перевага полягає в тому, що невалідні стани тепер неможливі завдяки системі типів і перевірці типів, яка відбувається під час компіляції! Це гарантує, що певні помилки, такі як відображення вмісту неопублікованої публікації, будуть виявлені до того, як вони потраплять у продуктивне середовище.

Спробуйте виконати завдання, запропоновані на початку цього розділу для крейту blog у його стані після лістингу 18-21, щоб побачити, що ви думаєте про проєктування цієї версії коду. Зверніть увагу, що деякі з завдань можуть бути вже виконані в цьому дизайні.

Ми побачили, що хоча Rust і здатний реалізовувати об’єктно-орієнтовані шаблони проєктування, в Rust також доступні інші шаблони, такі як кодування стану в систему типів. Ці шаблони мають різні компроміси. Хоча ви можете бути дуже знайомі з об’єктно-орієнтованими шаблонами, переосмислення проблеми з використанням особливостей Rust може дати переваги, наприклад запобігання деяким помилкам на етапі компіляції. Об’єктно-орієнтовані шаблони не завжди будуть найкращим рішенням у Rust через певні особливості, такі як власність, яких немає в об’єктно-орієнтованих мовах.

Підсумок

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

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