Перейти к содержанию

Последнее обновление:2026-10-12

Управление ресурсами RAII в C++

0. Введение

В языке C существует три типа распределения памяти: статическое, автоматическое и динамическое. Статические переменные — это константы, встроенные в исходный файл; поскольку их размер известен и никогда не меняется, они не так интересны. Автоматическое распределение можно считать стековым — память выделяется при входе в лексический блок и освобождается при выходе из него. Его важнейшая особенность связана с этим напрямую. До C99 переменные с автоматическим выделением должны были иметь размер, известный на этапе компиляции. Это означало, что любые строки, списки, отображения и любые структуры, производные от них, должны были жить в динамической памяти в куче.

Программист использует четыре базовые операции для явного выделения и освобождения динамической памяти: malloc, realloc, calloc и free. Первые две не выполняют никакой инициализации, и память может содержать мусор. Все, кроме free, могут завершиться неудачей. В этом случае они возвращают нулевой указатель, обращение к которому — неопределённое поведение; в лучшем случае ваша программа упадёт. В худшем случае программа будет некоторое время работать, обрабатывая мусорные данные, прежде чем упасть.

Например:

int main() {
   char *str = (char *) malloc(7); 
   strcpy(str, "toptal");
   printf("char array = \"%s\" @ %u\n", str, str);

   str = (char *) realloc(str, 11);
   strcat(str, ".com");
   printf("char array = \"%s\" @ %u\n", str, str);

   free(str);

   return(0);
}

Вывод:

char array = "toptal" @ 2762894960
char array = "toptal.com" @ 2762894960

Хотя код и прост, он уже содержит антипаттерн и сомнительное решение. В реальной жизни не следует писать количество байтов напрямую, а нужно использовать функцию sizeof. Аналогично, мы выделяем массив char * ровно вдвое больше необходимого нам размера строки (на один больше длины строки с учётом нулевого терминатора) — это довольно дорогая операция. Более сложная программа могла бы построить буфер строки большего размера, позволяющий строке расти.

1. Изобретение RAII: новая надежда

Всякое ручное управление, мягко говоря, неприятно. В середине 80-х Bjarne Stroustrup придумал для своего нового языка C++ новую парадигму. Он назвал её «получение ресурса есть инициализация» (Resource Acquisition Is Initialization), и её базовое прозрение таково: можно указать, что объект имеет конструктор и деструктор, которые компилятор автоматически вызывает в подходящие моменты, и это даёт куда более удобный способ управлять памятью данного объекта. Когда это нужно, и эта техника полезна также для ресурсов, не являющихся памятью.

Это означает, что приведённый выше пример на C++ выглядит лаконичнее:

int main() {
   std::string str = std::string ("toptal");
   std::cout << "string object: " << str << " @ " << &str << "\n";

   str += ".com";
   std::cout << "string object: " << str << " @ " << &str << "\n";

   return(0);
}

Вывод:

string object: toptal @ 0x7fffa67b9400
string object: toptal.com @ 0x7fffa67b9400

В примере выше нет никакого ручного управления памятью! Конструируется объект string, вызываются перегруженные методы, а при выходе из функции он автоматически уничтожается. К сожалению, та же простота порождает и другие проблемы. Рассмотрим подробно пример:

vector<string> read_lines_from_file(string &file_name) {
    vector<string> lines;
    string line;

    ifstream file_handle (file_name.c_str());
    while (file_handle.good() && !file_handle.eof() && file_handle.peek()!=EOF) {
        getline(file_handle, line);
        lines.push_back(line);
    }

    file_handle.close();

    return lines;
}

int main(int argc, char* argv[]) {
    // get file name from the first argument
    string file_name (argv[1]);
    int count = read_lines_from_file(file_name).size();
    cout << "File " << file_name << " contains " << count << " lines.";

    return 0;
}

Вывод:

File makefile contains 37 lines.

Выглядит просто. vector заполняется, возвращается и используется. Однако нас, как эффективных программистов, заботящихся о производительности, кое-что здесь беспокоит: в операторе return из-за использования семантики значений vector копируется в новый vector незадолго до уничтожения исходного.

В современном C++ это уже не строго обязательно. C++11 ввёл концепцию семантики перемещения, при которой источник остаётся в валидном (чтобы его можно было корректно уничтожить), но неспецифицированном состоянии. Для компилятора возврат значения — простейший случай для оптимизации перемещающей семантики, поскольку он знает, что источник будет уничтожен вскоре перед любым дальнейшим обращением. Однако цель этого примера — показать, почему в конце 80-х и начале 90-х было изобретено целое множество языков со сборкой мусора, тогда как move-семантика в C++ была недоступна.

