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