JavaRush /Курси /C++ SELF /Lifetime і destructors: коли викликається деструктор

Lifetime і destructors: коли викликається деструктор

C++ SELF
Рівень 38 , Лекція 3
Відкрита

1. Час життя і деструктор: базова ідея

Коли ви лише починаєте, може здаватися, що програма — це просто послідовність рядків, а змінні — «комірки», які живуть десь «поки не набридне». Але C++ — мова сувора: кожен обʼєкт має час життя (lifetime). І коли цей час минає, мова зобовʼязана за звичайного завершення виконати спеціальні дії: викликати деструктор.

Чому це важливо на практиці? Тому що майже всі зручності стандартної бібліотеки побудовані саме на цьому механізмі. Рядки звільняють буфер, вектори — памʼять для елементів, файли закриваються (про це поговоримо пізніше), а ваші власні типи теж можуть «прибирати за собою». У стандарті C++ тему часу життя обʼєктів виділено як окрему важливу частину. Її часто обговорюють у контексті [basic.life].

Lifetime: де він починається і де закінчується

Якщо говорити простими словами, lifetime обʼєкта — це проміжок від моменту, коли обʼєкт створено, до моменту, коли його знищено. Тут корисно розрізняти дві схожі, але не тотожні ідеї: scope (де видно імʼя) і lifetime (чи існує сам обʼєкт). Найчастіше для локальних змінних ці речі збігаються: увійшли в блок — обʼєкт зʼявився, вийшли з блоку — обʼєкт зник.

Важливо добре відчути цю інтуїцію: фраза «я востаннє використав змінну» не є сигналом для C++. Компілятор не думає так: «О, змінну s більше не використовують, тож приберімо її просто зараз». Він мислить простіше: «Локальний обʼєкт живе до кінця блоку. Крапка».

Нижче — невелика «мапа часу життя» для локального обʼєкта:

flowchart LR
    A["Вхід у блок {"] --> B["Створення обʼєкта (ініціалізація)"]
    B --> C["Використання обʼєкта"]
    C --> D["Вихід із блоку }"]
    D --> E["Виклик деструктора ~Type()"]

Деструктор: що це за функція і чому вона викликається «сама»

Деструктор — це спеціальна функція вигляду ~Type(), яку C++ викликає автоматично, коли завершується lifetime обʼєкта. Навіть якщо ви самі не писали деструктор, він зазвичай існує «за замовчуванням»: компілятор може згенерувати його за вас. Поки що нам не потрібні всі тонкощі цих правил. Достатньо розуміти головне: обʼєкт має механізм знищення.

Головна думка цієї лекції така: виклик деструктора привʼязаний до завершення часу життя, а не до вашої команди «знищся». Тому новачкам корисно хоча б раз побачити це наочно. Зараз ми створимо тип, який друкує повідомлення під час створення і знищення.

#include <iostream>
#include <string>

struct Tracer {
    std::string name;

    Tracer(const std::string& n) : name(n) {
        std::cout << "create " << name << '\n';
    }
    ~Tracer() {
        std::cout << "destroy " << name << '\n';
    }
};

int main() {
    Tracer t("X");
    std::cout << "inside main\n";
}
// create X
// inside main
// destroy X

Зверніть увагу на характерну деталь: destroy X друкується в самому кінці, на виході з main. Ми ніде не писали «виклич деструктор» — він спрацював автоматично.

2. Локальні обʼєкти і вихід зі scope

Локальні обʼєкти: деструктор викликається на }

Локальні змінні всередині функцій і блоків {} зазвичай мають automatic storage duration. Це означає, що обʼєкт створюється під час входу в блок і знищується під час виходу з нього. Для таких обʼєктів відповідь на запитання «коли викликається деструктор?» майже завжди звучить так: на закривній фігурній дужці.

Перевірмо це на вкладеному блоці. Це дуже зручний прийом, якщо ви хочете свідомо скоротити lifetime «важкого» обʼєкта: створили, використали й знищили якомога раніше, не чекаючи кінця функції.

#include <iostream>
#include <string>

struct Tracer {
    std::string name;
    Tracer(const std::string& n) : name(n) { std::cout << "create " << name << '\n'; }
    ~Tracer() { std::cout << "destroy " << name << '\n'; }
};

int main() {
    std::cout << "A\n";
    { Tracer t("inner"); std::cout << "B\n"; }
    std::cout << "C\n";
}
// A
// create inner
// B
// destroy inner
// C

Подивіться на порядок: destroy inner друкується між "B" і "C", тому що ми вийшли з блоку. Ось як на практиці працює правило: «деструктор на }».

Ранній вихід: return теж завершує lifetime

Дуже поширена помилка новачка — думати, що return «вистрибує» з функції, наче телепорт, і «пропускає прибирання». Насправді return лише означає: «ми залишаємо scope функції». А якщо ми залишаємо scope, то всі локальні обʼєкти з automatic storage duration мають бути коректно знищені, і їхні деструктори мають бути викликані.

