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["Вихід з області видимості (}) або 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 розвʼязують саме ці повсякденні проблеми. Якщо ви не можете дуже чітко пояснити, навіщо вам ручна купа, значить, вона вам поки що не потрібна.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