Для файлов с большим объёмом данных это может оказаться дорого. Оптимизируем: будем возвращать только указатель. Синтаксис немного меняется, остальной код тот же:

vector<string> * read_lines_from_file(string &file_name) {
    vector<string> * lines;
    string line;

    ifstream file_handle (file_name.c_str());
    while (file_handle.good() && !file_handle.eof() && file_handle.peek()!=EOF) {
        getline(file_handle, line);
        lines->push_back(line);
    }

    file_handle.close();

    return lines;
}
int main(int argc, char* argv[]) {
    // get file name from the first argument
    string file_name (argv[1]);
    int count = read_lines_from_file(file_name).size();
    cout << "File " << file_name << " contains " << count << " lines.";

    return 0;
}

Вывод:

Segmentation fault (core dumped)

Программа падает! Нам нужно лишь выделить память для lines:

vector<string> * lines = new vector<string>;

Теперь это работает!

К сожалению, несмотря на внешнее совершенство, здесь всё ещё есть изъян: утечка памяти. В C++ указатель на кучу должен быть вручную удалён, когда он больше не нужен; иначе, как только последний указатель выйдет из области видимости, память станет недоступной и вернётся к управлению операционной системой лишь по завершении процесса. Идиоматичный современный C++ использовал бы здесь unique_ptr, реализующий желаемое поведение: он удаляет объект, на который указывает указатель, при выходе указателя из области видимости. Однако такое поведение стало частью языка лишь в C++11.

Здесь можно использовать синтаксис, существовавший до C++11, изменив лишь main:

vector<string> * read_lines_from_file(string &file_name) {
    vector<string> * lines = new vector<string>;
    string line;

    ifstream file_handle (file_name.c_str());
    while (file_handle.good() && !file_handle.eof() && file_handle.peek()!=EOF) {
        getline(file_handle, line);
        lines->push_back(line);
    }

    file_handle.close();

    return lines;
}

int main(int argc, char* argv[]) {
    // get file name from the first argument
    string file_name (argv[1]);
    vector<string> * file_lines = read_lines_from_file(file_name);
    int count = file_lines->size();
    delete file_lines;
    cout << "File " << file_name << " contains " << count << " lines.";

    return 0;
}

Вручную выделить память и вручную освободить её.

К сожалению, по мере роста программы за пределы описанного выше рассуждать о том, когда и где указатель должен быть удалён, становится всё труднее. Когда функция возвращает указатель — теперь он ваш? Следует ли вам удалить его самому по завершении, или он принадлежит некой структуре данных, которая будет освобождена разом позже? Ошибитесь в одну сторону — утечка памяти; ошибитесь в другую — вы разрушили обсуждаемую структуру данных и, возможно, другие структуры, поскольку они попытаются разыменовать ставший невалидным указатель.

2. «Используй сборщик мусора, флайбой!»

Сборка мусора — не новая технология. Она была изобретена John McCarthy в 1959 году для Lisp. В 1980 году с появлением Smalltalk-80 сборка мусора начала становиться мейнстримом. Но настоящим расцветом технологии стали 1990-е: между 1990 и 2000 годами вышло множество языков, использующих ту или иную форму сборки мусора: Haskell, Python, Lua, Java, JavaScript, Ruby, OCaml и C# — самые известные из них.

Что такое сборка мусора? Короче говоря, это набор техник для автоматизации ручного управления памятью. Обычно она предоставляется как библиотека для языков с ручным управлением памятью (как C и C++), но чаще используется в языках, где она необходима. Главнейшее преимущество: программисту вообще не нужно думать о памяти. Всё абстрагировано. Например, эквивалент нашего кода чтения файла на Python выглядит так:

def read_lines_from_file(file_name):
    lines = []
    with open(file_name) as fp: 
        for line in fp:
            lines.append(line)
    return lines

if __name__ == '__main__':
    import sys
    file_name = sys.argv[1]
    count = len(read_lines_from_file(file_name))
    print("File {} contains {} lines.".format(file_name, count))

Массив строк возникает при первом присваивании и возвращается без копирования в область вызова. Поскольку момент не определён, он будет очищен сборщиком мусора в какой-то момент после выхода из области видимости. Любопытно, что в Python RAII для ресурсов, не являющихся памятью, не является идиоматичным. Это разрешено — мы можем просто написать fp = open(file_name) вместо использования блока with и позволить GC провести очистку. Однако рекомендуемый шаблон — по возможности использовать менеджеры контекста, чтобы ресурсы можно было освободить в определённый момент.

