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

Використання трейт-об’єктів для абстрагування над спільною поведінкою

У розділі 8 ми згадували, що одне з обмежень векторів полягає в тому, що вони можуть зберігати елементи лише одного типу. Ми створили обхідний шлях у Лістингу 8-9, де визначили перелік SpreadsheetCell, який мав варіанти для зберігання цілих чисел, чисел з плаваючою комою та тексту. Це означало, що ми могли зберігати різні типи даних у кожній комірці й водночас мати вектор, який представляв рядок комірок. Це цілком добрий розв’язок, коли наші взаємозамінні елементи — це фіксований набір типів, який ми знаємо коли наш код компілюється.

Однак іноді ми хочемо, щоб користувач нашої бібліотеки міг розширювати набір типів, які є дійсними в певній ситуації. Щоб показати, як ми могли б досягти цього, ми створимо приклад інструмента графічного інтерфейсу користувача (GUI), який ітерує через список елементів, викликаючи метод draw для кожного з них, щоб намалювати його на екрані — поширена техніка для інструментів GUI. Ми створимо крейт бібліотеки під назвою gui, який містить структуру бібліотеки GUI. Цей крейт може включати деякі типи для використання людьми, такі як Button або TextField. Крім того, користувачі gui захочуть створювати власні типи, які можна малювати: наприклад, один програміст може додати Image, а інший може додати SelectBox.

На момент написання бібліотеки ми не можемо знати й визначити всі типи, які інші програмісти можуть захотіти створити. Але ми знаємо, що gui потрібно відстежувати багато значень різних типів, і йому потрібно викликати метод draw для кожного з цих значень різних типів. Йому не потрібно точно знати, що станеться, коли ми викличемо метод draw, лише те, що значення матиме цей метод, доступний для виклику.

Щоб зробити це в мові з успадкуванням, ми могли б визначити клас під назвою Component, у якого є метод під назвою draw. Інші класи, такі як Button, Image і SelectBox, успадковували б від Component і таким чином успадковували б метод draw. Кожен із них міг би перевизначити метод draw, щоб визначити їхню власну поведінку, але фреймворк міг би розглядати всі типи так, ніби вони були екземплярами Component, і викликати на них draw. Але оскільки Rust не має успадкування, нам потрібен інший спосіб структурувати бібліотеку gui, щоб дозволити користувачам створювати нові типи, сумісні з бібліотекою.

Визначення трейту для спільної поведінки

Щоб реалізувати поведінку, яку ми хочемо мати в gui, ми визначимо трейт під назвою Draw, який матиме один метод під назвою draw. Потім ми можемо визначити вектор, який приймає трейт-об’єкт. Трейт-об’єкт вказує і на екземпляр типу, що реалізує наш вказаний трейт, і на таблицю, яка використовується для пошуку методів трейту для цього типу під час виконання. Ми створюємо трейт-об’єкт, вказуючи певний вид вказівника, наприклад посилання або розумний вказівник Box<T>, потім ключове слово dyn, а потім відповідний трейт. (Ми поговоримо про причину, чому трейт-об’єкти мають використовувати вказівник, у “Динамічно розмірних типах та трейті Sized у розділі 20.) Ми можемо використовувати трейт-об’єкти замість узагальненого або конкретного типу. Де б ми не використовували трейт- об’єкт, система типів Rust на етапі компіляції гарантуватиме, що будь-яке значення, яке використовується в цьому контексті, реалізовуватиме трейт трейт-об’єкта. Отже, нам не потрібно знати всі можливі типи на етапі компіляції.

Ми вже згадували, що в Rust ми утримуємося від називання структур і переліків “об’єктами”, щоб відрізняти їх від об’єктів в інших мовах. У структурі або переліку дані в полях структури та поведінка в блоках impl є розділеними, тоді як в інших мовах дані й поведінка, об’єднані в одне поняття, часто називають об’єктом. Трейт-об’єкти відрізняються від об’єктів в інших мовах тим, що ми не можемо додавати дані до трейт-об’єкта. Трейт-об’єкти не є настільки загально корисними, як об’єкти в інших мовах: їхнє конкретне призначення — це дозволити абстрагування над спільною поведінкою.

