Смарт-указатели

Последнее обновление: 28.07.2024

Кроме обычных указателей в Rust также есть смарт-указатели, для работы с которыми не нужен unsafe-контекст. Рассмотрим некоторые их типы.

Box

Тип 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

Rc (Reference Counter или счетчик ссылок) добавляет в язык Rust концепцию разделяемого (общего) владения. В то время как стандартная модель владения Rust обычно предполагает наличие одного владельца для некоторого значения, Rc позволяет нескольким владельцам совместно использовать одни и те же данные. Rc отслеживает количество ссылок на данные и автоматически освобождает память при удалении последней ссылки.

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

Reference Counter в Rust

Рассмотрим небольшой пример:

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 позволяет изменять данные, на которые он ссылается. Причем 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 позволяет нам иметь множество неизменяемых заимствований или одно изменяемое заимствование в любой момент времени.

Помощь сайту
Юмани:
410011174743222
Номер карты:
4048415020898850