JavaRush /Курсы /C++ SELF /RAII в стандартной библиотеке: потоки, контейнеры

RAII в стандартной библиотеке: потоки, контейнеры

C++ SELF
42 уровень , 1 лекция
Открыта

1. Введение

Если в ручном new/delete вы почувствовали себя сапёром, которому выдали два проводка и сказали «ну ты аккуратно», то стандартная библиотека C++ — это тот момент, когда вам наконец дают нормальный заводской разъём с защёлкой. Идея RAII (Resource Acquisition Is Initialization) в стандартной библиотеке не «фича», а базовая культура: большинство типов там устроены так, чтобы захватывать ресурс при создании объекта и гарантированно отпускать его в деструкторе. И это работает не только для памяти — «ресурсом» может быть файл, буфер, блокировка, соединение, дескриптор и т.д.

Здесь важно поймать правильное ощущение: RAII — это не «способ писать деструкторы ради деструкторов», а способ сделать программу менее нервной. Вы пишете код «по смыслу» (что сделать), а не «по обряду» (в каждой ветке не забыть delete, close(), unlock() и так далее).

2. RAII в контейнерах: std::string и std::vector

Когда вы впервые изучали std::string и std::vector, вы, скорее всего, думали о них как о «удобной строке» и «удобном массиве». Сейчас мы на те же самые типы посмотрим глазами RAII: это готовые владельцы динамической памяти, причём владельцы в хорошем смысле «скучные». Они умеют выделять память, расширяться, освобождать память и не заставляют вас писать delete[] по углам программы. Скучно — значит надёжно (в программировании это комплимент).

«Память как ресурс»: кто освобождает и когда

std::vector<int> внутри себя хранит динамический буфер (в куче), куда складывает элементы. При выходе vector из области видимости вызывается его деструктор — и буфер освобождается автоматически. Это ровно та же идея, что у нашего самодельного RAII-владельца из прошлой лекции, только реализована профессионально и протестирована миллионами программистов, которые тоже иногда забывают поесть и закрыть delete[].

Мини-пример «память освобождается сама» выглядит банально — и это хорошо:

#include <vector>

void f() {
    std::vector<int> v{1, 2, 3};
    v.push_back(4);
} // v уничтожается -> память освобождается автоматически

Важный психологический момент: вы не видите delete[], но он «как бы есть» — просто спрятан внутри деструктора vector. То есть вы всё ещё работаете с кучей, просто больше не отвечаете головой за каждый байт.

Ранний return и автоматическое освобождение

Одна из самых противных причин утечек — «ранний выход». Вы уже видели это на примерах с new int{...}. С контейнерами вы получаете RAII-страховку автоматически: при выходе из функции контейнер будет уничтожен, а значит ресурс будет освобождён.

#include <iostream>
#include <vector>

int sum_if_ok(bool ok) {
    std::vector<int> data{10, 20, 30};

    if (!ok) {
        return 0; // data всё равно корректно уничтожится
    }

    return data[0] + data[1] + data[2];
}

int main() {
    std::cout << sum_if_ok(false) << '\n'; // 0
    std::cout << sum_if_ok(true) << '\n';  // 60
}

Здесь нет «памятного камня» с надписью «не забудь очистить». Уничтожение привязано к времени жизни объекта, а не к вашей внимательности.

Копирование без double-free

В лекции про double-free мы видели проблему: если два указателя смотрят на один и тот же адрес, и оба делают delete, будет беда. С std::vector и std::string так не происходит в нормальном использовании, потому что копирование у них — копирование значения. То есть у каждой копии свой буфер, и каждый деструктор освобождает свой ресурс.

Давайте сравним идею (без запуска UB-кода).

Небезопасная мысль (копия указателя = два «владельца» одного адреса):

int main() {
    int* p = new int{1};
    int* q = p;     // q и p смотрят в один адрес (два "владельца" в голове новичка)

    delete p;
    // delete q;    // UB: double-free (не выполняем)
    q = nullptr;
}

