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

Идиома RAII помогает эффективно организовать работу с ресурсами (например, памятью или файлами) и создавать более надёжный код. На этой идее построены «умные указатели» — классы unique_ptr и shared_ptr из стандартной библиотеки.

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

  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.

#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 там, где это возможно.

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

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

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

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

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

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