Использование Box<T> для ссылки на данные в куче

Наиболее простой умный указатель - это box, чей тип записывается как Box<T>. Такие переменные позволяют хранить данные в куче, а не в стеке. То, что остаётся в стеке, является указателем на данные в куче. Обратитесь к Главе 4, чтобы рассмотреть разницу между стеком и кучей.

У Box нет проблем с производительностью, кроме хранения данных в куче вместо стека. Но он также и не имеет множества дополнительных возможностей. Вы будете использовать его чаще всего в следующих ситуациях:

  • Если у вас есть тип, размер которого не может быть известен во время компиляции и вы хотите использовать значение этого типа в контексте, который требует точного размера
  • Когда у вас есть большой объем данных и вы хотите передать его во владение, но убедиться, что данные не будут скопированы, когда вы это сделаете
  • Когда вы хотите иметь значение и вам важно только то, что это тип, который реализует конкретный типаж, а не является конкретный типом

Мы продемонстрируем первую ситуацию в разделе "Реализация рекурсивных типов с помощью Box". Во втором случае, передача владения на большой объем данных может занять много времени, потому что данные копируются через стек. Для повышения производительности в этой ситуации, мы можем хранить большое количество данных в куче с помощью Box. Затем только небольшое количество данных указателя копируется в стеке, в то время как данные, на которые он ссылается, остаются в одном месте кучи. Третий случай известен как типаж объект (trait object) и глава 17 посвящает целый раздел "Использование типаж объектов, которые допускают значения разных типов" только этой теме. Итак, то что вы узнаете здесь, вы примените снова в Главе 17!

Использование Box<T> для хранения данных в куче

Прежде чем мы обсудим этот вариант использования Box<T>, мы рассмотрим синтаксис и как взаимодействовать со значениями, хранящимися в Box<T>.

В листинге 15-1 показано, как использовать поле для хранения значения i32 в куче:

Файл: src/main.rs

fn main() {
    let b = Box::new(5);
    println!("b = {}", b);
}

Листинг 15-1: Сохранение значения i32 в куче с использованием box

Мы определяем переменную b как имеющую значение типа Box, указывающее на значение 5 в куче. Эта программа напечатает b = 5; в этом случае мы можем получить доступ к данным в поле, как если бы это были данные в стеке. Как и любое значение во владении, данная память будет освобождена, когда box выйдет из области действия, что происходит с b в конце main. Освобождается память, занимаемая box (хранится в стеке), и тех данных, на которые он указывает (хранятся в куче).

Размещение единственного значения в куче не очень полезно, поэтому вы не будете часто использовать box сам по себе таким способом. Иметь единственное значение i32 в стеке, где они хранятся по умолчанию, подходит в большинстве ситуаций. Давайте рассмотрим случай, когда Box позволяет определять типы, которые были бы невозможны, если бы у нас не было Box.

Включение рекурсивных типов с помощью Boxes

Во время компиляции Rust должен знать, сколько места занимает тип. Некоторый тип, чей размер не может быть известен во время компиляции, является рекурсивным типом (recursive type), где значение может иметь в своём составе другое значение того же типа. По причине того, что это вложение значений может теоретически продолжаться бесконечно, Rust не знает, сколько пространства памяти необходимо для значений рекурсивного типа. Однако Box имеет известный размер, поэтому используя Box в определении рекурсивного типа, можно его реализовать.

Давайте рассмотрим cons список (cons - функция конструктор, создаёт объекты памяти, которые содержат два значения или указатели на значения), который является распространённым в функциональных языках программирования типом данных, как пример рекурсивного типа. Тип "cons список", который мы определим, является простым, за исключением рекурсии; поэтому концепции, используемые в примере, с которым мы будем работать, будут полезны и в более сложных ситуациях, связанных с рекурсивными типами.

Больше информации о cons списке

cons список (cons list) - это структура данных, которая пришла из языка программирования Lisp и его диалектов. В Lisp, функция cons (сокращение от "construct function" функция-конструктор) создаёт новую пару используя два аргумента, один из которых значение, а другой - пара. Эти пары, содержащие другие пары, образуют список.

Концепция функции конструктора прошла свой путь и превратилась в более общий функциональный, программный жаргон: "to cons х onto у" неформально означает создание нового экземпляра контейнера, помещая элемент х в начале нового контейнера, за которым следует контейнер y.

Каждый элемент в cons списке содержит два элемента: значение текущего элемента и следующий элемент. Последний элемент в списке содержит только значение называемое Nil без следующего элемента. Cons список создаётся путём рекурсивного вызова функции cons. Каноничное имя для обозначения базового случая рекурсии - Nil. Обратите внимание, что это не то же самое, что понятие “null” или “nil” из главы 6, которая является недействительным или отсутствующим значением.

Хотя функциональные языки программирования часто используют cons списки, этот список не является широко используемой структурой данных в Rust. Большую часть времени, когда есть список элементов в Rust, лучше использовать Vec<T>. Более сложные рекурсивные типы данных являются полезными в различных ситуациях, но начиная изучение с cons списка, мы можем исследовать, как box-ы позволяют определить рекурсивный тип данных без особых проблем.

Листинг 15-2 содержит объявление перечисления cons списка. Обратите внимание, что этот код не будет компилироваться, потому что тип List не имеет известного размера, что мы и продемонстрируем.

Файл: src/main.rs

enum List {
    Cons(i32, List),
    Nil,
}

fn main() {}

Листинг 15-2: Первая попытка определить перечисление для представления структуры данных cons списка из значений i32