А вот с vector копирование ведёт себя по-человечески:

#include <iostream>
#include <vector>

int main() {
    std::vector<int> a{1, 2, 3};
    std::vector<int> b = a;   // копия значений (не копия "адреса буфера")

    b[0] = 100;

    std::cout << a[0] << '\n'; // 1
    std::cout << b[0] << '\n'; // 100
}

Да, копирование vector может быть дорогим (копируются элементы). Но в контексте RAII это честная цена за понятную семантику владения. Мы позже будем обсуждать, как избегать лишних копий, но сейчас важнее другое: копирование контейнеров не превращает программу в минное поле.

Мини-схема: как RAII «держит» память контейнера

flowchart TD
    A["Создали vector/string"] --> B["Внутри выделилась память (heap)"]
    B --> C["Работаем с данными"]
    C --> D["Выход из scope (}) или return"]
    D --> E["Деструктор vector/string"]
    E --> F["Освобождение памяти автоматически"]

Смысл схемы в том, что ресурс (память) «привязан» к объекту (контейнеру), а не к набору ручных действий, раскиданных по функции.

3. RAII в потоках: std::stringstream и файловые потоки

Слово «потоки» (streams) у новичков часто вызывает странные ассоциации: кто-то думает про «потоки выполнения» (threads), кто-то про «потоки воды» (и это ближе к истине, чем кажется). В стандартной библиотеке поток — это объект, через который «течёт» ввод/вывод, а сам объект обычно владеет некоторым ресурсом: буфером, дескриптором файла, состоянием форматирования. И как раз поэтому потоки — отличный пример RAII: они сами корректно освобождают то, чем владеют, когда выходят из области видимости.

std::stringstream: буфер как ресурс

std::stringstream вы уже встречали в теме парсинга: он хранит строку внутри, позволяет писать в неё через <<, а потом читать из неё через >> или получить результат через .str(). Внутри у него есть буфер (память), и этот буфер тоже освобождается автоматически, когда stringstream уничтожается.

Практическая польза: удобно строить строки для логов/сообщений, не склеивая всё вручную через +.

#include <iostream>
#include <sstream>
#include <string>

std::string make_log_line(int id, const std::string& text) {
    std::stringstream ss;
    ss << "task#" << id << ": " << text;
    return ss.str();
}

int main() {
    std::cout << make_log_line(7, "buy milk") << '\n'; // task#7: buy milk
}

Обратите внимание: мы возвращаем std::string по значению, и это нормально. Строка — владеющий тип, и её память тоже будет управляться автоматически.

Файловые потоки: «открыл — и само закроется»

Полноценная работа с файлами у нас будет позже отдельным днём, поэтому здесь мы берём ровно одну мысль: объект файлового потока (например, std::ofstream или std::ifstream) обычно закрывает файл в своём деструкторе. То есть даже если вы забудете вызвать .close(), при выходе из области видимости поток аккуратно отпустит ресурс.

Это, кстати, одна из причин, почему в C++ любят «оборачивать ресурсы в объекты»: закрывать файл в одном месте проще, чем помнить про close() в каждой ветке.

Мини-демонстрация (без углубления в обработку ошибок):

#include <fstream>

void write_demo() {
    std::ofstream out("demo.txt");
    out << "Hello!\n";
} // out уничтожается -> файл закрывается автоматически

Даже если вы пока не будете писать такой код в учебных задачах, важно, чтобы у вас в голове закрепилось: потоки — RAII-объекты, они «держат ресурс» и «отпускают ресурс» сами.

4. RAII в блокировках: std::mutex и std::lock_guard

С блокировками есть забавный парадокс: как только их начинаешь объяснять «по-взрослому», новичкам хочется убежать. Но как только их не объясняешь вообще — новички потом пишут код, который иногда работает, а иногда… живёт своей жизнью. Поэтому мы сделаем ровно то, что нужно для темы RAII: посмотрим на блокировку как на ресурс и увидим, как RAII спасает от забытых unlock().

