Характеристики об’єктно-орієнтованих мов
У спільноті програмування немає консенсусу щодо того, якими саме можливостями має володіти мова, щоб вважатися об’єктно-орієнтованою. Rust зазнав впливу багатьох парадигм програмування, зокрема OOP; наприклад, ми дослідили можливості, що походять із функціонального програмування, у Розділі 13. Можна стверджувати, що OOP-мови мають певні спільні характеристики — а саме об’єкти, інкапсуляцію та успадкування. Давайте розглянемо, що означає кожна з цих характеристик і чи підтримує їх Rust.
Об’єкти містять дані та поведінку
Книга Design Patterns: Elements of Reusable Object-Oriented Software авторів Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides (Addison-Wesley, 1994), яку в розмовній мові називають книгою The Gang of Four, є каталогом об’єктно-орієнтованих шаблонів проєктування. Вона визначає OOP так:
Об’єктно-орієнтовані програми складаються з об’єктів. Об’єкт упаковує як дані, так і процедури, що працюють із цими даними. Процедури зазвичай називають методами або операціями.
Згідно з цим визначенням, Rust є об’єктно-орієнтованим: структури та переліки
мають дані, а блоки impl надають методи для структур і переліків. Хоча
структури та переліки з методами не називають об’єктами, вони надають ту саму
функціональність, відповідно до визначення об’єктів із книги Gang of Four.
Інкапсуляція, що приховує деталі реалізації
Інший аспект, який зазвичай пов’язують з OOP, — це ідея інкапсуляції, що означає, що деталі реалізації об’єкта недоступні для коду, який використовує цей об’єкт. Отже, єдиний спосіб взаємодіяти з об’єктом — через його публічний API; код, що використовує об’єкт, не повинен мати змоги дістатися до внутрішньої структури об’єкта і змінювати дані або поведінку напряму. Це дає програмісту змогу змінювати та рефакторити внутрішню структуру об’єкта без потреби змінювати код, який використовує об’єкт.
Ми обговорювали, як керувати інкапсуляцією в Розділі 7: ми можемо використати
ключове слово pub, щоб вирішити, які модулі, типи, функції та методи в нашому
коді мають бути публічними, а за замовчуванням усе інше є приватним. Наприклад,
ми можемо визначити структуру AveragedCollection, яка має поле, що містить
вектор значень i32. Структура також може мати поле, що містить середнє
значення елементів у векторі, тобто середнє не потрібно обчислювати на вимогу
кожного разу, коли воно потрібне. Іншими словами, AveragedCollection буде
кешувати обчислене середнє значення для нас. У Переліку 18-1 наведено
визначення структури AveragedCollection.
pub struct AveragedCollection {
list: Vec<i32>,
average: f64,
}
Структура позначена як pub, щоб інший код міг її використовувати, але поля
всередині структури залишаються приватними. Це важливо в цьому випадку, тому що
ми хочемо гарантувати, що щоразу, коли значення додається до списку або
видаляється з нього, середнє значення також оновлюється. Ми робимо це,
реалізуючи методи add, remove і average для структури, як показано в
Переліку 18-2.
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;
}
}
Публічні методи add, remove і average — це єдині способи отримати доступ
до даних або змінити їх в екземплярі AveragedCollection. Коли елемент
додається до list за допомогою методу add або видаляється за допомогою
методу remove, реалізації кожного з них викликають приватний метод
update_average, який також відповідає за оновлення поля average.
Ми залишаємо поля list і average приватними, щоб не було способу для
зовнішнього коду додавати елементи до поля list або видаляти їх із нього
безпосередньо; інакше поле average могло б стати несинхронним, коли list
змінюється. Метод average повертає значення в полі average, дозволяючи
зовнішньому коду читати average, але не змінювати його.
Оскільки ми інкапсулювали деталі реалізації структури
AveragedCollection, ми можемо легко змінити такі аспекти, як структура даних,
у майбутньому. Наприклад, ми могли б використати 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 без додаткового коду. Це подібно до того, як батьківський клас має
реалізацію методу, а дочірній клас, що успадковує, також має реалізацію цього
методу. Ми також можемо перевизначити реалізацію методу summarize за
замовчуванням, коли реалізуємо трейт Summary, що подібно до того, як дочірній
клас перевизначає реалізацію методу, успадкованого від батьківського класу.
Інша причина використовувати успадкування пов’язана зі системою типів: щоб дозволити використовувати дочірній тип у тих самих місцях, що й батьківський тип. Це також називається поліморфізмом, що означає, що ви можете підставляти кілька об’єктів один замість одного під час виконання, якщо вони мають певні спільні характеристики.
Поліморфізм
Для багатьох людей поліморфізм є синонімом успадкування. Але насправді це більш загальне поняття, яке стосується коду, здатного працювати з даними кількох типів. Для успадкування цими типами зазвичай є підкласи.
Натомість Rust використовує узагальнені типи, щоб абстрагуватися від різних можливих типів, і обмеження трейтів, щоб накладати умови на те, що ці типи повинні надавати. Це іноді називають обмеженим параметричним поліморфізмом.
Rust обрав інший набір компромісів, не пропонуючи успадкування. Успадкування часто пов’язане з ризиком спільного використання більшої кількості коду, ніж потрібно. Підкласи не завжди повинні ділити всі характеристики свого батьківського класу, але з успадкуванням це відбувається. Це може зробити проєктування програми менш гнучким. Воно також створює можливість виклику методів на підкласах, які не мають сенсу або спричиняють помилки, тому що методи не застосовуються до підкласу. Крім того, деякі мови дозволяють лише одиночне успадкування (тобто підклас може успадковувати лише від одного класу), що ще більше обмежує гнучкість проєктування програми.
З цих причин Rust обирає інший підхід, використовуючи трейт-об’єкти замість успадкування, щоб досягати поліморфізму під час виконання. Давайте подивимося, як працюють трейт-об’єкти.