Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Умные указатели

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

Note

Умный указатель (smart pointer) — термин, пришедший из C++, где, в отличие от C, в котором указатель является просто ячейкой, хранящей в себе адрес, умный указатель представляет из себя класс, который не просто предоставляет доступ к данным по адресу, но и умеет автоматически очищать память, на которую он ссылается.

Box

Первым указателем, который мы рассмотрим, является Box. Мы уже вскользь упоминали его в разделе Возврат трэйта из функции.

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

Tip

Проводя аналогию с C++, Box является прямым аналогом умного указателя unique_ptr.

С точки зрения лэйаута в памяти, Box является так называемой “zero cost abstraction”. То есть представляет из себя просто ячейку с адресом, которая располагается на стеке, и не более.

┌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐    ┌╌╌╌╌╌╌╌╌┐
┆Stack            ┆    ┆Heap    ┆
┆ ┌────────────┐  ┆    ┆ ┌────┐ ┆
┆ │Box: pointer├────────>│Data│ ┆
┆ └────────────┘  ┆    ┆ └────┘ ┆
└╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘    └╌╌╌╌╌╌╌╌┘

Наиболее простой способ создать Box — использовать метод-конструктор Box::new(T), который принимает в качестве аргумента значение и переносит это значение в кучу.

Рассмотрим пример:

// Структура для хранения координат точки в двухмерном пространстве
struct Point2D { x: i32, y: i32 }

fn main() {
    let p: Point2D = Point2D {x: 5, y: 2}; // Создаём значение на стеке
    let b: Box<Point2D> = Box::new(p);     // Перемещаем значение в кучу
}

Чем же нам может быть полезно такое хранение значений в куче? Дело в том, что для того, чтобы значение можно было хранить на стеке, его размер должен быть известен во время компиляции. Например, как со структурой Point2D: размер значения всегда будет одинаковым (два поля размером i32). Однако полный размер таких структур, как вектор, не известен на этапе компиляции, потому что количество элементов вектора не известно.

Для наглядности давайте напишем классическую структуру данных — односвязный список.

┌──────────┐    ┌──────────┐    ┌──────────┐
│значение 1│ ╭─>│значение 2│ ╭─>│значение 3│
├──────────┤ │  ├──────────┤ │  ├──────────┤
│next: ptr ├─╯  │next: ptr ├─╯  │next: nil │
└──────────┘    └──────────┘    └──────────┘

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

// Список — это:
enum List<T> {
    Nil,              // либо пустой список
    Elem(T, List<T>), // либо пара: значение + список
}

Однако компилятор выдаст такую ошибку:

enum List<T> {
    Nil,
    Elem(T, List<T>),
}//         ------- recursive without indirection
// error[E0072]: recursive type `List` has infinite size
// insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
//   Elem(T, Box<List<T>>),
//           ++++       +

Это как раз то, о чём мы говорили раньше: рекурсивная структура имеет неопределённый размер, что делает невозможным её размещение на стеке. Компилятор любезно предлагает нам использовать Box, что как раз решит нашу проблему:

#[derive(Debug)]
enum List<T> {
    Nil,
    Elem(T, Box<List<T>>),
}

use List::*;

fn main() {
    let list: List<i32> =
        Elem(1, Box::new(
            Elem(2, Box::new(
                Elem(3, Box::new(Nil))
        ))));
    println!("{:?}", list); // Elem(1, Elem(2, Elem(3, Nil)))
}

Трэйты Deref и DerefMut

Для объекта типа Box можно использовать оператор разыменовывания *, словно это обычная ссылка. Также при помощи оператора & из объекта типа Box можно получить прямую ссылку на данные в куче.

fn main() {
    let mut b = Box::new(1);
    *b = 2;
    println!("{b}"); // 2

    increment(&mut b);
    println!("{b}"); // 3
}

fn increment(i: &mut i32) {
    *i += 1;
}

Такое поведение объекта Box (словно он ссылка) возможно благодаря тому, что тип Box реализует трэйт Deref.

pub trait Deref {
    type Target: ?Sized;
    fn deref(&self) -> &Self::Target;
}

Этот трэйт позволяет типу предоставлять некую ссылку. Разумеется, в случае с Box это будет ссылка его на данные в куче.

