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