Идиома RAII (Resource Acquisition Is Initialization) переводится как «захват ресурса должен быть инициализацией объекта». Пусть программе требуется какой-то ресурс (память, файл), который надо обязательно «вернуть», когда он будет уже не нужен. Идея состоит в том, что лучше всего запрашивать этот ресурс в конструкторе некоторого объекта, а освобождать — в деструкторе. На этой идее построены стандартные контейнеры и так называемые «умные указатели» — классы unique_ptr
и shared_ptr
из стандартной библиотеки.
Название идиомы, как замечает сам Бьярне Страуструп, выбрано неудачно. Лучше отражают её смысл альтернативные названия:
- CADR (Constructor Acquires, Destructor Releases) — в конструкторе захватываем ресурс, в деструкторе — освобождаем;
- SBRM (Scope-Bound Resource Management) — управление ресурсами с привязкой к области видимости.
Файл как ресурс
Рассмотрим хрестоматийный пример ресурса: файл. Работу программы с файлами обеспечивает операционная система. Файл вначале нужно «открыть» для чтения или записи, а в конце работы — «закрыть».
Давайте посмотрим, как работали с файлами в языке С, из которого вырос C++. С каждым файлом связывался так называемый файловый дескриптор. Это был просто указатель на объект специальной структуры std::FILE
.
Этот способ возможен и в C++. Мы обсудим его недостатки и напишем позже более удобную замену.
#include <cstdio>
int main() {
// Открываем файл input.txt для чтения и получаем его дескриптор
if (std::FILE* f = std::fopen("input.txt", "r"); f != nullptr) {
// Если дескриптор не является нулевым указателем, то файл успешно окрыт
char buf[100]; // массив символов размера 100
std::fscanf(f, "%99s", buf); // считываем из файла максимум 99 символов текста в буфер
// ...
std::fclose(f); // закрываем файл
} else {
std::cout << "File open failure!\n";
}
}
Мы не будем останавливаться на деталях работы функции fscanf
. Нас сейчас интересует открытие и закрытие файла, а также поведение программы в случае ошибок.
В приведённом примере обрабатывается случай, когда файл не получается открыть. Такое может произойти если файла с указанным именем, например, не существует. В строках кода, обозначенных многоточием, тоже возможны аварийные ситуации. Например, в файле могут оказаться некорректные данные. В каждой из таких ситуаций важно не забыть закрыть файл. Код с такими проверками становится слишком громоздким.
Согласно идиоме RAII, нам следует «обернуть» файловый дескриптор в объект специального класса. Тогда открытие файла соответствовало бы инициализации объекта в его конструкторе, а закрытие файла — уничтожению объекта в деструкторе. В случае ошибки при открытии файла мы бы сгенерировали исключение, и объект нашего класса не был бы создан.
#include <cstdio>
#include <exception>
#include <string>
class CannotOpenFileException {
};
class File {
private:
std::FILE* f; // тот самый файловый дескриптор, который мы оборачиваем
public:
File(const std::string& name) {
if (f = std::fopen(name.c_str(), "r"); f == nullptr) {
throw CannotOpenFileException();
}
}
~File() noexcept {
std::fclose(f);
}
std::string Read() const {
char buf[100];
std::fscanf(f, "%99s", buf);
return buf;
}
};
Теперь можно работать так:
int main() {
try {
File file("input.txt");
auto str = file.Read();
// ...
} catch (const CannotOpenFileException&) {
std::cout << "File open failure!\n";
}
}
Что бы ни произошло в строках с многоточием, для объекта file
всегда будет вызван деструктор, а значит, файл будет корректно закрыт.
Копирование и присваивание
Наша обёртка File
пока не идеальна: у неё есть проблемы с копированием и присваиванием. Рассмотрим такой пример:
int main() {
File f1("a.txt");
File f2 = f1; // конструктор копирования
File f3("b.txt");
f3 = f1; // оператор присваивания
}
Все эти три объекта хранят внутри на самом деле один и тот же файловый дескриптор. Когда для них вызовутся деструкторы, то один и тот же файл будет закрыт несколько раз, что ошибочно.
Один из способов решить эту проблему — просто запретить такое копирование и присваивание. Это можно сделать так:
class File {
public:
// Запрещаем компилятору автоматически генерировать
// конструктор копирования и оператор присваивания:
File(const File&) = delete;
File& operator = (const File&) = delete;
// ...
};
Теперь код функции main
как в примере выше просто не скомпилируется. Но всё-таки хочется, чтобы вот такой код был допустимым:
// Вспомогательная функция
File GetFile() {
File f("a.txt");
// ...
return f;
}
int main() {
// Сейчас не сработает — конструктор копирования запрещён!
File f = GetFile(); // ошибка компиляции!
// ...
}
В отличие от предыдущего примера с копированием именованных объектов, здесь копируется временный объект — результат функции GetFile
. Как мы знаем, для таких объектов предусмотрены особые rvalue-ссылки. Можно просто написать конструктор перемещения, который заберет данные из такого объекта:
class File {
public:
File(const File&) = delete;
File& operator = (const File&) = delete;
// Конструктор перемещения
File(File&& other) noexcept { // File&& — ссылка на временный объект
f = other.f;
other.f = nullptr; // забираем владение дескриптором у временного объекта other!
}
// Оператор присваивания с семантикой перемещения
File& operator = (File&& other) noexcept {
if (f != nullptr && f != other.f) {
fclose(f); // закрываем файл у текущего объекта
}
f = other.f; // забираем владение у временного объекта other
other.f = nullptr;
return *this;
}
// Добавим проверку в деструктор:
~File() noexcept {
if (f != nullptr) {
fclose(f);
}
}
// ...
};
Теперь копирование и присваивание обычных именованных объектов запрещены, но для временных объектов они возможны. Так как временный объект всё равно скоро будет уничтожен деструктором, можно отобрать у него владение ресурсом и оставить его «сиротой», то есть, сделать указатель f
нулевым. У такого объекта уже нельзя вызывать функцию Read
, но никто это и не будет делать
В деструкторе мы добавляем проверку и закрываем файл только если дескриптор f
ненулевой. Это обеспечивает корректную работу деструктора для «осиротевших» временных объектов.
Заметим, что оператор перемещающего присваивания можно было бы реализовать чуть иначе с помощью функции std::swap
. Это позволяет избавиться от неуклюжей проверки и от вызова fclose
внутри тела оператора:
#include <utility>
class File {
public:
File& operator = (File&& other) noexcept {
std::swap(f, other.f); // обмениваемся дескрипторами с other
return *this;
}
// ...
};
Здесь мы просто обмениваемся дескрипторами с временным объектом other
. Файловый дескриптор текущего объекта будет закрыт в деструкторе объекта other
, который должен быть вызван вскоре после присваивания.
В отличие от оператора перемещающего присваивания, в конструкторе перемещения закрывать файловый дескриптор у текущего объекта не нужно, так как текущего объекта пока просто не существует.
RAII в стандартной библиотеке
В стандартной библиотеке уже есть готовая обёртка над файловыми дескрипторами std::fstream
, которая позволяет работать с файлами как с потоками ввода-вывода. Она написана в стиле RAII
и напоминает наш класс File
. Некоторое отличие заключается в том, что конструктор std::fstream
не генерирует исключение в случае ошибки.
Вот как ей можно воспользоваться:
#include <fstream>
#include <iostream>
#include <string>
int main() {
std::fstream file("input.txt");
std::string data;
file >> data;
std::cout << data << "\n";
} // файл автоматически закроется при вызове деструктора
Другим примером ресурса является динамическая память. Все контейнеры стандартной библиотеки (кроме, пожалуй, std::array
) так или иначе исповедуют идиому RAII: в их конструкторах выделяется память, которой они владеют, а в деструкторах эта память освобождается. Таким образом, эти контейнеры надёжно защищают программиста от ошибок из-за утечек памяти.
Ещё примеры
Другим примером использования RAII в стандартной библиотеке является класс std::lock_guard
. Он предназначен для синхронизации мьютексов в многопоточном приложении. Многопоточность выходит за рамки этого пособия. Но тем не менее приведём такой пример для иллюстрации.
#include <mutex>
std::mutex m;
void f(); // какая-то функция
void Bad() {
m.lock(); // захватываем мьютекс
f(); // если произойдёт исключение, то мьютекс никогда не освободится
m.unlock(); // освобождаем мьютекс
}
void Good() {
std::lock_guard<std::mutex> lock(m); // класс-обёртка в стиле RAII
f(); // если произойдёт исключение, то мьютекс будет освобождён!
} // вызов m.unlock() не требуется — это сделает деструктор объекта lock
Владение несколькими ресурсами
Решим задачу, которую часто предлагают на собеседованиях. Пусть дан некоторый класс A
. Нужно написать конструкторы и деструктор класса C
, который владеет переменной типа A
и хранит её в динамической памяти.
Напишем сначала наивный вариант решения.
template <typename A>
class C {
private:
A* x;
public:
C() {
x = new A();
}
// ...
~C() {
delete x;
}
};
Тут есть такая же проблема, как и с классом File
: конструктор копирования и оператор присваивания по умолчанию просто скопируют указатель на ту же память:
int main() {
C<int> c1;
C<int> c2(c1); // указатели c1.x и c2.x равны!
} // проблема: в деструкторе c1 память будет освобождена повторно!
Допишем конструктор копирования и оператор присваивания. В отличие от класса File
мы не будем их запрещать, а сделаем «глубокое» копирование:
template <typename A>
class C {
private:
A* x;
public:
C() {
x = new A();
}
// Создаём новый объект с помощью конструктора копирования класса A
C(const C& other) {
x = new A(*other.x);
}
// Вызываем оператор присваивания не для указателей, а для объектов класса A
C& operator = (const C& other) {
*x = *other.x;
return *this;
}
~C() {
delete x;
}
};
Наш класс владеет динамической памятью в стиле RAII: в конструкторах память выделяется, в деструкторе — освобождается. Теперь усложним задачу. Пусть даны два класса A
и B
. В нашем классе D
должны быть два указателя: и на объект класса A
, и на объект класса B
. Наш класс D
должен владеть и той, и другой динамической памятью.
template <typename A, typename B>
class D {
private:
A* x;
B* y;
public:
D() {
x = new A();
y = new B();
}
D(const D& other) {
x = new A(*other.x);
y = new B(*other.y);
}
D& operator = (const D& other) {
*x = *other.x;
*y = *other.y;
return *this;
}
~D() {
delete x;
delete y;
}
};
Казалось бы, здесь нет никакого подвоха. Однако такой класс по сравнению с предыдущей версией является опасным. Дело в том, что в конструкторе при инициализации поля y
может произойти ошибка. Во-первых, может просто не хватить динамической памяти. Во-вторых, может произойти исключение в конструкторе класса B
. Сама ошибка не является чем-то опасным. Однако давайте посмотрим, что произойдёт при этом с полем x
. При исключении будет сворачиваться стек. В частности, будет вызван деструктор для уже проинициализированной переменной x
. Но деструктор указателя тривиален. Этот деструктор не сделает ничего. Поэтому объект *x
не будет корректно уничтожен и память в x
«утечёт». Напомним, что деструктор ~D
не будет вызван, так как конструктор завершится с ошибкой.
Первое приходящее в голову решение проблемы: обернуть инициализацию в конструкторе в try
/catch
:
template <typename A, typename B>
class D {
private:
A* x;
B* y;
public:
D() {
x = new A(); // тут тоже может произойти ошибка, но она не приведёт к утечкам!
try {
y = new B();
} catch (...) {
delete x; // если что-то пошло не так, то освобождаем уже созданный x
throw; // и прокидываем исключение дальше
}
}
// Примерно такой же код придётся написать
// в конструкторе копирования и в операторе присваивания
// ...
~D() {
delete x;
delete y;
}
};
Мы решили проблему, но это выглядит коряво. Если ли лучшее решение? Следуя идиоме RAII нам следовало бы каждую переменную — и x
, и y
— хранить вместо обычного указателя в какой-нибудь умной «обёртке». В конструкторе такой обёртки выделялась бы память, а в деструкторе она бы освобождалась. В чём-то такая обёртка была бы похожа на класс C
с одним указателем. Тогда в самом классе D
нам не пришлось бы ни обрабатывать исключения, ни писать деструктор. Более того, не надо было бы вообще определять свои методы: сгодились бы все те, что компилятор предоставляет по умолчанию.
template <typename A, typename B>
class D {
private:
C<A> x;
C<B> y;
};
Заметьте, никаких конструкторов, операторов присваивания и деструктора в классе D
теперь вообще писать не нужно! Что, например, произойдёт, если в конструкторе по умолчанию при инициализации поля y
будет выброшено исключение? В этом случае будет сворачиваться стек. Для уже проинициализированного поля x
будет вызван деструктор. Но теперь тип поля x
— это не «голый» указатель A*
, а наша обёртка C<A>
. В её деструкторе и будет освобождена память. Утечки не произойдёт.
Зачем может понадобиться хранить в классе не обычные поля, а указатели?
Рассмотрим, например, вот такую альтернативу классу D
:
template <typename A, typename B>
class E {
private:
A a;
B b;
};
Здесь поля a
и b
являются автоматическими. Если такой вариант решения возможен, то он является более предпочтительным. Однако указатели могут понадобиться по следующим причинам:
-
Объекты типа
A
иB
могут быть «тяжёлыми», то есть, иметь большойsizeof
. Поэтому их может быть выгоднее создавать не на стеке, который ограничен, а в динамической памяти. -
Может потребоваться отложенная инициализация для полей
a
иb
. КлассE
требует, чтобы эти поля были проинициализированы сразу в его конструкторе. Но инициализацияa
илиb
может быть долгой или дорогой, или у нас пока может не быть для неё достаточного набора данных. Если там будет указатель (изначально — нулевой), то создать объекты типовA
иB
можно будет и позже. Впрочем, для этого можно использовать и обёрткуstd::optional
. -
Мы пишем класс-контейнер, который хранит свои данные в динамической памяти. Обычно двумя указателями тут не обойтись, но аналогия распространяется и на более сложные случаи. Например, можно реализовать матрицу через низкоуровневое выделение памяти для двумерного массива. А можно вместо этого воспользоваться готовой RAII-обёрткой — стандартным вектором векторов, что мы и сделали в параграфе «Шаблоны классов».
Умный указатель std::unique_ptr
Умные обёртки над отдельным указателем, следующие идиоме RAII, есть в стандартной библиотеке. Это так называемые умные указатели (smart pointers).
Рассмотрим умный указатель std::unique_ptr
.
#include <iostream>
#include <memory> // все умные указатели объявлены здесь
int main() {
int* ptr = new int(17); // обычный указатель
std::cout << *ptr << "\n"; // 17
delete ptr; // важно не забыть!
// А это — умный указатель
std::unique_ptr<int> smart = std::make_unique<int>(17); // вместо new int(17)
// Он притворяется обычным указателем — у него перегружены соответствующие операторы:
std::cout << *smart << "\n"; // 17
} // вызывать delete не надо, выделенная память освободится при выходе из блока
Умный указатель «владеет» ресурсом: память будет освобождена в его деструкторе. Слово unique
в его названии подчёркивает, что это единственный владелец ресурса. Такой указатель, как и наш класс File
, нельзя скопировать. Иначе будет непонятно, кто должен владеть ресурсом и кто должен удалять его: исходный объект или копия?
auto smart2 = smart; // не скомпилируется!
Заметим, что std::unique_ptr
отличается от класса C
, который мы написали ранее. Класс C
выполнял глубокое копирование хранимого объекта, в то время как std::unique_ptr
копирование запрещает. В этом смысле std::unique_ptr
больше похож на наш класс File
. Как и у File
, у std::unique_ptr
есть конструктор перемещения и оператор перемещающего присваивания. Поэтому, хотя объекты std::unique_ptr
нельзя копировать, их можно возвращать из функции:
#include <memory>
#include <utility>
// Странная функция: она неявно требует, чтобы вызывающая сторона взяла на себя владение ресурсом
int* f() {
// ...
return new int(123);
}
// Эта функция возвращает умный указатель:
std::unique_ptr<int> g() {
// ...
return std::make_unique<int>(123); // OK
}
int main() {
f(); // возвращаемое значение проигнорировано, память утекла!
g(); // а тут ничего страшного: деструктор временного объекта очистил память
}
Может показаться, что std::unique_ptr
фактически не отличается от автоматической переменной. Это не так. Объект, созданный в динамической памяти и обёрнутый в std::unique_ptr
, всегда имеет одного чётко определённого владельца. Невозможно случайно создать копию такого объекта. Владение можно передавать в функции и возвращать из них.
#include <iostream>
#include <memory>
#include <utility>
void PutIn(std::unique_ptr<Logger> x) {
// Получаем x по значению
// что-то делаем с x
}
int main() {
auto smart = std::make_unique<Logger>();
// ...
// Притворяемся, что smart — временный объект.
// Фактически, передаём владение в функцию.
PutIn(std::move(smart));
// Объект smart теперь всё ещё живой, но он больше ничем не владеет
if (smart.get() == nullptr) {
std::cout << "Empty!\n"; // Empty!
}
}
Рассмотрим элегантную реализацию односвязного списка через std::unique_ptr
.
#include <memory>
template <typename T>
class ForwardList {
private:
struct Node {
T data;
std::unique_ptr<Node> next; // узел владеет следующим узлом
};
std::unique_ptr<Node> head; // сам список владеет начальным узлом
public:
void PushFront(const T& elem) {
head = std::make_unique<Node>(elem, std::move(head));
}
void PopFront() {
head = std::move(head->next);
}
const T& Front() const {
return head->data;
}
bool Empty() const {
return head == nullptr;
}
~ForwardList() {
// Можно было бы оставить деструктор пустым — всё корректно бы удалилось,
// но на больших списках мог бы переполниться стек вызовов из-за рекурсии в деструкторе Node.
while (!Empty()) {
PopFront();
}
}
};
Сравните это с реализацией класса List
из предыдущего параграфа!
Умный указатель std::shared_ptr
Другая разновидность умного указателя — умный указатель с подсчётом ссылок на объект. Это std::shared_ptr
. Такой указатель уже можно копировать. При копировании увеличивается счётчик созданных копий. В деструкторе этот счётчик уменьшается. Объект удаляется последним владельцем, когда счётчик дойдёт до нуля. Этот счётчик хранится в отдельной ячейке динамической памяти. На неё ссылаются все объекты shared_ptr
, которые разделяют владение одним и тем же объектом в динамической памяти. Текущее значение счётчика можно узнать с помощью функции use_count
.
#include <iostream>
#include <memory>
int main() {
std::shared_ptr<int> ptr1 = std::make_shared<int>(17);
std::cout << *ptr1 << "\n"; // 17
std::cout << ptr1.use_count() << "\n"; // 1
auto ptr2 = ptr1; // копирование разрешено!
std::cout << *ptr1 << "\n"; // 17
std::cout << *ptr2 << "\n"; // 17 — это всё тот же объект
std::cout << ptr1.use_count() << "\n"; // 2
std::cout << ptr2.use_count() << "\n"; // 2
std::shared_ptr<int> ptr3;
std::cout << ptr3.use_count() << "\n"; // 0
ptr3 = ptr1; // присваивание тоже разрешено!
std::cout << *ptr3 << "\n"; // 17
std::cout << ptr1.use_count() << "\n"; // 3
std::cout << ptr2.use_count() << "\n"; // 3
std::cout << ptr3.use_count() << "\n"; // 3
}
Классический случай применения std::shared_ptr
— реализация направленного ациклического графа. В этой реализации каждая вершина хранит вектор умных указателей на соседние вершины, в которые ведут рёбра. Умные указатели на начальные вершины хранятся отдельно.
Подробнее
Требование ацикличности графа важно: если допустить циклы в таком графе, то может оказаться, что вершина A
ссылается через std::shared_ptr
на вершину B
, а та, в свою очередь, ссылается через std::shared_ptr
на вершину A
. Такой цикл никогда не сможет быть разрушен с помощью счётчика ссылок. Это приведёт к утечке памяти. Решением может быть замена одной из ссылок на std::weak_ptr
, но это выходит за рамки этого пособия.
Следует помнить, что счётчик ссылок и выделение динамической памяти — это дополнительные накладные расходы. Поэтому предпочитайте класс std::unique_ptr
классу std::shared_ptr
, а обычный объект на стеке — умному указателю std::unique_ptr
там, где это возможно.