Внимание: мы пока не строим многопоточные программы и не обсуждаем гонки данных. Сейчас нас интересует только механика «взял ресурс — отпусти ресурс гарантированно».

Почему ручной lock()/unlock() легко ломается

Представим, что у вас есть мьютекс, и вы вручную его блокируете, а потом где-то делаете ранний выход. Ошибка очень человеческая: вы добавили return, чтобы улучшить читаемость, и забыли, что теперь нужно ещё и unlock().

#include <mutex>

std::mutex g_m;

void bad(bool ok) {
    g_m.lock();

    if (!ok) {
        return; // мьютекс останется заблокированным (логическая катастрофа)
    }

    g_m.unlock();
}

Даже без потоков видно: в этой функции есть путь, где unlock() не выполняется. В многопоточном мире это превращается в зависание, которое «иногда случается», «на компьютере тимлида не воспроизводится» и «в пятницу вечером почему-то всегда».

std::lock_guard: «заблокировал в конструкторе, разблокировал в деструкторе»

std::lock_guard — маленький объект-охранник. Он берёт мьютекс в конструкторе (блокирует), а в деструкторе отпускает (разблокирует). Это буквальная RAII-модель: ресурс — это «право владеть блокировкой».

#include <mutex>

std::mutex g_m;

void good(bool ok) {
    std::lock_guard<std::mutex> guard(g_m);

    if (!ok) {
        return; // guard уничтожится -> мьютекс разблокируется автоматически
    }

    // ... критическая секция ...
}

Идея настолько фундаментальная, что в документах и обсуждениях стандартной библиотеки регулярно всплывают формулировки и требования вокруг блокировочных типов и их интерфейса.

Границы критической секции задаются {}

Очень полезно мысленно перестать думать «я вызвал lock/unlock» и начать думать «я создал объект guard — значит секция началась; guard уничтожился — значит секция закончилась». Тогда границы защищённого участка становятся такими же видимыми, как отступы в коде.

Вот аккуратный паттерн через отдельный блок:

#include <iostream>
#include <mutex>

std::mutex g_out;

void print_line(const std::string& s) {
    {
        std::lock_guard<std::mutex> guard(g_out);
        std::cout << s << '\n';
    } // guard уничтожен -> мьютекс отпущен
}

Да, в однопоточном коде это выглядит как «надел шлем, чтобы сходить в магазин за хлебом». Но в лекции про RAII нам важен принцип: если освобождение ресурса привязано к деструктору, то вы не забудете освобождение при раннем return, выходе из блока и вообще при любой структуре кода.

5. Мини‑пример: TaskBook без new/delete, но с RAII

Сейчас мы соберём небольшой фрагмент нашего условного консольного приложения «TaskBook» (список задач), которое мы могли развивать на протяжении курса: храним задачи в памяти, читаем команды строкой, разбираем её через stringstream, печатаем результат. В этой лекции мы не добавляем «новую функциональность ради функциональности», а показываем: почти всё здесь уже RAII, и ручное управление ресурсами просто не нужно.

Модель данных: задачи в std::vector, текст в std::string

#include <string>
#include <vector>

struct Task {
    int id{};
    std::string text;
};

using TaskList = std::vector<Task>;

Никаких Task*, никаких new Task[]. Память под список задач будет расширяться автоматически внутри vector, а строки будут хранить текст внутри string.

Добавление задачи: контейнер сам управляет памятью

#include <string>
#include <vector>

int add_task(std::vector<Task>& tasks, const std::string& text) {
    int new_id = static_cast<int>(tasks.size()) + 1;
    tasks.push_back(Task{new_id, text});
    return new_id;
}

Тут важно не то, что это «идеальная генерация id» (она не идеальная), а то, что мы вообще не думаем про освобождение памяти: tasks владеет своими элементами.

