C++

Деструктори та ідіома RAII

Деструктори — спеціальні методи для очищення ресурсів при знищенні обєкта. Автоматичний виклик, порядок виконання, ідіома 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()!
g++ IntArray.cpp -o array && ./array
$ ./array
Constructor: Allocating array of size 5
numbers[0] = 10
numbers[1] = 20
End of main()
Destructor: Deallocating array

Зверніть увагу на порядок виводу: деструктор викликається останнім, після завершення всього коду 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 виходить з області видимості — деструктор викликається тут
g++ Demo.cpp -o demo && ./demo
$ ./demo
Start of main()
Constructing Demo 1
Constructing Demo 2
Inside nested block
Destructing Demo 2
After nested block
Destructing Demo 1

Об'єкт 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;
}
g++ Demo.cpp -o demo && ./demo
$ ./demo
Start of main()
Constructing Demo 10
Constructing Demo 20
Before delete
Destructing Demo 20
After deleting ptr2
Destructing Demo 10
End of main()
Критична помилка: Якщо забути викликати delete для динамічно створеного об'єкта, деструктор не викличеться, і ресурси не звільняться:
Demo* leak = new Demo(99);
// ❌ Забули викликати delete leak;
// Деструктор НЕ викличеться — витік пам'яті!
Саме тому динамічні об'єкти з ресурсами не рекомендуються — легко забути про delete.

Порядок виклику конструкторів та деструкторів

Порядок виклику деструкторів — зворотний до порядку виклику конструкторів. Це логічно: об'єкт, створений останнім, знищується першим (принцип LIFOLast 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;
}
g++ Resource.cpp -o resource && ./resource
$ ./resource
main() started
[+] Resource 1 acquired
[+] Resource 2 acquired
[+] Resource 3 acquired
main() ending
[-] Resource 3 released
[-] Resource 2 released
[-] Resource 1 released

Порядок конструювання: 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;
}
g++ Car.cpp -o car && ./car
$ ./car
Creating car
Engine constructor: 150 HP
Car constructor: Toyota
End of main
Car destructor: Toyota
Engine destructor: 150 HP

Порядок конструювання:

  1. Спочатку конструюються поля-об'єкти (engine) — зліва направо в порядку оголошення
  2. Потім виконується тіло конструктора зовнішнього класу (Car)

Порядок деструкції — зворотний:

  1. Спочатку виконується тіло деструктора зовнішнього класу (~Car())
  2. Потім знищуються поля-об'єкти (~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;
}
g++ FileHandler.cpp -o file && ./file
$ ./file
Before processData()
File opened: data.txt
File closed: data.txt
After processData()

✅ Переваги RAII

  • Автоматичність — ресурси звільняються автоматично, не потрібно пам'ятати про delete, fclose(), тощо
  • Безпека винятків — навіть якщо виникне виняток, деструктор викличеться і ресурс звільниться
  • Немає витоків — неможливо забути звільнити ресурс
  • Простота використання — клієнтський код не турбується про управління ресурсами

🎯 Приклади RAII у стандартній бібліотеці

  • std::string — керує динамічною пам'яттю для рядка
  • std::vector — керує динамічним масивом
  • std::fstream — керує файловими дескрипторами
  • std::unique_ptr — керує динамічною пам'яттю (розглянемо пізніше)
  • std::lock_guard — керує мьютексами у багатопотоковому коді

Усі ці класи захоплюють ресурси в конструкторі та звільняють у деструкторі!

Золоте правило RAII:Якщо ваш клас керує ресурсом (пам'ять, файл, з'єднання, блокування), завжди:
  1. Виділяйте ресурс у конструкторі
  2. Звільняйте ресурс у деструкторі
  3. Не створюйте об'єкти класу динамічно — використовуйте стек
Це гарантує, що ресурси автоматично звільнятимуться при виході об'єкта з області видимості.

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) без додаткових зусиль.

Контраст із ручним управлінням:Без RAII довелося б писати так:
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;
}
Це багатослівно, схильне до помилок і дублювання. RAII вирішує це елегантно.

Небезпека функції 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() НЕ викличеться!
g++ Exit.cpp -o exit && ./exit
$ ./exit
Acquiring resource: Database Connection
Doing work...
Critical error! Calling exit()
# Деструктор НЕ викликався!
Критична проблема 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;  // ✅ Нормальне завершення
    }
}
// Деструктори викличуться коректно

У обох випадках відбувається нормальне розкручування стеку, деструктори викликаються, ресурси звільняються.

Коли 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
Filter
NameTypeValue
numbers.dataint*0x00007FF7A0001000
numbers.sizeint3
numbers.data[0]int10
numbers.data[1]int20
numbers.data[2]int30
Running
Process: 12842

Під час виконання деструктора:

~IntArray()
{
    delete[] data;  // Звільняємо динамічний масив
    // data тепер вказує на звільнену пам'ять (dangling pointer)
}

Стан після деструкції:

  • Сам об'єкт numbers (поля data і size) ще існує в пам'яті стеку
  • Але data вказує на звільнену пам'ять — це небезпечний dangling pointer
  • Масив у купі звільнений — пам'ять повернена операційній системі
  • Одразу після деструктора пам'ять для самого об'єкта numbers також звільняється
Ніколи не використовуйте об'єкт після виклику деструктора!Після деструкції об'єкт знаходиться у невизначеному стані. Будь-які звернення до його полів або методів — це невизначена поведінка (undefined behavior).

Практичне завдання: клас DynamicString

Застосуємо отримані знання, створивши власний клас для роботи з динамічними рядками.

Резюме та ключові висновки

Деструктори та ідіома 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 в конструкторах та деструкторах
Найкраща практика для початківців:
  1. Завжди пишіть деструктор, якщо клас керує ресурсом (пам'ять, файл, з'єднання)
  2. Слідуйте RAII: захоплюйте у конструкторі, звільняйте у деструкторі
  3. Уникайте динамічних об'єктів з ресурсами — використовуйте стек
  4. Не викликайте exit() — використовуйте return або винятки
  5. Для масивів використовуйте delete[], а не delete
  6. Не викликайте деструктор вручну — компілятор зробить це автоматично
Copyright © 2026