Характеристики об'єктно орієнтованого програмування

Спільнота програмістів не дійшла згоди в питані того, що повинна містити мова, щоб вважатися об'єктно орієнтованою. На Rust вплинуло багато парадигм програмування, включно з ООП; наприклад, ми розглянули особливості, які прийшли з функціонального програмування, у Розділі 13. Беззаперечно, ООП мови містять у собі деякі спільні характеристики, а саме об'єкти, інкапсуляцію і наслідування. Тож розгляньмо, що кожна з цих характеристик значить і чи Rust її підтримує.

Об'єкти, котрі місять дані та поведінку

Книга Еріха Гамми, Річарда Гелма, Ральфа Джонсона і Джона Вліссайдса Design Patterns: Elements of Reusable Object-Oriented Software(Addison-Wesley Professional, 1994), яку в розмовній мові називають книгою Банди Чотирьох, є каталогом шаблонів об'єктно орієнтованого дизайну. В книзі ООП визначається наступним способом:

Об'єктно орієнтовані програми складаються з об'єктів. Об'єкт формується як даними, так і процедурами, котрі працюють з цими даними. Цими процедурами є так звані методи або операції.

Користуючись цим визначенням, Rust є об'єктно орієнтованим: структури та енуми містять дані, а блоки impl дозволяє реалізовувати методи для структур і енумів. Не зважаючи на те, що структури й енуми з методами не називають об'єктами, вони містять той самий функціонал, що й об'єкти згідно з визначенням Банди Чотирьох.

Інкапсуляція, яка приховує деталі реалізації

Іншим аспектом, який часто асоціюють з ООП, є ідея інкапсуляції. Головною ідеєю цього аспекту є те, що деталі реалізації об'єкта не є доступними з коду, який цей об'єкт користує. З цього випливає, що єдиним способом взаємодії з об'єктом є його публічне API; код, який використовує об'єкт, не повинен мати можливості прямого доступу до даних об'єкту, його внутрішнього стану чи безпосередньої зміни поведінки об'єкта. Це дозволяє програмісту змінювати і рефакторити внутрішній код об'єкта без необхідності зміни коду, який використовує об'єкт.

Ми обговорили, як контролювати інкапсуляцію, у Розділі 7: ми можемо використовувати ключове слово pub, щоб визначити, які модулі, типи, функції і методи нашого коду повинні бути публічними. За замовчанням усе є приватним. Наприклад, ми можемо визначити структуру AveragedCollection, котра містить поле - вектор значеньi32. Структура також може містити поле, яке зберігає середнє значення в векторі, що дозволяє не перераховувати середнє значення кожен раз, коли хтось його запросить. Іншими словами, AveragedCollection буде кешувати підраховане середнє значення для нас. В Блоці коду 17-1 міститься визначення структури AveragedCollection:

Файл: src/lib.rs

pub struct AveragedCollection {
    list: Vec<i32>,
    average: f64,
}

Блок коду 17-1: Структура `AveragedCollection', що зберігає список цілих чисел і середнє значення елементів у колекції

Структуру позначено pub, щоб інший код міг її використовувати, але поля структури залишаться приватними. Це важливо, оскільки ми хочемо гарантувати, що коли б ми не додали чи забрали якесь значення зі списку -- середнє значення теж оновилось. Ми досягаємо цього, реалізуючи для структури методи add, remove і average так, як показано в Блоці коду 17-2:

Файл: src/lib.rs

pub struct AveragedCollection {
    list: Vec<i32>,
    average: f64,
}

impl AveragedCollection {
    pub fn add(&mut self, value: i32) {
        self.list.push(value);
        self.update_average();
    }

    pub fn remove(&mut self) -> Option<i32> {
        let result = self.list.pop();
        match result {
            Some(value) => {
                self.update_average();
                Some(value)
            }
            None => None,
        }
    }

    pub fn average(&self) -> f64 {
        self.average
    }

    fn update_average(&mut self) {
        let total: i32 = self.list.iter().sum();
        self.average = total as f64 / self.list.len() as f64;
    }
}

Блок коду 17-2: Реалізації публічних методів add, remove і average для AveragedCollection

Публічні методи add, remove і average є єдиним способом, щоб отримати доступ чи змінити дані в екземплярі AveragedCollection. Коли ми додаємо елемент до list за допомогою методу add чи видаляємо методом remove, реалізація всіх методів викличе приватний метод update_average, який обробить зміну середнього значення.

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

Оскільки ми інкапсулювали деталі реалізації структури AveragedCollection, ми без проблем можемо змінити аспекти реалізації структури в майбутньому. Наприклад, ми можемо використати a HashSet<i32> замість Vec<i32> для поля list. Доки сигнатура публічних методів add, removeі average залишається незмінною, використання AveragedCollection не потрібно буде змінювати. Якби ми зробили list публічним, можливо б довелося змінювати спосіб взаємодії зі структурою: HashSet<i32> і Vec<i32> мають різні способи додавання і видалення елементів, тому користувачеві структури, скоріше за все, довелося б змінювати використання структури list.

Якщо інкапсуляція є обов'язковим аспектом для об'єктно орієнтованої мови, то Rust можна вважати такою. Наявність чи відсутність ключового слова pub дозволяє інкапсулювати деталі реалізації.

Наслідування як система типів, а також як система спільного використання коду

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

Якщо мова повинна мати наслідування, щоб бути об’єктно орієнтованою, то Rust не є нею. Тут нема способу визначити структуру, яка успадковує поля та реалізації методів батьківської структури, без використання макросу.

Однак, якщо ви звикли мати успадкування у своєму наборі інструментів програмування, ви можете використовувати інші рішення в Rust, залежно від того, чому ви спочатку звернулися до успадкування.

Є дві основні причини, щоб використовувати наслідування. Перша з них, це щоб перевикористати код: ви можете реалізувати поведінку для якогось одного типа, а наслідування дозволить вам перевикористати реалізацію для іншого типу. Ви можете використати обмежену версію цього підходу в Rust, з допомогою усталеної реалізації трейту, яку ви бачили в роздруківці 10-14, коли ми додали усталену реалізацію методу summarize для трейту Summary. Кожний тип, який реалізовує трейт Summary матиме доступним метод summarize без повторного написання коду. Це є схожим до батьківського класу, який містить реалізацію методу, і дочірнього класу, який успадкувує реалізацію цього ж методу. Також у випадках реалізації трейту Summary, ми можемо перевизначити усталену реалізацію методу summarize власною, що схоже до перевизначення батьківського методу в дочірньому класі.

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

Поліморфізм

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

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

Останнім часом програмісти почали значно рідше використовувати наслідування, оскільки наявний великий ризик перевикористання більше коду ніж необхідно. Підкласи не завжди повинні мати всі ті ж самі характеристики, що і їхні батьки, однак ми завжди робимо так з наслідуванням. Це може зробити архітектуру програм менш гнучкою. Також це створює можливість викликати методи в підкласах попри те, що це не має сенсу чи викликає помилки, бо цей метод не застосовний для цього підкласу. На додачу до цього, деякі мови забороняють множинне наслідування (тобто підклас може наслідуватися лише під одного класу), що ще сильніше зменшує гнучкість програм.

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