Деструктори та ідіома RAII
Проблема: хто звільняє ресурси?
У попередніх статтях ми навчилися створювати об'єкти класів і ініціалізувати їх через конструктори. Але залишається критичне питання: хто відповідає за звільнення ресурсів, які використовує об'єкт?
Розглянемо клас, що керує динамічним масивом:
#include <iostream>
using namespace std;
class IntArray
{
private:
int* data; // Покажчик на динамічний масив
int size;
public:
IntArray(int arraySize)
{
cout << "Allocating array of size " << arraySize << "\n";
data = new int[arraySize]; // Виділяємо пам'ять у конструкторі
size = arraySize;
}
void setValue(int index, int value)
{
if (index >= 0 && index < size) {
data[index] = value;
}
}
int getValue(int index) const
{
if (index >= 0 && index < size) {
return data[index];
}
return 0;
}
int getSize() const { return size; }
};
int main()
{
IntArray numbers(10);
numbers.setValue(0, 42);
numbers.setValue(1, 17);
cout << "numbers[0] = " << numbers.getValue(0) << "\n";
cout << "numbers[1] = " << numbers.getValue(1) << "\n";
// ⚠️ Проблема: хто викличе delete[] для data?
return 0;
}
Що не так із цим кодом?
Конструктор IntArray(int) виділяє динамічну пам'ять через new int[arraySize]. Коли об'єкт numbers виходить з області видимості наприкінці main(), пам'ять для самого об'єкта звільняється автоматично (він створений на стеку). Але пам'ять для масиву data, виділена через new, залишається в купі — відбувається витік пам'яті (memory leak)!
new має відповідати виклику delete. Якщо забути викликати delete[] для динамічного масиву, пам'ять не повернеться операційній системі до завершення програми. У великих програмах це призводить до вичерпання пам'яті та аварійного завершення.Можна додати метод cleanup(), який користувач має викликати вручну:
class IntArray
{
public:
// ...
void cleanup()
{
delete[] data;
data = nullptr;
}
};
int main()
{
IntArray numbers(10);
numbers.setValue(0, 42);
numbers.cleanup(); // ❌ Користувач повинен пам'ятати про це!
return 0;
}
Але це ненадійно: легко забути викликати cleanup(), викликати його двічі (подвійне видалення), або взагалі не дізнатися про його існування. Потрібен автоматичний механізм очищення.
Саме для цього існують деструктори (destructors).
Що таке деструктор
Деструктор — це спеціальний метод класу, який автоматично викликається під час знищення об'єкта. Якщо конструктор ініціалізує об'єкт та захоплює ресурси, то деструктор звільняє ці ресурси та виконує необхідне очищення перед остаточним знищенням об'єкта.
Правила іменування деструкторів
Деструктори мають особливі правила, схожі на конструктори:
📛 Ім'я деструктора
Ім'я класу зі знаком тильди ~ на початку:
class ClassName
{
public:
~ClassName() // Деструктор
{
// Код очищення
}
};
🚫 Без параметрів
Деструктор не приймає жодних параметрів:
// ✅ Правильно
~IntArray() {}
// ❌ Помилка — параметри заборонені
~IntArray(int x) {}
Це означає, що клас може мати тільки один деструктор — немає перевантаження.
↩️ Без типу повернення
Деструктор не має типу повернення (навіть не void):
// ✅ Правильно
~IntArray() {}
// ❌ Помилка — тип повернення заборонений
void ~IntArray() {}
int ~IntArray() {}
🔓 Зазвичай public
Деструктор зазвичай оголошується public, щоб дозволити знищення об'єкта будь-де:
class IntArray
{
public:
~IntArray() {} // Public деструктор
};
Приватні деструктори — рідкісний випадок для спеціальних патернів.
Додаємо деструктор до IntArray
Виправимо клас IntArray, додавши деструктор, який звільняє динамічну пам'ять:
#include <iostream>
using namespace std;
class IntArray
{
private:
int* data;
int size;
public:
// Конструктор — виділяє ресурс
IntArray(int arraySize)
{
cout << "Constructor: Allocating array of size " << arraySize << "\n";
data = new int[arraySize];
size = arraySize;
// Ініціалізуємо масив нулями
for (int i = 0; i < size; i++) {
data[i] = 0;
}
}
// Деструктор — звільняє ресурс
~IntArray()
{
cout << "Destructor: Deallocating array\n";
delete[] data; // Звільняємо динамічну пам'ять
}
void setValue(int index, int value)
{
if (index >= 0 && index < size) {
data[index] = value;
}
}
int getValue(int index) const
{
if (index >= 0 && index < size) {
return data[index];
}
return 0;
}
int getSize() const { return size; }
};
int main()
{
IntArray numbers(5);
numbers.setValue(0, 10);
numbers.setValue(1, 20);
cout << "numbers[0] = " << numbers.getValue(0) << "\n";
cout << "numbers[1] = " << numbers.getValue(1) << "\n";
cout << "End of main()\n";
return 0;
} // ✅ Тут автоматично викликається ~IntArray()!
Зверніть увагу на порядок виводу: деструктор викликається останнім, після завершення всього коду main(), але перед тим, як програма завершиться. Це відбувається автоматично — нам не потрібно нічого викликати вручну!
- Об'єкт виходить з області видимості (локальні змінні наприкінці блоку
{}) - Викликається
deleteдля динамічно створеного об'єкта - Програма завершується (для глобальних і статичних об'єктів)
Коли викликаються деструктори: детальний розгляд
Розглянемо різні сценарії виклику деструкторів:
Локальні об'єкти (на стеку)
#include <iostream>
using namespace std;
class Demo
{
private:
int id;
public:
Demo(int demoId) : id(demoId)
{
cout << "Constructing Demo " << id << "\n";
}
~Demo()
{
cout << "Destructing Demo " << id << "\n";
}
int getId() const { return id; }
};
int main()
{
cout << "Start of main()\n";
Demo obj1(1); // Створено на стеку
{
Demo obj2(2); // Створено у вкладеному блоці
cout << "Inside nested block\n";
} // ✅ obj2 виходить з області видимості — деструктор викликається тут
cout << "After nested block\n";
return 0;
} // ✅ obj1 виходить з області видимості — деструктор викликається тут
Об'єкт obj2 знищується одразу після закриваючої дужки } вкладеного блоку. Об'єкт obj1 знищується наприкінці main().
Динамічні об'єкти (на купі)
Для динамічно створених об'єктів деструктор викликається при явному виклику delete:
int main()
{
cout << "Start of main()\n";
Demo* ptr1 = new Demo(10); // Динамічно створений об'єкт
Demo* ptr2 = new Demo(20);
cout << "Before delete\n";
delete ptr2; // ✅ Деструктор для obj2 викликається тут
cout << "After deleting ptr2\n";
delete ptr1; // ✅ Деструктор для obj1 викликається тут
cout << "End of main()\n";
return 0;
}
delete для динамічно створеного об'єкта, деструктор не викличеться, і ресурси не звільняться:Demo* leak = new Demo(99);
// ❌ Забули викликати delete leak;
// Деструктор НЕ викличеться — витік пам'яті!
delete.Порядок виклику конструкторів та деструкторів
Порядок виклику деструкторів — зворотний до порядку виклику конструкторів. Це логічно: об'єкт, створений останнім, знищується першим (принцип LIFO — Last In, First Out).
#include <iostream>
using namespace std;
class Resource
{
private:
int id;
public:
Resource(int resourceId) : id(resourceId)
{
cout << " [+] Resource " << id << " acquired\n";
}
~Resource()
{
cout << " [-] Resource " << id << " released\n";
}
};
int main()
{
cout << "main() started\n";
Resource r1(1);
Resource r2(2);
Resource r3(3);
cout << "main() ending\n";
return 0;
}
Порядок конструювання: r1 → r2 → r3
Порядок деструкції: r3 → r2 → r1 (зворотний!)
r3 використовує r2, а r2 використовує r1, то r3 має бути знищений першим, щоб не звертатися до вже знищених r2 чи r1.Класи з вкладеними об'єктами
Розглянемо клас, що містить інші об'єкти як поля:
class Engine
{
private:
int power;
public:
Engine(int hp) : power(hp)
{
cout << " Engine constructor: " << power << " HP\n";
}
~Engine()
{
cout << " Engine destructor: " << power << " HP\n";
}
};
class Car
{
private:
Engine engine;
string model;
public:
Car(string carModel, int enginePower) : engine(enginePower), model(carModel)
{
cout << " Car constructor: " << model << "\n";
}
~Car()
{
cout << " Car destructor: " << model << "\n";
}
};
int main()
{
cout << "Creating car\n";
Car myCar("Toyota", 150);
cout << "End of main\n";
return 0;
}
Порядок конструювання:
- Спочатку конструюються поля-об'єкти (
engine) — зліва направо в порядку оголошення - Потім виконується тіло конструктора зовнішнього класу (
Car)
Порядок деструкції — зворотний:
- Спочатку виконується тіло деструктора зовнішнього класу (
~Car()) - Потім знищуються поля-об'єкти (
~Engine()) — у зворотному порядку оголошення
Це гарантує, що поля доступні протягом усього часу життя зовнішнього об'єкта.
Ідіома RAII (Resource Acquisition Is Initialization)
RAII (Resource Acquisition Is Initialization — «Отримання ресурсу є ініціалізація») — це фундаментальна ідіома C++, яка пов'язує час життя ресурсу із часом життя об'єкта.
Принципи RAII
Крок 1: Захоплення ресурсу в конструкторі
Ресурс (пам'ять, файл, мережеве з'єднання, мьютекс) захоплюється (acquire) у конструкторі об'єкта.
class FileHandler
{
private:
FILE* file;
public:
FileHandler(const char* filename)
{
file = fopen(filename, "w"); // Захоплюємо ресурс (файл)
if (file == nullptr) {
throw runtime_error("Failed to open file");
}
cout << "File opened: " << filename << "\n";
}
};
Крок 2: Використання ресурсу протягом життя об'єкта
Поки об'єкт існує, ресурс доступний і може використовуватися через методи класу.
void write(const char* text)
{
fprintf(file, "%s\n", text);
}
Крок 3: Звільнення ресурсу в деструкторі
Ресурс автоматично звільняється (release) у деструкторі, коли об'єкт знищується.
~FileHandler()
{
if (file != nullptr) {
fclose(file); // Звільняємо ресурс
cout << "File closed\n";
}
}
Повний приклад RAII з файлом
#include <iostream>
#include <cstdio>
#include <stdexcept>
using namespace std;
class FileHandler
{
private:
FILE* file;
string filename;
public:
// Конструктор — захоплює ресурс
FileHandler(const char* name) : filename(name), file(nullptr)
{
file = fopen(name, "w");
if (file == nullptr) {
throw runtime_error("Failed to open file");
}
cout << "File opened: " << filename << "\n";
}
// Деструктор — звільняє ресурс
~FileHandler()
{
if (file != nullptr) {
fclose(file);
cout << "File closed: " << filename << "\n";
}
}
void write(const char* text)
{
if (file != nullptr) {
fprintf(file, "%s\n", text);
}
}
};
void processData()
{
FileHandler output("data.txt"); // Файл відкривається тут
output.write("Line 1");
output.write("Line 2");
output.write("Line 3");
// Файл автоматично закриється при виході з функції
}
int main()
{
cout << "Before processData()\n";
processData();
cout << "After processData()\n";
return 0;
}
✅ Переваги RAII
- Автоматичність — ресурси звільняються автоматично, не потрібно пам'ятати про
delete,fclose(), тощо - Безпека винятків — навіть якщо виникне виняток, деструктор викличеться і ресурс звільниться
- Немає витоків — неможливо забути звільнити ресурс
- Простота використання — клієнтський код не турбується про управління ресурсами
🎯 Приклади RAII у стандартній бібліотеці
std::string— керує динамічною пам'яттю для рядкаstd::vector— керує динамічним масивомstd::fstream— керує файловими дескрипторамиstd::unique_ptr— керує динамічною пам'яттю (розглянемо пізніше)std::lock_guard— керує мьютексами у багатопотоковому коді
Усі ці класи захоплюють ресурси в конструкторі та звільняють у деструкторі!
- Виділяйте ресурс у конструкторі
- Звільняйте ресурс у деструкторі
- Не створюйте об'єкти класу динамічно — використовуйте стек
RAII та винятки
Одна з найважливіших переваг RAII — безпека при винятках. Навіть якщо виникне виняток, деструктори викликаються для всіх об'єктів на стеку:
void riskyFunction()
{
FileHandler log("log.txt"); // Відкрили файл
IntArray data(1000); // Виділили пам'ять
log.write("Processing started");
// Якась небезпечна операція
throw runtime_error("Something went wrong!"); // ❌ Виняток!
// Цей код не виконається
log.write("Processing finished");
}
int main()
{
try {
riskyFunction();
}
catch (const exception& e) {
cout << "Caught exception: " << e.what() << "\n";
}
// ✅ Деструктори для log і data все одно виконались!
// Файл закритий, пам'ять звільнена
return 0;
}
Коли виникає виняток у riskyFunction(), стек розкручується (stack unwinding): всі локальні об'єкти (log, data) автоматично знищуються, викликаються їхні деструктори, ресурси звільняються. Це робить код виняток-безпечним (exception-safe) без додаткових зусиль.
void riskyFunctionManual()
{
FILE* file = fopen("log.txt", "w");
int* data = new int[1000];
try {
// Робота з ресурсами
throw runtime_error("Error!");
}
catch (...) {
// ❌ Потрібно вручну звільняти ресурси у кожному catch
fclose(file);
delete[] data;
throw;
}
// ❌ І тут теж
fclose(file);
delete[] data;
}
Небезпека функції exit()
Функція exit() завершує програму негайно, минаючи нормальне розкручування стеку. Це означає, що деструктори локальних об'єктів не викликаються.
#include <iostream>
#include <cstdlib> // Для exit()
using namespace std;
class Important
{
private:
string name;
public:
Important(string resourceName) : name(resourceName)
{
cout << "Acquiring resource: " << name << "\n";
}
~Important()
{
cout << "Releasing resource: " << name << "\n";
// Важлива робота: закриття файлів, запис у БД, тощо
}
};
int main()
{
Important resource("Database Connection");
cout << "Doing work...\n";
// Виявлено критичну помилку
if (true) {
cout << "Critical error! Calling exit()\n";
exit(1); // ❌ Негайне завершення програми
}
cout << "This will never execute\n";
return 0;
} // Деструктор ~Important() НЕ викличеться!
exit():- Локальні об'єкти не знищуються — деструктори не викликаються
- Ресурси не звільняються — файли не закриваються, пам'ять не звільняється
- Буфери не скидаються — дані можуть не записатися на диск
- З'єднання не закриваються — БД, мережеві сокети залишаються відкритими
Безпечна альтернатива: винятки або return
Замість exit() використовуйте:
int main()
{
try {
Important resource("Database");
if (errorCondition) {
throw runtime_error("Critical error");
}
return 0;
}
catch (const exception& e) {
cerr << "Error: " << e.what() << "\n";
return 1; // ✅ Нормальне завершення
}
}
// Деструктори викличуться коректно
int main()
{
Important resource("Database");
if (errorCondition) {
cerr << "Error occurred\n";
return 1; // ✅ Нормальне завершення
}
return 0;
}
// Деструктори викличуться коректно
У обох випадках відбувається нормальне розкручування стеку, деструктори викликаються, ресурси звільняються.
exit() допустимий?exit() може бути виправданим у дуже специфічних випадках:- Фонові демони/сервіси без критичних ресурсів
- Системне програмування з власним управлінням ресурсами
- Програми, які викликають
fork()та потрібно завершити дочірній процес
return або винятки.Візуалізація стану об'єкта при деструкції
Розглянемо, що відбувається з об'єктом IntArray під час деструкції:
int main()
{
IntArray numbers(3);
numbers.setValue(0, 10);
numbers.setValue(1, 20);
numbers.setValue(2, 30);
// Кінець main() — викликається ~IntArray()
}
Стан об'єкта перед деструкцією:
Object State — before destructor
| Name | Type | Value |
|---|---|---|
| ◢numbers.data | int* | 0x00007FF7A0001000 |
| ◢numbers.size | int | 3 |
| ◢numbers.data[0] | int | 10 |
| ◢numbers.data[1] | int | 20 |
| ◢numbers.data[2] | int | 30 |
Під час виконання деструктора:
~IntArray()
{
delete[] data; // Звільняємо динамічний масив
// data тепер вказує на звільнену пам'ять (dangling pointer)
}
Стан після деструкції:
- Сам об'єкт
numbers(поляdataіsize) ще існує в пам'яті стеку - Але
dataвказує на звільнену пам'ять — це небезпечний dangling pointer - Масив у купі звільнений — пам'ять повернена операційній системі
- Одразу після деструктора пам'ять для самого об'єкта
numbersтакож звільняється
Практичне завдання: клас DynamicString
Застосуємо отримані знання, створивши власний клас для роботи з динамічними рядками.
Створіть клас DynamicString, який зберігає рядок у динамічній пам'яті та автоматично управляє нею через RAII.
Вимоги:
Поля (приватні):
buffer(типchar*) — покажчик на динамічний масив символівlength(типint) — довжина рядка (без\0)
Конструктори (публічні):
- Конструктор з параметром
const char*— копіює C-style рядок у динамічну пам'ять - Конструктор за замовчуванням — створює порожній рядок
Деструктор (публічний):
- Звільняє динамічну пам'ять через
delete[]
Методи (публічні):
getLength() const— повертає довжину рядкаprint() const— виводить рядок на екран
Важливо:
- Використовуйте функції
strlen()таstrcpy()з<cstring> - Не забудьте виділити
length + 1байт для\0наприкінці - Додайте вивід у конструкторі та деструкторі для спостереження за RAII
Тестовий код:
int main()
{
cout << "Creating string1\n";
DynamicString string1("Hello, RAII!");
string1.print();
cout << "Length: " << string1.getLength() << "\n\n";
{
cout << "Creating string2 in nested scope\n";
DynamicString string2("Temporary");
string2.print();
cout << "Exiting nested scope\n";
}
cout << "\nEnd of main\n";
return 0;
}
Очікуваний вивід:
Creating string1
Constructor called for: "Hello, RAII!"
Hello, RAII!
Length: 12
Creating string2 in nested scope
Constructor called for: "Temporary"
Temporary
Exiting nested scope
Destructor called for: "Temporary"
End of main
Destructor called for: "Hello, RAII!"
#include <iostream>
#include <cstring> // Для strlen, strcpy
using namespace std;
class DynamicString
{
private:
char* buffer;
int length;
public:
// Конструктор з параметром
DynamicString(const char* text)
{
length = strlen(text);
buffer = new char[length + 1]; // +1 для '\0'
strcpy(buffer, text);
cout << "Constructor called for: \"" << buffer << "\"\n";
}
// Конструктор за замовчуванням
DynamicString() : buffer(nullptr), length(0)
{
buffer = new char[1];
buffer[0] = '\0';
cout << "Default constructor called\n";
}
// Деструктор
~DynamicString()
{
cout << "Destructor called for: \"" << buffer << "\"\n";
delete[] buffer;
}
int getLength() const
{
return length;
}
void print() const
{
cout << buffer << "\n";
}
};
int main()
{
cout << "Creating string1\n";
DynamicString string1("Hello, RAII!");
string1.print();
cout << "Length: " << string1.getLength() << "\n\n";
{
cout << "Creating string2 in nested scope\n";
DynamicString string2("Temporary");
string2.print();
cout << "Exiting nested scope\n";
}
cout << "\nEnd of main\n";
return 0;
}
Аналіз рішення:
- Конструктор виділяє
length + 1байт (потрібен додатковий байт для нульового символу\0) strcpyкопіює рядок у виділену пам'ять, включаючи\0- Деструктор звільняє пам'ять через
delete[]— критично важливо для масивів - Об'єкт
string2автоматично знищується при виході з вкладеного блоку - Об'єкт
string1знищується наприкінціmain()
Резюме та ключові висновки
Деструктори та ідіома RAII — фундаментальні механізми C++, що забезпечують автоматичне та безпечне управління ресурсами.
🔧 Деструктори
- Синтаксис:
~ClassName()— ім'я класу з тильдою на початку - Немає параметрів і типу повернення
- Один деструктор на клас — немає перевантаження
- Автоматичний виклик: при виході з області видимості або
delete - Порядок: зворотний до конструкторів (LIFO)
🎯 Ідіома RAII
- Захоплення ресурсу в конструкторі
- Звільнення ресурсу в деструкторі
- Час життя ресурсу = час життя об'єкта
- Безпека винятків — автоматичне очищення при розкручуванні стеку
- Приклади:
std::string,std::vector,std::fstream, власні класи
⚠️ Пастки та застереження
exit()пропускає деструктори — уникайте його- Динамічні об'єкти потребують явного
delete— краще використовувати стек - Подвійне видалення — не викликайте
deleteдвічі для одного покажчика - Dangling pointers — не використовуйте об'єкт після деструкції
🔜 Що далі
У наступній статті ми розглянемо прихований покажчик this — механізм, через який методи знають, до якого об'єкта вони належать. Ви дізнаєтесь:
- Що таке
thisі як він працює під капотом - Явне використання
this->для розв'язання конфліктів імен - Method chaining через
return *this - Використання
thisв конструкторах та деструкторах
- Завжди пишіть деструктор, якщо клас керує ресурсом (пам'ять, файл, з'єднання)
- Слідуйте RAII: захоплюйте у конструкторі, звільняйте у деструкторі
- Уникайте динамічних об'єктів з ресурсами — використовуйте стек
- Не викликайте
exit()— використовуйтеreturnабо винятки - Для масивів використовуйте
delete[], а неdelete - Не викликайте деструктор вручну — компілятор зробить це автоматично
Списки ініціалізації та делегуючі конструктори
Member Initializer List — ефективний спосіб ініціалізації полів класу. Обовязкові випадки використання: const-поля, посилання, вкладені обєкти. Порядок ініціалізації та делегуючі конструктори C++11.
Прихований покажчик this
Як методи класу знають, до якого обєкта вони належать? Прихований покажчик this, його роль у викликах методів. Явне використання this для розвязання конфліктів імен. Method chaining через return *this.