Несмотря на упрощение управления памятью, оно обходится большой ценой. При подсчёте ссылок все присваивания переменных и выходы из областей видимости получают небольшую накладную стоимость на обновление ссылок. В системах с отметкой и очисткой выполнение всей программы приостанавливается на непредсказуемые интервалы, пока GC очищает память. Это обычно называют событием «остановка мира» (stop the world). Реализации вроде Python, использующие обе системы одновременно, несут оба штрафа. Эти проблемы снижают применимость языков со сборкой мусора там, где производительность критична или требуются приложения реального времени. Реальную просадку производительности видно даже на следующей игрушечной программе:

$ make cpp && time ./c++ makefile
g++ -o c++ c++.cpp
File makefile contains 38 lines.
real    0m0.016s
user    0m0.000s
sys     0m0.015s

$ time python3 python3.py makefile
File makefile contains 38 lines.

real    0m0.041s
user    0m0.015s
sys     0m0.015s

Реальное время работы версии на Python почти втрое больше, чем версии на C++. И хотя не всё это различие можно отнести на счёт сборки мусора, оно всё равно ощутимо.

3. Владение: пробуждение RAII

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

Некоторые ошибки, которые могут возникнуть при использовании new и delete:

  • утечка объекта (или памяти): объект выделен через new, а удалить его забыли.

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

  • двойное удаление: попытка удалить один объект дважды.

Обычно предпочтительны переменные области видимости. Однако RAII можно использовать как замену new и delete, чтобы объект существовал независимо от своей области видимости. Эта техника состоит в том, чтобы поместить указатель на объект, выделенный в куче, в объект-дескриптор/менеджер. Последний имеет деструктор, который и позаботится об уничтожении объекта. Это гарантирует, что объект доступен любой функции, желающей к нему обратиться, и что объект будет уничтожен в конце времени жизни объекта-дескриптора без явной очистки.

Примерами использования RAII из стандартной библиотеки C++ являются std::string и std::vector.

Рассмотрим этот код:

void fn(const std::string& str)
{
    std::vector<char> vec;
    for (auto c : str)
        vec.push_back(c);
    // do something
}

Когда вы создаёте vector и помещаете в него элементы, вам не нужно беспокоиться о выделении и освобождении памяти для этих элементов. vector использует new для выделения места под элементы в куче и delete для его освобождения. Как пользователь vector вы не обязаны знать детали реализации и доверяете тому, что vector не протекает. В этом случае vector — объект-дескриптор своих элементов.

Другие примеры RAII из стандартной библиотеки — std::shared_ptr, std::unique_ptr и std::lock_guard.

Другое название этой техники — SBRM, сокращение от Scope-Bound Resource Management (управление ресурсами, привязанное к области видимости).

Теперь изменим приведённый выше пример с чтением файла:

#include <iostream>
#include <vector>
#include <cstring>
#include <fstream>
#include <bits/unique_ptr.h>

using namespace std;
unique_ptr<vector<string>> read_lines_from_file(string &file_name) {
    unique_ptr<vector<string>> lines(new vector<string>);
    string line;

    ifstream file_handle (file_name.c_str());
    while (file_handle.good() && !file_handle.eof() && file_handle.peek()!=EOF) {
        getline(file_handle, line);
        lines->push_back(line);
    }

    file_handle.close();

    return lines;
}
int main(int argc, char* argv[]) {
    // get file name from the first argument
    string file_name (argv[1]);
    int count = read_lines_from_file(file_name).get()->size();
    cout << "File " << file_name << " contains " << count << " lines.";

    return 0;
}

4. Лишь в конце ты осознаёшь истинную силу RAII.

С момента изобретения компиляторов ручное управление памятью — кошмар, который программисты всячески пытались избежать. RAII — многообещающий шаблон, но без некоторых странных обходных путей он совершенно не годился для объектов, выделяемых в куче, и потому в C++ страдал. Поэтому в 90-е произошёл взрывной рост языков со сборкой мусора, призванных сделать жизнь программиста приятнее — ценой производительности.

Подводя итог, RAII можно описать так:

  • ресурс освобождается в деструкторе

  • экземпляры класса располагаются на стеке

  • ресурс получается в конструкторе.

RAII расшифровывается как «Resource Acquisition Is Initialization» — «получение ресурса есть инициализация».

Распространённые примеры:

  • файловые операции

  • умные указатели

  • мьютексы

5. Рекомендуемые статьи

1. https://www.toptal.com/software/eliminating-garbage-collector#remote-developer-job

2. https://stackoverflow.com/questions/2321511/what-is-meant-by-resource-acquisition-is-initialization-raii

Похожие темы

Комментарии