Для нормального C++ це критично. Інакше std::string, std::vector і половина стандартної бібліотеки були б просто марними: памʼять «витікала» б за кожного раннього виходу.

#include <iostream>
#include <string>

struct Tracer {
    std::string name;
    Tracer(const std::string& n) : name(n) { std::cout << "create " << name << '\n'; }
    ~Tracer() { std::cout << "destroy " << name << '\n'; }
};

void f(bool early) {
    Tracer t("in f");
    if (early) return;
    std::cout << "doing work\n";
}

int main() {
    f(true);
    f(false);
}
// create in f
// destroy in f
// create in f
// doing work
// destroy in f

Зверніть увагу: деструктор destroy in f викликається в обох випадках. Просто в першому випадку це стається раніше.

Порядок знищення: оголосили останнім — знищився першим

Тепер додамо ще одну деталь, про яку часто згадують, коли зʼявляються неочікувані баги: порядок руйнування обʼєктів в одному scope — зворотний до порядку їх оголошення. Це дуже схоже на стек: останнє поклали — перше дістали. Такий порядок дає змогу коректно руйнувати залежності: умовно кажучи, спершу закрити те, що залежить від чогось іншого.

Побачимо це наочно:

#include <iostream>
#include <string>

struct Tracer {
    std::string name;
    Tracer(const std::string& n) : name(n) { std::cout << "create " << name << '\n'; }
    ~Tracer() { std::cout << "destroy " << name << '\n'; }
};

int main() {
    Tracer a("A");
    Tracer b("B");
    std::cout << "end\n";
}
// create A
// create B
// end
// destroy B
// destroy A

Запамʼятайте просте правило: в одному блоці знищення йде знизу вгору, тобто у зворотному порядку оголошення. Пізніше це допоможе вам зрозуміти несподівані повідомлення в логах і поведінку складніших обʼєктів.

3. Складені обʼєкти і контейнери

Поля struct теж знищуються і теж у зворотному порядку

Коли у вас є struct із полями, ці поля теж є обʼєктами. А отже, вони теж мають lifetime. Коли завершується lifetime зовнішнього обʼєкта, C++ має зруйнувати і його поля.

Щоб відчути цю ідею без зайвої теорії, створімо struct, який містить два поля Tracer:

#include <iostream>
#include <string>

struct Tracer {
    std::string name;
    Tracer(const std::string& n) : name(n) { std::cout << "create " << name << '\n'; }
    ~Tracer() { std::cout << "destroy " << name << '\n'; }
};

struct Box {
    Tracer first{"first"};
    Tracer second{"second"};
};

int main() {
    Box b;
    std::cout << "done\n";
}
// create first
// create second
// done
// destroy second
// destroy first

Тут ми знову бачимо «зворотний порядок», але вже для полів: second знищився раніше за first. Це очікувано: поля знищуються у зворотному порядку їх оголошення в struct.

Контейнери: деструктор звільняє ресурси і знищує елементи

Зараз буде важливий момент, який повʼязує сьогоднішню лекцію з тим, що ви вже знаєте про std::vector і std::string. Контейнери — це власники ресурсів. Вектор володіє памʼяттю для елементів, рядок — буфером символів. І коли завершується lifetime контейнера, викликається його деструктор, а отже контейнер має коректно звільнити свої ресурси.

На нашому рівні достатньо розуміти це так: якщо std::vector<Tracer> є локальним обʼєктом, то на виході зі scope знищиться сам vector, а потім він знищить свої елементи і викличе їхні деструктори. Тобто ваші Tracer усередині вектора теж «приберуться».

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

struct Tracer {
    std::string name;
    Tracer(const std::string& n) : name(n) { std::cout << "create " << name << '\n'; }
    ~Tracer() { std::cout << "destroy " << name << '\n'; }
};

int main() {
    std::vector<Tracer> v;
    v.push_back(Tracer("one"));
    v.push_back(Tracer("two"));
    std::cout << "leaving scope\n";
}
// (повідомлення create/destroy залежать від оптимізацій, але наприкінці scope елементи буде знищено)

Важливе уточнення: точні проміжні повідомлення, особливо «зайві» створення і знищення тимчасових обʼєктів, можуть відрізнятися через оптимізації та особливості додавання у вектор. Але ключовий факт лишається незмінним: коли вектор знищується, його елементи теж знищуються.

Є й ще одна практична грань: елементи можуть знищитися раніше, якщо ви викликаєте clear(), erase() або resize() до меншого розміру. Але базова «опорна точка» для новачка — це саме деструктор контейнера на виході зі scope.

4. Довгоживучі обʼєкти і налагодження scope

static обʼєкти: живуть довго і знищуються в кінці програми