Лістинг 18-3 показує, як визначити трейт під назвою Draw з одним методом під назвою draw.

pub trait Draw {
    fn draw(&self);
}

Цей синтаксис має бути вам знайомий із наших обговорень про те, як визначати трейт у розділі 10. Далі йде дещо новий синтаксис: Лістинг 18-4 визначає структуру під назвою Screen, яка містить вектор під назвою components. Цей вектор має тип Box<dyn Draw>, який є трейт-об’єктом; це заміна для будь-якого типу всередині Box, що реалізує трейт Draw.

pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

Для структури Screen ми визначимо метод під назвою run, який викликатиме метод draw для кожного з її components, як показано в Лістингу 18-5.

pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

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

pub trait Draw {
    fn draw(&self);
}

pub struct Screen<T: Draw> {
    pub components: Vec<T>,
}

impl<T> Screen<T>
where
    T: Draw,
{
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

Це обмежує нас екземпляром Screen, який має список компонентів усіх типу Button або всіх типу TextField. Якщо у вас завжди будуть лише однорідні колекції, використання узагальнень і обмежень трейту є кращим, тому що визначення будуть моноформізовані під час компіляції для використання конкретних типів.

З іншого боку, з методом, який використовує трейт-об’єкти, один екземпляр Screen може містити Vec<T>, що містить Box<Button>, а також Box<TextField>. Давайте подивимося, як це працює, а потім поговоримо про наслідки для продуктивності під час виконання.

Реалізація трейту

Тепер ми додамо деякі типи, які реалізують трейт Draw. Ми надамо тип Button. Знову ж таки, фактична реалізація бібліотеки GUI виходить за межі цієї книги, тому метод draw не матиме жодної корисної реалізації в своєму тілі. Щоб уявити, як могла б виглядати реалізація, структура Button могла б мати поля для width, height і label, як показано в Лістингу 18-7.

pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

pub struct Button {
    pub width: u32,
    pub height: u32,
    pub label: String,
}

impl Draw for Button {
    fn draw(&self) {
        // code to actually draw a button
    }
}

Поля width, height і label у Button відрізнятимуться від полів в інших компонентах; наприклад, тип TextField може мати ті самі поля плюс поле placeholder. Кожен із типів, які ми хочемо малювати на екрані, реалізовуватиме трейт Draw, але використовуватиме різний код у методі draw, щоб визначити, як малювати цей конкретний тип, як це зроблено тут у Button (без фактичного коду GUI, як уже згадувалося). Тип Button, наприклад, може мати додатковий блок impl, що містить методи, пов’язані з тим, що відбувається, коли користувач натискає кнопку. Такі методи не будуть застосовні до типів на кшталт TextField.

Якщо хтось, хто використовує нашу бібліотеку, вирішить реалізувати структуру SelectBox, яка має поля width, height і options, він реалізує трейт Draw для типу SelectBox також, як показано в Лістингу 18-8.

use gui::Draw;

struct SelectBox {
    width: u32,
    height: u32,
    options: Vec<String>,
}

impl Draw for SelectBox {
    fn draw(&self) {
        // code to actually draw a select box
    }
}

fn main() {}

Користувач нашої бібліотеки тепер може написати свою функцію main, щоб створити екземпляр Screen. До екземпляра Screen вони можуть додати SelectBox і Button, поклавши кожен у Box<T>, щоб стати трейт-об’єктом. Потім вони можуть викликати метод run на екземплярі Screen, який викличе draw для кожного з компонентів. Лістинг 18-9 показує цю реалізацію.

use gui::Draw;

struct SelectBox {
    width: u32,
    height: u32,
    options: Vec<String>,
}

impl Draw for SelectBox {
    fn draw(&self) {
        // code to actually draw a select box
    }
}

use gui::{Button, Screen};

fn main() {
    let screen = Screen {
        components: vec![
            Box::new(SelectBox {
                width: 75,
                height: 10,
                options: vec![
                    String::from("Yes"),
                    String::from("Maybe"),
                    String::from("No"),
                ],
            }),
            Box::new(Button {
                width: 50,
                height: 10,
                label: String::from("OK"),
            }),
        ],
    };

    screen.run();
}

Коли ми писали бібліотеку, ми не знали, що хтось може додати тип SelectBox, але наша реалізація Screen змогла працювати з новим типом і намалювати його, тому що SelectBox реалізує трейт Draw, що означає, що він реалізує метод draw.

Ця концепція — дбати лише про повідомлення, на які значення відповідає, а не про конкретний тип значення, — подібна до концепції duck typing у мовах із динамічною типізацією: якщо це ходить, як качка, і крякає, як качка, то це мусить бути качка! У реалізації run для Screen у Лістингу 18-5 run не потрібно знати, який конкретний тип має кожен компонент. Він не перевіряє, чи є компонент екземпляром Button або SelectBox, він просто викликає метод draw на компоненті. Вказавши Box<dyn Draw> як тип значень у векторі components, ми визначили Screen так, що йому потрібні значення, для яких ми можемо викликати метод draw.

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

Наприклад, Лістинг 18-10 показує, що станеться, якщо ми спробуємо створити Screen з String як компонентом.

use gui::Screen;

fn main() {
    let screen = Screen {
        components: vec![Box::new(String::from("Hi"))],
    };

    screen.run();
}

Ми отримаємо цю помилку, тому що String не реалізує трейт Draw:

$ cargo run
   Compiling gui v0.1.0 (file:///projects/gui)
error[E0277]: the trait bound `String: Draw` is not satisfied
 --> src/main.rs:5:26
  |
5 |         components: vec![Box::new(String::from("Hi"))],
  |                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Draw` is not implemented for `String`
  |
  = help: the trait `Draw` is implemented for `Button`
  = note: required for the cast from `Box<String>` to `Box<dyn Draw>`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `gui` (bin "gui") due to 1 previous error

Ця помилка повідомляє нам, що або ми передаємо до Screen щось, що не мали на увазі передати, і тому повинні передати інший тип, або ми повинні реалізувати Draw для String, щоб Screen міг викликати на ньому draw.

Виконання динамічної диспетчеризації

Згадайте в “Продуктивність коду з узагальненнями” у розділі 10 наше обговорення процесу моноформізації, який компілятор виконує для узагальнень: компілятор генерує неузагальнені реалізації функцій і методів для кожного конкретного типу, який ми використовуємо замість узагальненого параметра типу. Код, що виникає внаслідок моноформізації, виконує статичну диспетчеризацію, тобто коли компілятор знає, який метод ви викликаєте під час компіляції. Це протиставляється динамічній диспетчеризації, тобто коли компілятор не може визначити під час компіляції, який метод ви викликаєте. У випадках динамічної диспетчеризації компілятор видає код, який під час виконання знатиме, який метод викликати.

Коли ми використовуємо трейт-об’єкти, Rust має використовувати динамічну диспетчеризацію. Компілятор не знає всіх типів, які можуть використовуватися з кодом, що використовує трейт-об’єкти, тому він не знає, який метод, реалізований для якого типу, викликати. Замість цього під час виконання Rust використовує вказівники всередині трейт-об’єкта, щоб знати, який метод викликати. Це звернення коштує часу виконання, чого не відбувається зі статичною диспетчеризацією. Динамічна диспетчеризація також заважає компілятору обрати вбудовування коду методу, що, у свою чергу, заважає деяким оптимізаціям, і в Rust є деякі правила щодо того, де можна, а де не можна використовувати динамічну диспетчеризацію, які називаються dyn-сумісністю. Ці правила виходять за межі цього обговорення, але ви можете прочитати більше про них у довіднику. Однак ми отримали додаткову гнучкість у коді, який ми написали в Лістингу 18-5, і змогли підтримати її в Лістингу 18-9, тож це компроміс, який варто враховувати.