4.6. Идиома RAII и умные указатели

Идиома 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 являются автоматическими. Если такой вариант решения возможен, то он является более предпочтительным. Однако указатели могут понадобиться по следующим причинам:

  1. Объекты типа A и B могут быть «тяжёлыми», то есть, иметь большой sizeof. Поэтому их может быть выгоднее создавать не на стеке, который ограничен, а в динамической памяти.

  2. Может потребоваться отложенная инициализация для полей a и b. Класс E требует, чтобы эти поля были проинициализированы сразу в его конструкторе. Но инициализация a или b может быть долгой или дорогой, или у нас пока может не быть для неё достаточного набора данных. Если там будет указатель (изначально — нулевой), то создать объекты типов A и B можно будет и позже. Впрочем, для этого можно использовать и обёртку std::optional.

  3. Мы пишем класс-контейнер, который хранит свои данные в динамической памяти. Обычно двумя указателями тут не обойтись, но аналогия распространяется и на более сложные случаи. Например, можно реализовать матрицу через низкоуровневое выделение памяти для двумерного массива. А можно вместо этого воспользоваться готовой 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 там, где это возможно.

Отмечайте параграфы как прочитанные чтобы видеть свой прогресс обучения

Вступайте в сообщество хендбука

Здесь можно найти единомышленников, экспертов и просто интересных собеседников. А ещё — получить помощь или поделиться знаниями.
Вступить
Сообщить об ошибке
Предыдущий параграф4.5. Обработка исключений

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

Следующий параграф4.7. Разбор задач к главе «Идиомы C++»

В этом параграфе мы разберём задачи к главе «Идиомы C++».