Що ви зможете пояснити
Після опрацювання матеріалу ви зможете розрізнити об’єкт і його адресу, простежити час життя об’єкта та пояснити, хто відповідає за звільнення ресурсу. Лабораторна робота допоможе перевірити ці ідеї у виконуваній програмі.
Інтуїція: ресурс потрібно повернути
Уявіть, що ви взяли книжку в бібліотеці. Поки книжка у вас, ви відповідаєте за її повернення. Якщо передати відповідальність іншій людині, треба однозначно домовитися, хто поверне книжку. З ресурсами програми — пам’яттю, файлами чи мережевими з’єднаннями — виникає та сама проблема.
Об’єкт і змінна
Клас описує власний тип: його дані та операції. Екземпляр класу — конкретний об’єкт цього типу. Два екземпляри одного класу можуть містити різні значення. Метод — функція, що описує операцію класу.
У прикладі std::cout — стандартний потік виводу, << передає йому значення, а \n позначає новий рядок. Заголовок <iostream> містить потрібні оголошення.
#include <iostream>
class Counter {
public:
void increment() { ++value_; } // Змінюємо стан об’єкта.
int value() const { return value_; } // Читаємо стан без змін.
private:
int value_ = 0; // Кожен об’єкт має власне значення.
};
int main() {
Counter first;
Counter second;
first.increment();
std::cout << first.value() << " " << second.value() << "\n";
}
Результат: 1 0. Поле value_ належить кожному об’єкту окремо. private обмежує доступ до поля, а public відкриває методи для використання. Метод із const після дужок не змінює звичайні поля об’єкта. Це інкапсуляція: керований доступ до внутрішнього стану через операції типу.
Час життя: коли об’єкт існує
Lifetime (час життя) — період, коли об’єкт існує й може коректно використовуватися. Область видимості визначає, де в коді доступне ім’я. Ці поняття пов’язані, але не тотожні.
Конструктор і деструктор
Конструктор ініціалізує об’єкт. Деструктор виконується при завершенні його часу життя та дає можливість звільнити ресурси. Назва деструктора починається з ~.
#include <iostream>
class Trace {
public:
Trace() { std::cout << "Створено\n"; }
~Trace() { std::cout << "Знищено\n"; }
};
int main() {
std::cout << "До блоку\n";
{
Trace item; // Об’єкт існує всередині цього блоку.
std::cout << "У блоці\n";
} // Тут викликається деструктор item.
std::cout << "Після блоку\n";
}
- Крок 1 із 3
Вхід у блок
Для
itemдоступне зберігання. Конструктор ініціалізує об’єкт і друкує «Створено». - Крок 2 із 3
Робота з об’єктом
Програма друкує «У блоці». Об’єкт залишається живим до виходу з блоку.
- Крок 3 із 3
Вихід із блоку
Викликається деструктор. Після «Знищено» програма друкує «Після блоку». Звертатися до колишнього об’єкта вже не можна.
Стек і купа: модель пам’яті
Stack (стек) — поширена реалізація зберігання локальних даних викликів функцій. Heap (купа) — поширена назва області для динамічного виділення пам’яті. Стандарт C++ описує тривалість зберігання, а не вимагає конкретного фізичного розташування всіх об’єктів у стеку чи купі.
Counter item; у функції має автоматичну тривалість зберігання: об’єкт знищується при виході з відповідного блоку. Типово це пов’язують зі стеком.std::make_unique<Counter>() створює об’єкт із динамічною тривалістю зберігання. Його час життя керується власником, а не областю видимості імені самого об’єкта.owner — локальний об’єкт-власник. Він зберігає адресу Counter і відповідає за його знищення.
Володіння та RAII
Ownership (володіння) означає відповідальність за час життя ресурсу. У прикладі std::unique_ptr — розумний вказівник з одним власником. Його не можна звичайно скопіювати; відповідальність можна передати через переміщення.
#include <iostream>
#include <memory> // std::unique_ptr, std::make_unique
#include <utility> // std::move
int main() {
auto owner = std::make_unique<int>(42); // Єдиний власник числа.
auto next = std::move(owner); // Передаємо володіння.
if (!owner) { // Після передачі unique_ptr порожній.
std::cout << "Володіння передано\n";
}
std::cout << *next << "\n"; // * читає об’єкт за адресою.
} // next автоматично звільняє керований об’єкт.
Що тут відбувається?
<memory>оголошує інструменти керування пам’яттю, а<utility>—std::move.autoпросить компілятор визначити тип зі значення справа: тут цеstd::unique_ptr<int>.make_unique<int>(42)створює число 42 і повертає його власника. Не потрібно окремо писатиnewіdelete.std::moveдозволяє обрати операцію переміщення. Сам по собі він нічого не пересилає; конструкторunique_ptrпередає адресу та обнуляє попереднього власника.- Перед розіменуванням перевіряйте, що вказівник непорожній. Для
nextце випливає з успішного створення та передачі. - При виході з
mainдеструкторnextзвільняє ресурс. Деструктор порожньогоownerнічого не видаляє.
Невдалий і кращий варіанти
✕ Невдалий варіант
// Фрагмент: раннє повернення спричиняє витік.
int* value = new int(42);
if (cancelled) return;
consume(*value);
delete value;✓ Кращий варіант
// Фрагмент: власник звільнить пам’ять на кожному виході.
auto value = std::make_unique<int>(42);
if (cancelled) return;
consume(*value);Чому другий варіант кращий?
Володіння видно з типу; не потрібно перевіряти кожну гілку на наявність delete. Це полегшує читання та підтримку, а також запобігає витоку при ранньому поверненні чи винятку. Ці фрагменти демонструють ідею: cancelled і consume належать навколишньому коду. Якщо динамічна пам’ять не потрібна, звичайний int value = 42; буде ще простішим. RAII не замінює тестування і сам по собі не доводить кращої швидкодії.
Граничні випадки й типові помилки
Порожній власник
std::unique_ptr<int> empty; не володіє об’єктом. Його можна знищити, але не можна розіменовувати: спочатку перевірте if (empty).Два власники однієї адреси
unique_ptr з одного сирого вказівника. Обидва спробують видалити той самий об’єкт, що призведе до невизначеної поведінки.Передача володіння
unique_ptr попередній власник порожній. Для інших типів стан після переміщення залежить від їхнього контракту: не узагальнюйте правило порожнього вказівника на всі типи.Виняток
throw. При розкручуванні стека деструктори вже створених локальних об’єктів звільняють їхні ресурси. Це не гарантує очищення при аварійному завершенні процесу.Професійні практики
Правило нуля та архітектура
Rule of zero (правило нуля): якщо поля вже коректно керують власними ресурсами, класу часто не потрібно вручну визначати деструктор, копіювання та переміщення. Наприклад, std::string володіє своїми символами, а std::vector — елементами. Водночас поле unique_ptr робить клас некопійованим за замовчуванням. Це рішення про семантику типу, а не просто синтаксис.
Спільне володіння через std::shared_ptr має сенс лише за реальної спільної відповідальності. Лічильник посилань не усуває логічних циклів і не забезпечує потокобезпечність самого об’єкта. Контракт функції має пояснювати, чи вона позичає доступ, отримує значення або приймає володіння.
Перевірка розуміння
01 Чи дорівнює область видимості часу життя?Показати відповідь
Ні. Область видимості стосується доступності імені, а час життя — існування об’єкта. Динамічний об’єкт може пережити блок, у якому його створили.
02 Яка основна ідея RAII?Показати відповідь
Прив’язати ресурс до часу життя об’єкта: завершення життя власника запускає звільнення ресурсу.
03 Чому копія адреси не є копією об’єкта?Показати відповідь
Адреса лише вказує на те саме місце зберігання. Для нового об’єкта потрібна окрема операція створення або копіювання значення.
Чи потрібен unique_ptr для кожного об’єкта?
Джерела для подальшого читання
Джерела англійською мовою. Основні терміни пояснено в цій лекції.