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 контейнера. Якщо ви тримаєте контейнер надто довго, наприклад оголосили його занадто високо у функції, памʼять теж утримуватиметься довше. Це не вада контейнера — це ваш вибір часу життя.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