Понятие об умных указателях Rust

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

Если по-простому, умный указатель  —  это абстрактный тип данных, который имитирует указатель, добавляя новый функционал, например автоматическое управление памятью и проверку границ. Умные указатели задействуются для уменьшения количества багов, возникающих в результате неправильного использования указателей. Эффективность при этом сохраняется.

Прежде чем говорить об умных указателях в Rust, заглянем в прошлое и узнаем, откуда они появились.

Немного истории

Умные указатели (особенно тип умных указателей с подсчетом ссылок) впервые получили широкое распространение в языке программирования C++ в первой половине 1990-х годов. Их появление стало ответом на критику отсутствия в C++ автоматической сборки мусора.

Тем не менее у непосредственного предшественника одного из языков, повлиявших на дизайн C++, уже имелись встроенные в язык ссылки с подсчетом ссылок. В C++ отчасти последовали примеру языка Симула-67, чьим прародителем был Симула I: элемент в Симула I аналогичен указателю C++ без null, а процесс в Симула I с фиктивным оператором в качестве тела действия аналогичен структуре в C++. В Симула I уже в сентябре 1965 года были элементы с подсчетом ссылок (т. е. выражения с указателями, использовавшими косвенную адресацию) на процессы (т. е. записи).

Ссылки осуществляются на каждый отдельный процесс. Физически ссылка процесса представляет собой указатель на область памяти, содержащую локальные для процесса данные и некоторую дополнительную информацию, определяющую текущее состояние его выполнения.

В C++ был позаимствован подход Симула к распределению памяти, а именно ключевое слово new при выделении процесса/записи для получения нового элемента в этот процесс/запись. Поэтому неудивительно, что в C++ в конечном итоге был воскрешен и применявшийся в языке Симула механизм умного указателя с подсчетом ссылок внутри элемента.

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

Если упрощенно, умный указатель в C++  —  это класс с перегруженными операторами, который ведет себя как обычный указатель. Кроме того, он обеспечивает правильное и своевременное удаление динамически выделяемой памяти, способствуя четко определенному жизненному циклу объекта.

Проблема использования обычных (необработанных) указателей в C++

В отличие от многих других языков программирования, C++ дает программисту полную гибкость в том, что касается выделения, освобождения и управления памятью. Но эта гибкость, к сожалению, становится в его руках обоюдоострым мечом. С одной стороны, она делает C++ мощным языком, а с другой  —  позволяет программисту создавать проблемы, связанные с управлением памятью (например, утечки памяти), особенно когда динамически выделенные объекты не освобождаются в нужное время.

Вот пример:

SomeClass* pointerData = anObject.GetData();

pointerData->DoSomething();

Здесь нельзя определенно сказать об указателе памяти на pointerData, что:

  • он выделен в heap (куче) и должен быть deallocated (освобожден);
  • должно ли выполняться это deallocate (освобождение) тем, что тут вызывается;
  • будет ли pointerData автоматически уничтожен деструктором объекта destructor.

Можно сказать лишь что-то вроде: «Программист должен быть осторожен и следовать лучшим практикам». И этого было бы достаточно. Но в идеальном мире, а не в реальном. Вот почему нам нужны некие механизмы для защиты от себя же самих.

Как умные указатели помогают в C++?

Даже имея в своем распоряжении все обычные указатели и методы управления памятью, программист на C++ не обязан использовать их для управления данными в куче / свободной памяти. Лучше выбрать более умный способ выделения динамических данных и управления ими, задействуя в программах умные указатели.

smart_pointer<SomeClass> smartPointerData = anObject.GetData();
smartPointerData->Display();
(*smartPointerData).Display();

// Не нужно беспокоиться об освобождении.
// Об этом позаботится деструктор умного указателя.

Умные указатели могут вести себя, как обычные указатели, но на самом деле они добавляют полезный функционал посредством своих overloaded operators (перегруженных операторов) и destructors (деструкторов), обеспечивая своевременное уничтожение динамически выделяемых ресурсов.