Лог-строка через std::stringstream

#include <sstream>
#include <string>

std::string format_added(int id, const std::string& text) {
    std::stringstream ss;
    ss << "Added task #" << id << ": " << text;
    return ss.str();
}

ss уничтожится — буфер освободится. Возвращаем std::string — она сама управляет памятью.

Печать с «охранником»: lock_guard как RAII не для памяти

Даже если у нас пока нет потоков, сделаем функцию печати так, будто завтра она станет вызываться из разных мест (или из разных потоков — позже). Это хороший стиль: защита ресурса (консоли) находится в одной точке.

#include <iostream>
#include <mutex>
#include <string>

std::mutex g_cout_mutex;

void safe_println(const std::string& s) {
    std::lock_guard<std::mutex> guard(g_cout_mutex);
    std::cout << s << '\n';
}

И вот так это может выглядеть вместе:

#include <iostream>
#include <string>
#include <vector>

int main() {
    std::vector<Task> tasks;

    int id = add_task(tasks, "learn RAII");
    safe_println(format_added(id, "learn RAII")); // Added task #1: learn RAII
}

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

6. Типичные ошибки при использовании RAII-типов стандартной библиотеки

Ошибка №1: думать, что RAII = «ничего не может пойти не так».
RAII гарантирует освобождение ресурса при уничтожении объекта, но не гарантирует, что вы правильно пользуетесь объектом. Например, std::vector не защитит вас от логической ошибки «не туда положил элемент», а поток не защитит от «читаю не из того формата». RAII — это ремень безопасности, а не автопилот.

Ошибка №2: сохранять «долгоживущие» указатели/ссылки на внутренности vector/string.
Частая ловушка: взять Task* p = &tasks[0]; и где-то хранить, а потом сделать tasks.push_back(). vector может перевыделить память, и ваш p станет указывать в никуда. Это не отменяет RAII, это просто другая категория проблем — время жизни «привязок» к элементам контейнера. Поэтому на практике стараются либо не хранить такие адреса долго, либо хранить индексы/идентификаторы.

Ошибка №3: «ручное управление поверх RAII»: лишние close(), unlock(), delete[] в неподходящих местах.
Иногда программист привыкает «на всякий случай всё закрывать вручную» и начинает вызывать unlock() там, где должен сработать lock_guard, или вручную закрывать поток в середине функции, а потом удивляться, что дальнейшая запись не идёт. Если вы выбрали RAII-объект, пусть он делает свою работу: освобождение ресурса должно быть привязано к области видимости, иначе вы сами себе возвращаете старые риски, от которых пытались уйти.

Ошибка №4: слишком широкая критическая секция при lock_guard.
Другая крайность: поставить lock_guard в начале функции и держать мьютекс заблокированным «на всякий случай» до конца, включая тяжёлые вычисления или ввод/вывод. Это не ошибка компиляции, но архитектурная ошибка мышления. RAII удобно тем, что границы секции задаются блоком {} — используйте это, чтобы блокировать ровно на нужный участок, не больше.

Ошибка №5: использовать new[] там, где уже есть vector/string, и оправдывать это словами «так быстрее/проще».
На практике для новичка new[] почти всегда сложнее: нужно помнить про delete[], нельзя копировать «как значение», легко потерять адрес и устроить утечку. std::vector и std::string решают именно эти бытовые проблемы. Если вы не можете очень чётко объяснить, зачем вам ручная куча, значит, она вам пока не нужна.

1
Задача
C++ SELF, 42 уровень, 1 лекция
Недоступна
Проверка партии
Проверка партии
1
Задача
C++ SELF, 42 уровень, 1 лекция
Недоступна
Одна строка лога
Одна строка лога
1
Задача
C++ SELF, 42 уровень, 1 лекция
Недоступна
Буфер команд
Буфер команд
1
Задача
C++ SELF, 42 уровень, 1 лекция
Недоступна
Печать под замком
Печать под замком
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