Использование Box<T> для указания на данные в куче
Наиболее простым умным указателем является box (бокс), тип которого записывается как Box<T>. Боксы позволяют хранить данные в куче, а не в стеке. То, что остаётся в стеке — это указатель на данные в куче. Обратитесь к Главе 4, чтобы повторить разницу между стеком и кучей.
Боксы не несут накладных расходов на производительность, кроме хранения своих данных в куче вместо стека. Но у них также нет много дополнительных возможностей. Вы будете использовать их чаще всего в следующих ситуациях:
- Когда у вас есть тип, размер которого не может быть известен во время компиляции, и вы хотите использовать значение этого типа в контексте, который требует точный размер
- Когда у вас есть большой объём данных, и вы хотите передать владение, но обеспечить, чтобы данные не копировались при этом
- Когда вы хотите владеть значением, и вас волнует только то, что это тип, реализующий определённый трейт, а не то, что он относится к конкретному типу
Мы продемонстрируем первую ситуацию в разделе «Включение рекурсивных типов с помощью боксов». Во втором случае передача владения большим объёмом данных может занять много времени, потому что данные копируются в стеке. Чтобы повысить производительность в этой ситуации, мы можем хранить большой объём данных в куче в боксе. Тогда только небольшой объём данных указателя копируется в стеке, в то время как данные, на которые он ссылается, остаются в одном месте в куче. Третий случай известен как объект трейта (trait object), и разделу «Использование объектов трейтов для абстракции над общим поведением» в Главе 18 посвящена эта тема. Так что то, что вы узнаете здесь, вы примените снова в том разделе!
Хранение данных в куче
Прежде чем мы обсудим случай использования Box<T> для хранения в куче, мы рассмотрим синтаксис и то, как взаимодействовать со значениями, хранящимися внутри Box<T>.
Listing 15-1 показывает, как использовать бокс для хранения значения i32 в куче.
fn main() { let b = Box::new(5); println!("b = {b}"); }
Мы определяем переменную b со значением Box, который указывает на значение 5, размещённое в куче. Эта программа напечатает b = 5; в этом случае мы можем получить доступ к данным в боксе аналогично тому, как если бы эти данные были в стеке. Так же, как и любое владеемое значение, когда бокс выходит из области видимости, как b делает это в конце main, он будет освобождён. Освобождение происходит как для бокса (хранящегося в стеке), так и для данных, на которые он указывает (хранящихся в куче).
Помещение единственного значения в кучу не очень полезно, поэтому вы не будете часто использовать боксы сами по себе таким образом. Наличие значений, таких как одиночный i32, в стеке, где они хранятся по умолчанию, более уместно в большинстве ситуаций. Давайте рассмотрим случай, когда боксы позволяют нам определять типы, которые мы не смогли бы определить, если бы у нас не было боксов.
Включение рекурсивных типов с помощью боксов
Значение рекурсивного типа может иметь другое значение того же типа как часть себя. Рекурсивные типы представляют проблему, потому что Rust необходимо знать во время компиляции, сколько места занимает тип. Однако вложение значений рекурсивных типов теоретически может продолжаться бесконечно, поэтому Rust не может знать, сколько места нужно значению. Поскольку боксы имеют известный размер, мы можем включить рекурсивные типы, вставив бокс в определение рекурсивного типа.
В качестве примера рекурсивного типа давайте исследуем 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 в этой главе, мы можем исследовать, как боксы позволяют нам определить рекурсивный тип данных без лишних сложностей.
Листинг 15-2 содержит определение перечисления для cons list. Обратите внимание, что этот код пока не скомпилируется, потому что тип List не имеет известного размера, что мы и продемонстрируем.
enum List {
Cons(i32, List),
Nil,
}
fn main() {}
Примечание: Мы реализуем cons list, который хранит только значения
i32для целей этого примера. Мы могли бы реализовать его, используя обобщённые типы, как мы обсуждали в Главе 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
Ошибка показывает, что этот тип «имеет бесконечный размер». Причина в том, что мы определили List с вариантом, который является рекурсивным: он напрямую содержит другое значение самого себя. В результате Rust не может выяснить, сколько места ему нужно для хранения значения List. Давайте разберём, почему мы получаем эту ошибку. Сначала мы посмотрим, как Rust решает, сколько места ему нужно для хранения значения нерекурсивного типа.
Вычисление размера нерекурсивного типа
Вспомните перечисление Message, которое мы определили в Листинге 6-2, когда обсуждали определения перечислений в Главе 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 пытается определить, сколько места нужно рекурсивному типу, такому как перечисление 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. Концептуально у нас всё ещё есть список, созданный списками, содержащими другие списки, но эта реализация теперь больше похожа на размещение элементов рядом друг с другом, а не внутри друг друга.
Мы можем изменить определение перечисления 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 плюс пространство для хранения данных указателя бокса. Вариант Nil не хранит значений, поэтому ему нужно меньше места в стеке, чем варианту Cons. Теперь мы знаем, что любое значение List займёт размер i32 плюс размер данных указателя бокса. Используя бокс, мы разорвали бесконечную рекурсивную цепь, поэтому компилятор может вычислить размер, необходимый для хранения значения List. Рисунок 15-2 показывает, как теперь выглядит вариант Cons.
Рисунок 15-2: List, который не бесконечно sized, потому что Cons содержит Box
Боксы предоставляют только косвенность и выделение в куче; у них нет других специальных возможностей, подобных тем, что мы увидим у других типов умных указателей. Они также не несут накладных расходов на производительность, которые влекут эти специальные возможности, поэтому они могут быть полезны в таких случаях, как cons list, где косвенность — это единственная функция, которая нам нужна. Мы рассмотрим больше случаев использования боксов в Главе 18.
Тип Box<T> является умным указателем, потому что он реализует трейт Deref, который позволяет обращаться со значениями Box<T> как со ссылками. Когда значение Box<T> выходит из области видимости, данные в куче, на которые указывает бокс, также очищаются благодаря реализации трейта Drop. Эти два трейта будут ещё более важны для функциональности, предоставляемой другими типами умных указателей, которые мы обсудим в оставшейся части этой главы. Давайте исследуем эти два трейта более подробно.