Если тип реализует трэйт Deref, то для его объектов компилятор подменяет &объект на объект.deref().

Как мы могли заметить, метод deref возвращает немутабельную ссылку. Для случаев, когда мы хотим иметь возможность изменять значение по ссылке, существует трэйт DerefMut.

pub trait DerefMut: Deref {
    fn deref_mut(&mut self) -> &mut Self::Target;
}

Вызов *объект=значение подменяется на *(объект.deref_mut())=значение.

Rc — совместное владение

Концепция владения Rust не позволяет совместное владение одним и тем же объектом, однако есть целый ряд структур данных, где это необходимо (например, двусвязный список). Для таких ситуаций стандартная библиотека Rust предоставляет умный указатель Rc (Reference Counted).

В отличие от Box<T>, который по сути представляет из себя просто указатель на данные в куче, Rc<T> — структура из двух полей:

  • указатель на данные в куче
  • указатель на счётчик копий объекта Rc

Рассмотрим простой пример использования Rc:

use std::rc::Rc;

fn main() {
    let rc1 = Rc::new("Hello".to_string());
    let rc2 = rc1.clone();
}

Когда мы создаём новый объект при помощи Rc::new(значение):

  1. На стеке создаётся объект структуры Rc
  2. В куче выделяется место для данных и в него переносится значение, переданное в Rc::new(). Далее адрес этого значения в куче присваивается полю объекта Rc — “указателю на данные”.
  3. На куче выделяется место под счётчик копий Rc и инициализируется единицей. Адрес этого счётчика присваивается полю объекта Rc — “указателю на счётчик”.

Когда мы клонируем объект Rc:

  1. На стеке создаётся новый объект Rc.
  2. Значение указателя на данные копируется из клонируемого объекта Rc.
  3. Значение указателя на счётчик числа копий Rc копируется из клонируемого объекта Rc, при этом сам счётчик инкрементируется.

Когда переменная, хранящая объект Rc, выходит из скоупа:

  1. Счётчик копий Rc уменьшается на 1.
  2. Если при этом значение счётчика стало равным 0, то память, в которой хранятся данные, и память, в которой хранится сам счётчик, очищаются.

Расположение Rc в памяти выглядит примерно так:

Tip

Проводя аналогию с C++, Rc является прямым аналогом умного указателя shared_ptr.

Cell

Основное неудобство Rc заключается в том, что в отличие от Box, он не реализует трэйт DerefMut, а значит, не позволяет менять его содержимое.

use std::rc::Rc;

fn main() {
    let mut rc1 = Rc::new(1);
    *rc1 += 1;
 // ^^^^^^^^^ cannot assign
 // trait `DerefMut` is required to modify through a dereference,
 // but it is not implemented for `Rc<i32>`
}

Почему так сделано? Давайте вспомним правило безопасности ссылок в Rust: на объект одновременно можно иметь либо одну мутабельную ссылку, либо сколько угодно немутабельных. И само это правило обусловлено тем, что в общем случае, без дополнительных механизмов синхронизации, нельзя гарантировать корректность немутабельной ссылки после того, как данные были изменены по мутабельной ссылке.

Именно поэтому Rc и позволяет множественное владение одним и тем же объектом (аналог множества немутабельных ссылок), но без возможности менять значение.

Как мы заметили выше, одновременное наличие немутабельной и мутабельной ссылок небезопасно БЕЗ дополнительных механизмов синхронизации. Стандартная библиотека Rust предлагает механизм синхронизации специально для таких ситуаций — структура-обёртка Cell.

Cell<T> — обёртка, которая позволяет заменять своё содержимое целиком, безопасно и атомарно, и при этом НЕ позволяет:

  • получать мутабельную ссылку на своё содержимое, что предотвращает возможность “порчи данных”
  • получать немутабельную ссылку, которая могла бы стать недействительной при замене значения в Cell.

Для работы с Cell, в основном, используются три метода:

  • new(значение) — создаёт новый объект Cell и инициализирует его заданным значением
  • replace(значение) — атомарно помещает в Cell новое значение, а старое возвращается в качестве результата вызова
  • set(значение) — атомарно помещает в Cell новое значение, а старое просто уничтожается

Рассмотрим пример работы с Cell:

use std::cell::Cell;