Типы умных указателей в C++

Управление ресурсами памяти (то есть реализуемая модель владения)  —  это то, что отличает классы умных указателей. Умные указатели сами решают, что делать с ресурсами, когда они копируются и назначаются. Самая простая реализация часто приводит к не самой лучшей производительности, а самые быстрые могут подходить не для всех задач программиста. Ведь, в конце концов, от него и от понимания им своих потребностей зависит, использовать ему умные указатели в своей программе или нет.

Классификация умных указателей в C++  —  это фактически классификация стратегий управления ресурсами памяти. Вот эти стратегии:

  • глубокое копирование;
  • копирование при записи (COW);
  • подсчет ссылок;
  • связывание ссылок;
  • разрушающее копирование.

Не будем расписывать, что представляет собой каждая из них, потому что, хотя их концепции похожи, все они связаны с C++. Наша цель  —  рассказать немного о том, что такое умные указатели и откуда они взялись.

Так что перейдем теперь к умным указателям Rust!

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

До сих пор мы говорили об умных указателях. А как насчет простых указателей?

Что такое указатели?

Указатель  —  это переменная, которая содержит адрес в памяти. Она указывает или ссылается на другие данные, поэтому переменная-указатель сама никаких фактических данных не содержит. Это как такая стрелка на это значение.

Переменная-указатель pointer_to_value, ссылающаяся на точку в памяти со значением

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

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

Добравшись до коттеджного поселка, нужно будет узнать конкретный адрес дома. Итак, вы стоите у ворот и спрашиваете охранника, в каком доме живет друг (а охранник сохранил адрес его дома). Точно так же приложение запрашивает адрес для конкретной части данных у переменной-указателя.

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

А как насчет указателей в Rust?

В Rust есть два обычных типа указателей, которые называются ссылками. Они обозначаются символом амперсанда, ставящимся перед именем переменной.

& для неизменяемой ссылки (это поведение по умолчанию):

fn my_function(my_variable: &String) {
    // делаем что-нибудь
}

&mut для изменяемой ссылки:

fn my_function(my_mutable_variable: &mut String) {
    // делаем что-нибудь
}

Ссылки на значение на самом деле не владеют этим значением (обычно), а только заимствуют его. То есть при исчезновении ссылки значение, на которое она указывала, все равно будет существовать. Это связано с понятием владения в Rust, о котором опять же мы не будем говорить подробно, потому что рискуем таким образом сильно отклониться от темы, а пора бы уже добраться до ее сути.

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

Ссылки, о которых мы говорили выше,  —  это обычные указатели, которые лишь указывают на какую-то часть данных, но не более того. Фактически умные указатели в Rust  —  это структуры данных, которые не только действуют как указатели, но и имеют метаданные и дополнительный функционал. У обычных указателей такого функционала нет.

Реализация умных указателей обычно происходит с помощью структур.

Важное различие между обычными ссылками и умными указателями в Rust: ссылки только заимствуют данные, в то время как умные указатели в большинстве случаев владеют данными, на которые они указывают. То есть, когда умный указатель удаляется, то и данные, на которые он указывает, удаляются тоже.

Еще одно важное различие заключается в том, что умные указатели реализуют типажи Deref и Drop. Типаж Deref позволяет экземпляру структуры умного указателя вести себя как ссылка, т. е. здесь возможно написание кода, который работает как со ссылками, так и с умными указателями. Типаж Drop позволяет настраивать код, который запускается при выходе экземпляра умного указателя из области видимости, что бывает очень полезно.

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

  • Box<T> для постоянного размещения значений в куче.
  • Rc<T>  —  тип с подсчетом ссылок, который разрешает множественное владение.
  • Ref<T> и RefMut<T>, доступ к которым осуществляется через RefCell<T>,  —  тип, который применяет правила заимствования во время выполнения, а не во время компиляции (и по возможности избегает последнего).

