Кроме обычных указателей в Rust также есть смарт-указатели, для работы с которыми не нужен unsafe-контекст. Рассмотрим некоторые их типы.
Тип Box позволяет управлять данными в куче. В отличие от значений с фиксированными размерами и временем жизни, которые хранятся в стеке, Box позволяет выделять память для известного типа в куче. После размещения в куче значение принадлежит объекту Box. И когда значение выходит за пределы области видимости, Box автоматически освобождает память.
Box удобно применять в следующих случаях:
Когда данные некоторого типа, размер которого не может быть известен во время компиляции, необходимо использовать в контексте, где компилятору требуется точный размера данных
Когда имеется большой объем данных, и необходимо передать право собственности и при этом гарантировать, что данные не будут скопированы.
Передача права собственности на большой объем данных может занять много времени, поскольку данные копируются в стек. Чтобы повысить производительность в этой ситуации, можно хранить большой объем данных в куче в Box. Затем в стеке копируется лишь небольшой объем данных указателя, а данные, на которые он ссылается, остаются в одном месте в куче.
Когда надо владеть некоторым значением, о типе которого известно только то, что он реализует определенный трейт.
Рассмотрим простейший пример:
fn main() {
let num = Box::new(22); // размещаем в куче число 22 с помощью Box
println!("num: {num}"); // обращаемся к объекту в my_box - получим число
} // Завершение области жизни Box и освобождение связанной с ним памяти
Здесь с помощью Box в куче размещается 22. Далее через переменную num, которая в реальности представляет тип Box, мы можем обращаться к этому числу из кучи точно так же, как если бы эти данные находились в стеке. Когда заканчивается область жизни переменной num - в данном случае при завершении функции main, память в куче будет освобождена. Освобождение происходит как для переменной num в стеке, так и для данных, на которые он указывает в куче.
Другой пример Box - для набора значений:
fn main() {
let my_box = Box::new(vec![1, 2, 3, 4, 5]); // создаем вектор в куче с помощью Box
let sum: i32 = my_box.iter().sum(); // обращаемся к объекту в my_box - получим сумму значений
let len: usize = my_box.len(); // обращаемся к объекту в my_box - получим размер вектора
println!("Sum: {} Len: {}", sum, len); // Sum: 15 Len: 5
} // Завершение области жизни Box и освобождение связанной с ним памяти
Здесь Box используется для выделения памяти для вектора [1, 2, 3, 4, 5], который находится в куче. Это показывает, что Box можно использовать для управления сложными структурами данных в куче. Когда завершается область действия, Box выходит за ее пределы и освобождает занятую память в куче, таким образом, снижая вероятность потенциальных утечек памяти.
Поскольку Box хранит данные в хипе, то использование таких данных может осуществляться вне блока, где Box выделяет память для них. Например, возьмем пример с присвоением ссылки в блоке и в следующем случае мы получим ошибку:
#[derive(Debug)]
struct Point{
x: u64,
y: u64
}
fn main() {
let ref_pt: &Point;
{
let pt = Point {x: 1, y: 2};
ref_pt = &pt; // ! Ошибка
}
println!("{:?}", ref_pt);
}
Поскольку ссылка не может указывать на данные дольше, чем данные существуют, то вне блока, где определены данные, ссылку на эти данные мы использовать не можем.
Теперь вместо ссылки используем Box:
#[derive(Debug)]
struct Point{
x: u64,
y: u64
}
fn main() {
let box_pt: Box<Point>;
{
box_pt = Box::new(Point { x: 10, y: 20 });
}
println!("{:?}", box_pt); // Point { x: 10, y: 20 }
}
Хотя данные опять же определены в блоке кода, но поскольку эти данные теперь храняться в хипе, то соответственно они продолжат существовать и после завершения блока. Соответственно вне этого блока мы также сможем использовать переменную Box для работы с этими данными.
Теперь рассмотрим некоторые ситуации применения Box.
Нередко компилятору требуется знать точный размер данных. С простыми данными типа i32 или f64 все относительно просто - у них четко задан размер типа. Размер структур является производным от размера ее составляющих компонентов. В отношении перечислений размер устанавливается по наибольшему варианту. Например:
enum Operation {
Unary(i32),
Binary(i32, i32),
}
Здесь перечисление Operation имеет два варианта. Для варианта Unary требуется память, которая достаточна, чтобы вместить значение i32, то есть 4 байта. А вот для Binary требуется уже 8 байт - на два значения i32. Таким образом, здесь размер перечисления вычисляем и может быть определен.
Однако в некоторых ситуациях размер трудно вычислить. Как в случае с рекурсивными структурами данных. Например:
enum List {
Node(i32, List),
Nil,
}
Здесь перечисление List, которое описывает список, имеет два варианта: Node, который хранит некоторые данные и ссылку на следующий узел, и Nil, который представляет пустой узел-конец списка. Однако поскольку вариант Node ссылается на значение List, то мы получаем рекурсивную структуру данных, где один узел может хранить ссылку на другой узел, а тот на третий узел и так далее. То есть потенциально мы можем столкнуть с бесконечным списком. И в этой ситуации компилятор не сможет определить размер перечисления. Например, возьмем следующую программу:
#[derive(Debug)]
enum List {
Node(i32, List),
Nil,
}
fn main() {
let numbers = List::Node(1, List::Node(2, List::Node(3, List::Nil)));
println!("{:?}", numbers);
}
В функции main создается условный список чисел, однако эта программа не скомпилируется. Посмотрим на вывод компилятора:
error[E0072]: recursive type `List` has infinite size
--> main.rs:14:1
|
14 | enum List {
| ^^^^^^^^^
15 | Node(i32, List),
| ---- recursive without indirection
|
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
|
15 | Node(i32, Box<List>),
| ++++ +
error[E0391]: cycle detected when computing when `List` needs drop
--> main.rs:14:1
|
14 | enum List {
| ^^^^^^^^^
......................................
И компилятор нам указывает, что мы имеем дело с рекурсивным типом данных. Однако в выводе компилятора также можно увидеть и решение проблемы - обертывание варианта перечисления в Box -
Node(i32, Box<List>). То есть компилятор предлагает изменить перечисление таким образом, чтобы вариант вместо непосредственного хранения значения хранил
указатель на это значение. Поскольку тип Box<T> является указателем, Rust всегда знает, сколько места нужно для Box<T>:
размер указателя не меняется в зависимости от объема данных, на которые он указывает. Это означает, что мы можем поместить Box<T> внутри варианта
перечисления вместо другого значения List напрямую. Box<T> будет указывать на следующее значение списка, которое будет находиться в куче,
а не внутри варианта Node. Концептуально у нас по-прежнему есть список, созданный из списков, содержащих другие списки, но эта реализация теперь больше похожа на размещение элементов рядом друг с другом, а не внутри друг друга.
#[derive(Debug)]
enum List {
Node(i32, Box<List>),
Nil,
}
fn main() {
let numbers = List::Node(1,
Box::new(List::Node(2,
Box::new(List::Node(3,
Box::new(List::Nil))))));
println!("{:?}", numbers); // Node(1, Node(2, Node(3, Nil)))
}
Теперь компилятор знает точный размер варианта Node - размер i32 плюс размер указателя Box (размер usize).
Хотя в ряде случаев мы можем использовать значение Box в качестве оригинального значения, которое хранится в куче. Но тем не менее это не оригинальное значение. Например, даже если Box содержит число, мы не сможем сравнить его с другим числом, наподобие следующего:
fn main() {
let p_num = Box::new(22);
let result = p_num > 10; // !Ошибка - mismatched types
println!("{}", result);
}
Чтобы получить из Box оригинальное значение, применяется операция разыменования или операция *:
fn main() {
let p_num = Box::new(22);
let result = *p_num > 10; // true
println!("{}", result); // true
}
Rc (Reference Counter или счетчик ссылок) добавляет в язык Rust концепцию разделяемого (общего) владения. В то время как стандартная модель владения Rust обычно предполагает наличие одного владельца для некоторого значения, Rc позволяет нескольким владельцам совместно использовать одни и те же данные. Rc отслеживает количество ссылок на данные и автоматически освобождает память при удалении последней ссылки.
Rc предоставляет возможность иметь несколько читателей одних и тех же данных, что делает его полезным для сценариев, когда необходимо совместно использовать одни и те же данные в разных частях программы. Однако стоит отметить, что Rc не гарантирует потокобезопасность. Наглядный пример расположения в памяти вектора, для которого с помощью Rc установлен ряд владельцев:
Рассмотрим небольшой пример:
use std::rc::Rc;
fn main() {
let data = Rc::new(vec![1, 2, 3, 4, 5]); // определяем разделяемые/общие данные
let clone1 = Rc::clone(&data); // копируем ссылку
let clone2 = Rc::clone(&data); // копируем ссылку
let sum: i32 = data.iter().sum(); // обращаемся к общим данным
let len: usize = data.len(); // обращаемся к общим данным
println!("Sum: {} Len: {}", sum, len); // Sum: 15 Len: 5
println!("Data: {:?}", data); // Data: [1, 2, 3, 4, 5]
println!("Clone1: {:?}", clone1); // Clone1: [1, 2, 3, 4, 5]
println!("Clone2: {:?}", clone2); // Clone2: [1, 2, 3, 4, 5]
}
Здесь счетчик ссылок Rc используется для установки для трех переменных совместного владения вектором [1, 2, 3, 4, 5]. Когда все ссылки на данные (data, clone1 и clone2) выходят за пределы области видимости, память автоматически освобождается.
Стоит отметить, что функция Rc::clone() в реальности не копирует данные, а лишь увеличивает счетчик ссылок. Поскольку глубое копирование данных особенно больших наборов данных может вызвать просадку по производительности. Для подчета ссылок на какой-то объект мы можем использовать функцию Rc::strong_count():
use std::rc::Rc;
fn main() {
let data = Rc::new(vec![1, 2, 3, 4, 5]);
println!("Initial ref count = {}", Rc::strong_count(&data));
let clone1 = Rc::clone(&data);
println!("ref count (after creating clone1) = {}", Rc::strong_count(&data));
{
let clone2 = Rc::clone(&data);
println!("ref count (after creating clone2) = {}", Rc::strong_count(&data));
}
println!("ref count (after deleting clone2) = {}", Rc::strong_count(&data));
}
Консольный вывод:
Initial ref count = 1 ref count (after creating clone1) = 2 ref count (after creating clone2) = 3 ref count (after deleting clone2) = 2
Мы видим, что после сразу после определения общих данных через Rc счетчик ссылок равен 1. Затем каждый раз, когда мы вызываем clone, счетчик увеличивается на 1. Когда переменная с сслкой на общие данные выходит за пределы области видимости, счетчик уменьшается на 1.
Но стоит отметить, что Rc представляет указатель на неизменяемые данные. То есть мы не можем написать что-то вроде следующего:
let data = Rc::new(vec![1, 2, 3, 4, 5]); // определяем разделяемые/общие данные let clone1 = Rc::clone(&data); // копируем ссылку data.push(6); // ! Ошибка clone1.push(6); // ! Ошибка
Указатель типа RefCell позволяет изменять данные, на которые он ссылается. Причем RefCell предоставляет возможность динамически изменять данные, сохраняя неизменность самой переменной. Это достигается благодаря тому, что в отличие от Box правила владения-заимствования проверяются во время выполнения программы, а не во время компиляции. Хотя также RefCell позволяет работать с данными и как с неизменяемыми значениями.
RefCell нередко используется при работе с замыканиями, позволяя изменять данные внутри замыканий, которые захватывают неизменяемые ссылки. RefCell удобно применять в однопоточных контекстах - если правила заимствования слишком строги в однопоточных контекстах, RefCell облегчает обмен данными.
Для получения неизменяемой ссылки на данные RefCell используется метод borrow():
use std::cell::RefCell;
fn main() {
let data = RefCell::new(vec![1, 2, 3]); // определяем данные
let data_ref = data.borrow(); // получаем неизменяемую ссылку
// обращение к данным
println!("Data: {:?}", data_ref); // Data: [1, 2, 3]
// data_ref.push(4); // если мы попробуем изменить данные, то мы столкнемся с ошибкой
}
Для получения неизменяемой ссылки на данные RefCell используется метод borrow_mut():
use std::cell::RefCell;
fn main() {
let data = RefCell::new(vec![1, 2, 3]); // определяем данные
let mut data_ref_mut = data.borrow_mut(); // получаем изменяемую ссылку на данные
println!("Original Data: {:?}", data_ref_mut); // Original Data: [1, 2, 3]
data_ref_mut.push(4); // изменяем данные
println!("Modified Data: {:?}", data_ref_mut); // Modified Data: [1, 2, 3, 4]
}
Если ссылка не нужна, ее можно удалить с помощью функции drop():
use std::cell::RefCell;
fn main() {
let data = RefCell::new(vec![1, 2, 3]); // определяем данные
let data_ref = data.borrow(); // получаем неизменяемую ссылку
println!("Original Data: {:?}", data_ref); // Original Data: [1, 2, 3]
drop(data_ref); // удаляем ссылку
let mut data_ref_mut = data.borrow_mut(); // получаем изменяемую ссылку
data_ref_mut.push(4); // изменяем данные
println!("Modified Data: {:?}", data_ref_mut); // Modified Data: [1, 2, 3, 4]
}
Метод borrow() возвращает указатель типа Ref<T>, а метод borrow_mut() - указатель типа RefMut<T>. Оба типа реализуют трейт
Deref, поэтому мы можем работать со значениями этих типов как с обычными ссылками. И RefCell<T> отслеживает, сколько
указателей Ref<T> и RefMut<T>в данный момент активны. Каждый раз, когда мы вызываем метод borrow(), RefCell
увеличивает количество активных неизменяемых заимствований. Когда значение Ref<T> выходит за пределы области действия,
количество неизменяемых заимствований уменьшается на 1. Как и правила заимствования во время компиляции, RefCell позволяет нам иметь множество неизменяемых
заимствований или одно изменяемое заимствование в любой момент времени.