Використання Box<T> для вказування на дані в купі (heap)
Найпростіший розумний вказівник — це box, тип якого записується як
Box<T>. Boxes дають змогу зберігати дані в купі, а не у стеку.
У стеку залишається вказівник на дані в купі. Зверніться до розділу 4,
щоб повторити різницю між стеком і купою.
Boxes не мають накладних витрат на продуктивність, окрім зберігання своїх даних у купі замість стеку. Але вони також не мають багатьох додаткових можливостей. Ви найчастіше використовуватимете їх у таких ситуаціях:
- Коли ви маєте тип, розмір якого неможливо знати під час компіляції, і хочете використати значення цього типу в контексті, який вимагає точного розміру
- Коли ви маєте велику кількість даних і хочете передати власність, але переконатися, що під час цього дані не буде скопійовано
- Коли ви хочете володіти значенням і вам важливо лише, щоб це був тип, який реалізує певний трейт, а не конкретний тип
Ми продемонструємо першу ситуацію в “Увімкнення рекурсивних типів за допомогою Boxes”. У другому випадку передавання власності на велику кількість даних може зайняти багато часу, тому що дані копіюються по стеку. Щоб поліпшити продуктивність у цій ситуації, ми можемо зберігати велику кількість даних у купі в box. Тоді лише невелика кількість даних вказівника копіюється по стеку, тоді як дані, на які він посилається, залишаються в одному місці в купі. Третій випадок відомий як трейт-об’єкт, і “Використання трейт-об’єктів для абстрагування над спільною поведінкою” у розділі 18 присвячений цій темі. Отже, те, що ви дізнаєтеся тут, ви знову застосуєте в тому розділі!
Зберігання даних у купі
Перш ніж ми обговоримо випадок використання Box<T> для зберігання в купі,
ми розглянемо синтаксис і те, як взаємодіяти зі значеннями, що зберігаються
всередині Box<T>.
У переліку 15-1 показано, як використовувати box для зберігання значення
i32 у купі.
fn main() {
let b = Box::new(5);
println!("b = {b}");
}
Ми визначаємо змінну b так, щоб вона мала значення Box, який вказує на
значення 5, розміщене в купі. Ця програма виведе b = 5; у цьому випадку
ми можемо отримати доступ до даних у box подібно до того, як ми зробили б це,
якби ці дані були в стеку. Як і будь-яке значення, що ним володіють, коли box
виходить з області видимості, як це відбувається з b наприкінці main, він
буде звільнений. Звільнення відбувається і для box (який зберігається у
стеку), і для даних, на які він вказує (які зберігаються в купі).
Поміщення одного значення в купу не дуже корисне, тому ви нечасто
використовуватимете boxes самі по собі таким чином. У більшості ситуацій
більш доречним є зберігання таких значень, як одне i32, у стеку, де вони за
замовчуванням і зберігаються. Розгляньмо випадок, у якому boxes дають нам
змогу визначати типи, які ми не мали б права визначити, якби в нас не було
boxes.
Увімкнення рекурсивних типів за допомогою Boxes
Значення рекурсивного типу може містити інше значення того самого типу як частину себе. Рекурсивні типи створюють проблему, тому що Rust потрібно знати під час компіляції, скільки місця займає тип. Однак вкладеність значень рекурсивних типів теоретично може продовжуватися нескінченно, тож Rust не може знати, скільки місця потребує значення. Оскільки boxes мають відомий розмір, ми можемо увімкнути рекурсивні типи, вставивши box у визначення рекурсивного типу.
Як приклад рекурсивного типу, розгляньмо cons list. Це тип даних, який зазвичай зустрічається у функціональних мовах програмування. Тип cons list, який ми визначимо, є простим, окрім рекурсії; отже, концепції в прикладі, який ми розглядатимемо, будуть корисні щоразу, коли ви зіткнетеся з більш складними ситуаціями, пов’язаними з рекурсивними типами.
Розуміння Cons List
Cons list — це структура даних, що походить із мови програмування Lisp та її
діалектів, складається зі вкладених пар і є версією зв’язаного списку в Lisp.
Назва походить від функції cons (скорочення від construct function) у
Lisp, яка створює нову пару зі своїх двох аргументів. Викликаючи cons для
пари, що складається зі значення та іншої пари, ми можемо будувати cons lists,
які складаються з рекурсивних пар.
Наприклад, ось псевдокодове представлення cons list, що містить список 1, 2, 3, де кожна пара подана в дужках:
(1, (2, (3, Nil)))
Кожен елемент cons list містить два елементи: значення поточного елемента та
значення наступного елемента. Останній елемент у списку містить лише значення,
яке називається Nil, без наступного елемента. Cons list утворюється
рекурсивним викликом функції cons. Загальноприйнята назва для позначення
базового випадку рекурсії — Nil. Зауважте, що це не те саме, що концепція
“null” або “nil”, про яку йшлося в розділі 6, яка є недійсним або відсутнім
значенням.
Cons list не є поширено використовуваною структурою даних у Rust. У більшості
випадків, коли у вас є список елементів у Rust, кращим вибором є Vec<T>.
Інші, складніші рекурсивні типи даних дійсно корисні в різних ситуаціях, але
починаючи з cons list у цьому розділі, ми можемо дослідити, як boxes дають
нам змогу визначати рекурсивний тип даних без особливих відволікань.
У переліку 15-2 міститься визначення enum для cons list. Зауважте, що цей код
ще не скомпілюється, тому що тип List не має відомого розміру, що ми й
продемонструємо.
enum List {
Cons(i32, List),
Nil,
}
fn main() {}
Примітка: Ми реалізуємо cons list, який містить лише значення
i32, для цілей цього прикладу. Ми могли б реалізувати його за допомогою generics, як ми обговорювали в розділі 10, щоб визначити тип cons list, який міг би зберігати значення будь-якого типу.
Використання типу List для зберігання списку 1, 2, 3 виглядало б як код у
переліку 15-3.
enum List {
Cons(i32, List),
Nil,
}
// --snip--
use crate::List::{Cons, Nil};
fn main() {
let list = Cons(1, Cons(2, Cons(3, Nil)));
}
Перше значення Cons містить 1 і ще одне значення List. Це значення
List є ще одним значенням Cons, яке містить 2 і ще одне значення List.
Це значення List є ще одним значенням Cons, яке містить 3 і значення
List, яке нарешті є Nil, нерекурсивним варіантом, що сигналізує про
кінець списку.
Якщо ми спробуємо скомпілювати код у переліку 15-3, отримаємо помилку, показану в переліку 15-4.
$ cargo run
Compiling cons-list v0.1.0 (file:///projects/cons-list)
error[E0072]: recursive type `List` has infinite size
--> src/main.rs:1:1
|
1 | enum List {
| ^^^^^^^^^
2 | Cons(i32, List),
| ---- recursive without indirection
|
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
|
2 | Cons(i32, Box<List>),
| ++++ +
error[E0391]: cycle detected when computing when `List` needs drop
--> src/main.rs:1:1
|
1 | enum List {
| ^^^^^^^^^
|
= note: ...which immediately requires computing when `List` needs drop again
= note: cycle used when computing whether `List` needs drop
= note: see https://rustc-dev-guide.rust-lang.org/overview.html#queries and https://rustc-dev-guide.rust-lang.org/query.html for more information
Some errors have detailed explanations: E0072, E0391.
For more information about an error, try `rustc --explain E0072`.
error: could not compile `cons-list` (bin "cons-list") due to 2 previous errors
У повідомленні про помилку показано, що цей тип “has infinite size”. Причина
полягає в тому, що ми визначили List з варіантом, який є рекурсивним: він
безпосередньо містить інше значення самого себе. У результаті Rust не може
визначити, скільки місця йому потрібно для зберігання значення List.
Розберімо, чому ми отримуємо цю помилку. Спочатку ми подивимося, як Rust
визначає, скільки місця йому потрібно для зберігання значення нерекурсивного
типу.
Обчислення розміру нерекурсивного типу
Згадайте enum Message, який ми визначили в переліку 6-2, коли обговорювали
визначення enum у розділі 6:
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
fn main() {}
Щоб визначити, скільки місця виділити для значення Message, Rust проходить
кожен з варіантів, щоб побачити, який варіант потребує найбільше місця. Rust
бачить, що Message::Quit не потребує жодного місця, Message::Move
потребує достатньо місця для зберігання двох значень i32, і так далі.
Оскільки буде використано лише один варіант, найбільше місця, яке знадобиться
значенню Message, — це місце, потрібне для зберігання найбільшого з його
варіантів.
Порівняйте це з тим, що відбувається, коли Rust намагається визначити, скільки
місця потребує рекурсивний тип, такий як enum List у переліку 15-2.
Компілятор починає з варіанта Cons, який містить значення типу i32 і
значення типу List. Отже, Cons потребує обсяг місця, що дорівнює розміру
i32 плюс розмір List. Щоб визначити, скільки пам’яті потребує тип List,
компілятор розглядає варіанти, починаючи з варіанта Cons. Варіант Cons
містить значення типу i32 і значення типу List, і цей процес триває
нескінченно, як показано на рисунку 15-1.
Рисунок 15-1: Нескінченний List, що складається з
нескінченних варіантів Cons
Отримання рекурсивного типу з відомим розміром
Оскільки Rust не може визначити, скільки місця виділити для рекурсивно визначених типів, компілятор видає помилку з такою корисною підказкою:
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
|
2 | Cons(i32, Box<List>),
| ++++ +
У цій підказці indirection означає, що замість безпосереднього зберігання значення ми повинні змінити структуру даних так, щоб зберігати значення непрямо, тобто зберігаючи замість нього вказівник на значення.
Оскільки Box<T> є вказівником, Rust завжди знає, скільки місця потребує
Box<T>: розмір вказівника не змінюється залежно від обсягу даних, на які він
вказує. Це означає, що ми можемо помістити Box<T> всередину варіанта Cons
замість іншого значення List безпосередньо. Box<T> вказуватиме на наступне
значення List, яке буде в купі, а не всередині варіанта Cons.
Концептуально ми все ще маємо список, створений зі списків, що містять інші
списки, але ця реалізація тепер більше схожа на розміщення елементів поруч один
з одним, а не всередині один одного.
Ми можемо змінити визначення enum List у переліку 15-2 і використання List
у переліку 15-3 на код у переліку 15-5, який скомпілюється.
enum List {
Cons(i32, Box<List>),
Nil,
}
use crate::List::{Cons, Nil};
fn main() {
let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
}
Варіант Cons потребує розміру i32 плюс місце для зберігання даних
вказівника box. Варіант Nil не зберігає жодних значень, тому йому потрібно
менше місця у стеку, ніж варіанту Cons. Тепер ми знаємо, що будь-яке
значення List займатиме розмір i32 плюс розмір даних вказівника box.
Використовуючи box, ми розірвали нескінченний, рекурсивний ланцюг, тож
компілятор може визначити розмір, потрібний для зберігання значення List.
Рисунок 15-2 показує, як тепер виглядає варіант Cons.
Рисунок 15-2: List, який не має нескінченного розміру,
тому що Cons містить Box
Boxes надають лише непряме зберігання та виділення в купі; вони не мають жодних інших особливих можливостей, як ті, які ми побачимо в інших типах розумних вказівників. Вони також не мають накладних витрат на продуктивність, які спричиняють ці особливі можливості, тому вони можуть бути корисними у випадках, подібних до cons list, де непряме зберігання є єдиною потрібною нам можливістю. Ми розглянемо більше варіантів використання boxes у розділі 18.
Тип Box<T> є розумним вказівником, тому що він реалізує трейт Deref,
який дає змогу розглядати значення Box<T> як посилання. Коли значення
Box<T> виходить з області видимості, дані в купі, на які вказує box, також
очищуються завдяки реалізації трейта Drop. Ці два трейт-и стануть ще
важливішими для функціональності, яку надають інші типи розумних вказівників,
що ми обговоримо в решті цього розділу. Розгляньмо ці два трейт-и докладніше.