Box<T>

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

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

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

use List::{Cons, Nil};

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

В приведенном выше примере варианту Cons нужно пространство размером i32 и пространство для хранения данных указателя box. С помощью box мы разорвали бесконечную рекурсивную цепочку, так что компилятор теперь в состоянии определить размер списка.

Rc<T>

Rc расшифровывается как Reference Counted (т. е. «подсчет ссылок»). Этот тип умного указателя используется для задействования множественного владения данными, что по умолчанию не допускается моделью владения Rust. Тип умного указателя Rc<T> отслеживает количество ссылок на значение, благодаря чему есть возможность узнать, сколько пространств использует переменная. При уменьшении количества ссылок до нуля значение нигде не используется, поэтому его можно безопасно удалить из памяти без каких-либо проблем.

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

use List::{Cons, Nil};
use std::rc::Rc;

fn main() {
    let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
    println!("count after creating a = {}", Rc::strong_count(&a));
    let b = Cons(3, Rc::clone(&a));
    println!("count after creating b = {}", Rc::strong_count(&a));
    {
        let c = Cons(4, Rc::clone(&a));
        println!("count after creating c = {}", Rc::strong_count(&a));
    }
    println!("count after c goes out of scope = {}", Rc::strong_count(&a));
}

Результаты выполнения:

count after creating a = 1
count after creating b = 2
count after creating c = 3
count after c goes out of scope = 2

Здесь счетчик Rc<T> на 1 при создании переменной a. Затем при создании другой переменной b (посредством клонирования переменной a) счетчик увеличивается на 1 и становится равным 2. Аналогично при создании другой переменной c (снова посредством клонирования переменной a) счетчик увеличивается еще на 1 и становится равным 3. После того, как c выходит из области видимости, счетчик уменьшается на 1 и становится равным 2.

RefCell<T>

RefCell<T>  —  это то, что делает возможной работу с шаблоном внутренней изменяемости, т. е. шаблоном проектирования в Rust, который позволяет изменять данные, даже когда на эти данные имеется одна или несколько неизменяемых ссылок. А это полностью противоречит правилам заимствования, объявленным в модели владения. Для этого шаблон использует небезопасный код внутри структуры данных, позволяющий обойти правила Rust в отношении изменения и заимствования.

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

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

use List::{Cons, Nil};
use std::rc::Rc;
use std::cell::RefCell;

fn main() {
    let value = Rc::new(RefCell::new(5));

    let a = Rc::new(Cons(Rc::clone(&value), Rc::new(Nil)));

    *value.borrow_mut() += 10;

    println!("a after = {:?}", a);
}

Результаты выполнения:

a after = Cons(RefCell { value: 15 }, Nil)

В этом примере создан экземпляр Rc<Refcell<i32>>, который сохранен в переменной value, а также список a с вариантом cons, который содержит value. value была клонирована так, чтобы и a, и value владели внутренней cell, которая имеет значение 5. Так не пришлось передавать владение из value. Затем при вызове borrow_mut() в value было добавлено 10 и этот метод вернул умный указатель RefMut<T>. После чего в нем использован ссылочный оператор для изменения его внутреннего value.

Заключение

«То, чего я не могу создать, я не понимаю»,  —  Ричард Фейнман.

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

«Плохие программисты думают о коде. Хорошие программисты думают о структурах данных и их взаимосвязях»,  —  Линус Торвальдс. 

Умные указатели  —  это, по сути, особый тип структуры данных, который по-особому связан с данными. Такое понимание может сделать вас как программиста только лучше.

Читайте также:

Читайте нас в TelegramVK и Яндекс.Дзен


Перевод статьи João Henrique Machado Silva: Understanding Rust Smart Pointers

Предыдущая статьяeCommerce UI/UX дизайн: карточка товара в примерах
Следующая статьяОбработка сигналов в операционных системах семейства Unix на Golang