fn main() {
    let cell = Cell::new("aaa".to_string());

    // При замещении новым значение, прошлое возвращается как результат
    let old_string = cell.replace("bbb".to_string());
    println!("{old_string}");

    // Если нам не нужно прошлое значение, то можно просто перезаписать его новым.
    cell.set("ccc".to_string());
}

Note

Обратите внимание: переменная cell объявлена без модификатора mut, однако мы смогли заместить хранящуюся в ней строку другой строкой. Так происходит, потому что замена значения происходит атомарно и безопасно, поэтому переменной не обязательно иметь семантику мутабельности со всеми сопутствующими ограничениями.

Если тип реализует интерфейс Copy, то из Cell можно извлекать его копию методом get.

use std::cell::Cell;

fn main() {
    let cell = Cell::new(1);
    println!("{}", cell.get()); // c1 = 1
}

Итак, теперь, используя комбинацию Rc<Cell<T>>, мы можем создавать структуры данных, которые требуют как совместное владение, так и возможность заменять хранимое значение.

use std::{cell::Cell, rc::Rc};

fn main() {
    let rc1 = Rc::new(Cell::new(1));
    let rc2 = rc1.clone();

    //  Получаем ссылку на разделяемый Cell и записываем в него новое значение
    rc2.as_ref().set(5);

    println!("{:?}", rc1); // Cell { value: 5 }
}

Important

Важно заметить, что Cell защищает только от сценариев порчи немутабельной ссылки путём изменения данных через мутабельную ссылку в рамках одного потока. Какой-либо защиты от гонок данных Cell не предоставляет.

RefCell

Очевидным недостатком Cell является то, что он позволяет только заменять хранимое значение, но не модифицировать. Обёртка RefCell<T> позволяет как раз изменять хранимое значение по ссылке.

use std::cell::{RefCell, RefMut};

fn main() {
    let ref_cell = RefCell::new(1);
    {
        // Получаем "мутабельную ссылку": RefMut реализует DerefMut
        let mut mut_ref: RefMut<'_, i32> = ref_cell.borrow_mut();

        // изменяем значение внутри RefCell
        *mut_ref = 5;
    }
    println!("{:?}", ref_cell);
}

RefCell не нарушает правило безопасности ссылок, а просто переносит проверку из времени компиляции в рантайм. То есть попытка получить одновременно мутабельную и немутабельную ссылки на содержимое RefCell вызовет панику.

use std::cell::{RefCell, RefMut, Ref};

fn main() {
    let ref_cell = RefCell::new(1);

    let immut_ref: Ref<'_, i32> = ref_cell.borrow(); // borrowing immutable

    let mut mut_ref: RefMut<'_, i32> = ref_cell.borrow_mut();
    *mut_ref = 5;                   // ^^^ already borrowed: BorrowMutError
    
    println!("{:?}", ref_cell);
}

Теперь мы можем переписать наш односвязный список с использованием RefCell:

use std::rc::Rc;
use std::cell::RefCell;

#[derive(Debug)]
enum List<T> {
    Elem(Rc<RefCell<T>>, Rc<List<T>>),
    Nil,
}

use List::*;

fn main() {
    let v = Rc::new(RefCell::new(1));

    let a = Rc::new(Elem(Rc::clone(&v), Rc::new(Nil)));

    let b = Elem(Rc::new(RefCell::new(2)), Rc::clone(&a));
    let c = Elem(Rc::new(RefCell::new(3)), Rc::clone(&a));

    *v.borrow_mut() += 10;
    println!("a after = {:?}", a);
    // Elem(RefCell { value: 11 }, Nil)
    
    println!("b after = {:?}", b);
    // Elem(RefCell { value: 2 }, Elem(RefCell { value: 11 }, Nil))
    
    println!("c after = {:?}", c);
    // Elem(RefCell { value: 3 }, Elem(RefCell { value: 11 }, Nil))
}

Arc

Rc позволяет совместное владение объектом, однако Rc не является потокобезопасным типом, то есть не позволяет совместное владение объектом из разных потоков. Для многопоточной среды имеется потокобезопасная версия — Arc (Atomically Reference Counted).

Мы будем разбирать многопоточное программирование на Rust в главе Многопоточность, поэтому пока что просто запомните, что в многопоточном программировании вместо Rc используется Arc.