Примечание: мы реализуем cons список, который для примера содержит только значения i32. Чтобы определить тип cons список для хранения значений любого типа, мы могли бы использовать обобщённые типы, как обсуждалось в главе 10.

Использование типа List для хранения списка 1, 2, 3 будет выглядеть как код в листинге 15-3:

Файл: src/main.rs

enum List {
    Cons(i32, List),
    Nil,
}

use crate::List::{Cons, Nil};

fn main() {
    let list = Cons(1, Cons(2, Cons(3, Nil)));
}

Листинг 15-3: Использование перечисления 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 {
  | ^^^^^^^^^ recursive type has infinite size
2 |     Cons(i32, List),
  |               ---- recursive without indirection
  |
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to make `List` representable
  |
2 |     Cons(i32, Box<List>),
  |               ^^^^    ^

error[E0391]: cycle detected when computing drop-check constraints for `List`
 --> src/main.rs:1:1
  |
1 | enum List {
  | ^^^^^^^^^
  |
  = note: ...which again requires computing drop-check constraints for `List`, completing the cycle
  = note: cycle used when computing dropck types for `Canonical { max_universe: U0, variables: [], value: ParamEnvAnd { param_env: ParamEnv { caller_bounds: [], reveal: UserFacing }, value: List } }`

error: aborting due to 2 previous errors

Some errors have detailed explanations: E0072, E0391.
For more information about an error, try `rustc --explain E0072`.
error: could not compile `cons-list`

To learn more, run the command again with --verbose.

Листинг 15-3: Ошибка, получаемая при попытке определить бесконечное рекурсивное перечисление

Ошибка сообщает, что этот тип "имеет бесконечный размер". Причина в том, что мы определили List с рекурсивным вариантом: он содержит другое значение самого себя. В результате Rust не может понять, сколько места ему нужно для хранения значения List. Давайте разберёмся, почему мы получаем эту ошибку. Во-первых, давайте посмотрим как Rust решает, сколько места ему нужно для хранения значения нерекурсивного типа.

Вычисление размера нерекурсивного типа

Вспомните перечисление 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 пытается определить, сколько места необходимо рекурсивному типу, такому как перечисление List в листинге 15-2. Компилятор смотрит на вариант Cons, который содержит значение типа i32 и значение типа List. Следовательно, Cons нужно пространство, равное размеру i32 плюс размер List. Чтобы выяснить, сколько памяти необходимо типу List, компилятор смотрит на варианты, начиная с Cons. Вариант Cons содержит значение типа i32 и значение типа List, и этот процесс продолжается бесконечно, как показано на рисунке 15-1.

Бесконечный список Cons

Рисунок 15-1: Бесконечный List, состоящий из бесконечных вариантов Cons

Использование Box<T> для получения рекурсивного типа с известным размером

Rust не может понять, сколько места выделить для типов определённых рекурсивно, поэтому компилятор выдаёт ошибку в листинге 15-4. Но ошибка включает в себя это полезное предложение:

help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to make `List` representable
  |
2 |     Cons(i32, Box<List>),
  |               ^^^^    ^

В этом предложении "косвенность" означает, что вместо сохранения значения напрямую, мы изменим структуру данных для хранения косвенного значения, с помощью хранения указателя на значение.

Поскольку Box<T> является указателем, Rust всегда знает, сколько памяти нужно для Box<T>: размер указателя не изменяется в зависимости от размера данных, на которые он указывает. Это значит, что мы можем поместить тип Box<T> в вариант перечисления Cons вместо помещения List напрямую. Поле Box<T> будет указывать на следующее значение List, которое будет в куче, а не внутри варианта Cons. Концептуально у нас все ещё есть список, созданный списками, "содержащими" другие списки, но эта реализация теперь больше похожа на размещение элементов рядом друг с другом, а не внутри друг друга.

Мы можем изменить определение перечисления List в листинге 15-2 и использование List в листинге 15-3 на код из листинга 15-5, который будет компилироваться:

Файл: src/main.rs

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))))));
}

Листинг 15-5: Определение List, которое использует Box, чтобы иметь известный размер

Варианту Cons понадобится размер i32 плюс место для хранения данных указателя Box. Вариант Nil не хранит значений, поэтому ему нужно меньше места, чем варианту Cons. Теперь мы знаем, что любое значение List будет занимать размер i32 плюс размер данных указателя Box. Используя Box, мы сломали бесконечную рекурсивную цепочку, так что компилятор может определить размер, необходимый для хранения значения List. На рисунке 15-2 показано как теперь выглядит вариант Cons.

Бесконечный список Cons

Рисунок 15-2: List, размер которого не безграничен, потому что Cons содержит Box

Box-ы обеспечивают только косвенность и выделение в куче; у них нет других специальных возможностей, таких как те, которые мы увидим у других типов умных указателей. Они также не имеют накладных расходов из-за этих специальных возможностей, поэтому могут быть полезны в случаях, похожих на cons список, где косвенность - единственная нужная функциональность. Мы рассмотрим ещё больше вариантов использования типа Box в главе 17.

Тип Box<T> является умным указателем, потому что он реализует типаж Deref, что позволяет значениям Box<T> обрабатываться как ссылки. Когда значение Box<T> выходит из области видимости, то данные кучи, на которые указывает Box, очищаются благодаря реализации типажа Drop. Давайте рассмотрим эти два типажа более подробно. Эти два типажа будут ещё более важными для функциональности, предоставляемой другими типами умных указателей, которые мы обсудим в оставшихся частях этой главы.