Тепер поговорімо про «довгоживучі» обʼєкти. Якщо обʼєкт має static storage duration, то в спрощеній моделі він живе протягом усієї програми: створюється один раз і знищується під час її завершення. Це стосується глобальних обʼєктів і static локальних змінних усередині функції.

З погляду головного запитання лекції — «коли насправді викликається деструктор?» — відповідь така: зазвичай наприкінці роботи програми, якщо вона завершується коректно.

Покажімо static локальний обʼєкт, який створюється один раз і переживає кілька викликів функції:

#include <iostream>
#include <string>

struct Tracer {
    std::string name;
    Tracer(const std::string& n) : name(n) { std::cout << "create " << name << '\n'; }
    ~Tracer() { std::cout << "destroy " << name << '\n'; }
};

void hits() {
    static Tracer t("static-in-hits");
    std::cout << "hits()\n";
}

int main() {
    hits();
    hits();
    std::cout << "main ends\n";
}
// create static-in-hits
// hits()
// hits()
// main ends
// destroy static-in-hits

Практичний висновок: static — це не «магічна памʼять», а просто інший режим часу життя. Він зручний, але потребує обережності: обʼєкт живе довго, і якщо в ньому зберігається стан, то цей стан теж живе довго.

Міні-інструмент: логер виходу зі scope

Зараз зробимо річ, яку ви під час налагодження і полюбите, і часом зненавидите: обʼєкт, що друкує повідомлення під час входу у функцію і під час виходу з неї. Це класичний трюк: ми використовуємо той факт, що деструктор викликається автоматично на виході зі scope, і перетворюємо його на «подію виходу».

Уявімо, що ми далі розвиваємо наш консольний міні-застосунок — скажімо, список завдань, де функції виконують зрозумілі кроки: додати завдання, показати завдання, порахувати статистику. Додамо «охоронця scope»:

#include <iostream>
#include <string>

struct ScopeLog {
    std::string name;

    ScopeLog(const std::string& n) : name(n) {
        std::cout << ">> enter " << name << '\n';
    }
    ~ScopeLog() {
        std::cout << "<< leave " << name << '\n';
    }
};

void print_tasks() {
    ScopeLog log("print_tasks");
    std::cout << "printing...\n";
}

int main() {
    print_tasks();
}
// >> enter print_tasks
// printing...
// << leave print_tasks

Тепер важлива думка: ми нічого не «викликаємо вручну» для << leave. Це повідомлення зʼявляється саме тому, що log — локальний обʼєкт, і на виході з print_tasks() завершується його lifetime.

Це чудовий спосіб «спіймати» моменти, коли ви неочікувано виходите з функції раніше, наприклад через return, і зрозуміти, які ділянки коду справді виконуються.

5. Типові помилки

Помилка № 1: очікувати, що обʼєкт знищиться «одразу після останнього використання».
Новачок часто дивиться на код очима людини: «ну я ж більше не звертаюся до s після цього рядка, отже s має зникнути». У C++ так мислити небезпечно: деструктор для локального обʼєкта зазвичай викликається на межі scope, тобто на }. Якщо потрібно обмежити час життя раніше, зробіть вкладений блок і створіть обʼєкт усередині нього.

Помилка № 2: плутати «імʼя не видно» і «обʼєкта не існує».
Буває й зворотна плутанина: студент упевнений, що якщо змінну не видно з поточного рядка, то її вже знищено. Насправді «не видно» означає лише те, що імʼя перебуває поза scope. А от чи існує обʼєкт — це вже питання lifetime, і найчастіше воно привʼязане до конкретного блоку та його меж.

Помилка № 3: не враховувати зворотний порядок руйнування і дивуватися логам.
Коли ви бачите destroy B, а потім destroy A, перша реакція іноді така: «компʼютер щось переплутав». Насправді ні: в одному scope знищення йде у зворотному порядку оголошення. Якщо ваші обʼєкти логічно залежать один від одного, порядок оголошення стає важливим, бо на виході зі scope «розбирання» піде у зворотному напрямку.

Помилка № 4: думати, що static поводиться «як звичайна локальна змінна».
static локальна змінна виглядає як локальна, але живе як глобальна: створюється один раз і знищується наприкінці програми. Це легко перетворюється на «прихований стан», який неочікувано впливає на поведінку функції під час повторних викликів. Якщо ви використовуєте static, майте на увазі: це рішення про довге життя обʼєкта.

Помилка № 5: вважати, що контейнер «сам усе очистить, коли я перестану ним користуватися», але забувати про scope.
Так, std::vector і std::string коректно звільняють ресурси в деструкторі. Але деструктор буде викликано лише тоді, коли завершиться lifetime контейнера. Якщо ви тримаєте контейнер надто довго, наприклад оголосили його занадто високо у функції, памʼять теж утримуватиметься довше. Це не вада контейнера — це ваш вибір часу життя.

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