JavaRush /Курси /C++ SELF /Правило modern C++: уникаємо

Правило modern C++: уникаємо delete у прикладному коді

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

1. delete у прикладному коді — символ проблеми

Якщо ви новачок, delete часто здається чимось на кшталт «дорослої магії»: мовляв, ось зараз я вручну керую памʼяттю, отже, я справжній програміст на C++. На практиці все навпаки. Що частіше в прикладному коді зʼявляється delete, то вища ймовірність, що програма падатиме «десь в іншій частині», «інколи», «після пʼятого запуску» або «лише на компʼютері викладача». І річ не в тому, що C++ шкідливий, а в тому, що ручне керування ресурсами вимагає залізної дисципліни й дуже чітких контрактів володіння.

Варто уточнити, що таке «прикладний код». Це ваш код бізнес-логіки: обробка команд, зберігання задач, обчислення статистики, форматування виводу, робота з моделями даних. Це не низькорівневий фрагмент стандартної бібліотеки й не окрема «системна» підсистема, спеціально призначена для роботи з ресурсами. У прикладному коді ви хочете думати про задачі, а не про те, хто мав викликати delete і чи не забув він це зробити.

До речі, вимоги до оголошень operator delete змінювалися між стандартами, і в сучасному C++ він має бути noexcept. Це лише підсилює головну думку: навіть «нутрощі мови» працюють за строгими контрактами, а в прикладному коді підтримувати такі контракти вручну важко й ризиковано.

Правило дня: якщо ви пишете delete, зупиніться

Це правило звучить трохи пафосно, але воно дуже практичне. Коли рука тягнеться написати delete, корисно зробити коротку паузу й подумки поставити собі два запитання.

Перше: «Чому в мене взагалі є сирий T*, який потрібно звільняти вручну?»

Друге: «Чому звільнення ресурсу не зосереджене в одному-єдиному місці, яке гарантовано спрацює?»

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

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

2. Стратегії: як жити без delete

Не виділяйте памʼять вручну: std::vector замість new[]

Дуже типова історія новачка виглядає так: «Мені потрібен список задач, але його розмір наперед невідомий, отже, треба new». Така логіка зрозуміла, але в сучасному C++ вона зазвичай хибна з погляду вибору інструмента. Якщо вам потрібен «масив змінного розміру», то майже завжди це std::vector, а не new[].

Давайте продовжимо наш навчальний консольний застосунок — нехай це буде TaskBook: проста програма, яка зберігає задачі й друкує їх. Раніше ми могли б спокійно тримати задачі в std::vector<Task>, і це вже був би правильний шлях у modern C++. Але спеціально змоделюймо «поганий поворот»: хтось вирішив зберігати задачі як вказівники, бо «так гнучкіше».

Поганий варіант: std::vector<Task*> і ручний delete

Одразу важливе уточнення: приклад нижче — це ілюстрація дизайну, а не те, що ми хочемо повторювати.

#include <string>
#include <vector>

struct Task {
    int id{};
    std::string title;
};

int main() {
    std::vector<Task*> tasks;
    tasks.push_back(new Task{1, "Почитати про RAII"});
    // ... а потім десь усе потрібно видалити ...
}

Проблема навіть не в тому, що тут поки немає delete. Проблема в тому, що ви все одно мусите десь його написати — і зробити це правильно: видалити кожен елемент рівно один раз, нічого не забути, не видалити чуже й не видалити двічі.

Хороший варіант: std::vector<Task> — володіння за значенням

#include <string>
#include <vector>

struct Task {
    int id{};
    std::string title;
};

int main() {
    std::vector<Task> tasks;
    tasks.push_back(Task{1, "Почитати про RAII"});
}

Тут немає ані new, ані delete. І це не «спрощення для новачків», а саме стиль сучасного C++: контейнер володіє памʼяттю, сам розширюється й сам звільняє все в деструкторі.

Щоб ця ідея стала ще практичнішою, корисно тримати під рукою коротку таблицю: «що потрібно» → «що в modern C++ зазвичай варто взяти»:

Що вам потрібно в задачі Ідея новачка Рішення в modern C++
Масив змінної довжини
new T[n]
std::vector<T>
Рядок змінної довжини
new char[n]
std::string
«Може бути значення, а може й ні» -1, nullptr, «магія»
std::optional<T>
Тимчасовий буфер символів char* + delete[] std::vector<char> або std::string

Зробіть володіння очевидним: «сирі» вказівники не мають володіти

Одна з головних причин, чому delete такий небезпечний у прикладному коді, — за типом T* зазвичай неясно, хто власник. Вказівник — це лише адреса. Він чудово підходить для ситуацій «подивися туди» або «може, там щось є», але зовсім не підходить на роль «я відповідаю за звільнення».

Домовімося про простий, але дуже практичний поділ сенсів.

Посилання T& зазвичай означає: «обʼєкт точно існує, він не null, я просто працюю з ним».

Вказівник T* зазвичай означає: «обʼєкт може бути відсутнім (nullptr), я працюю з ним, але не обовʼязково є його власником».

І дуже важливо: за замовчуванням параметр типу T* не має означати «забери володіння і видали».

Подивімося на приклад із TaskBook: ми хочемо знайти задачу за id. Якщо дані зберігаються за значенням (std::vector<Task>), то можна повернути вказівник на елемент як «невласницьке посилання», тобто зі змістом «подивися, ось вона, якщо знайшлася».

#include <vector>
#include <string>

struct Task { int id{}; std::string title; };

Task* find_task_by_id(std::vector<Task>& tasks, int id) {
    for (auto& t : tasks) {
        if (t.id == id) return &t;
    }
    return nullptr;
}

Тут Task* — це не про володіння, а про «може не знайтися». І це нормально. Проблема починається тоді, коли хтось у коді вирішує: «О, Task*, значить, треба видалити». Ні. Цей вказівник указує на елемент усередині std::vector, тож видаляти його не можна — та й не потрібно.

Щоб закріпити цей контракт, можна зробити функцію друку, яка нічого не видаляє й просто коректно обробляє nullable-параметр:

#include <iostream>

void print_task(const Task* t) {
    if (t == nullptr) {
        std::cout << "Задачу не знайдено\n";
        return;
    }
    std::cout << t->id << ": " << t->title << '\n';
}

Зверніть увагу на стиль: ми прямо показуємо, що nullptr — допустимий сценарій. Це і є nullable-дизайн без володіння.

Повертайте результати за значенням

Новачки часто починають писати функції, які повертають вказівник, бо «це ж C++, отже, так і треба». На практиці повертати T* із функції майже завжди означає створювати контракт «вгадай, хто потім робить delete».

Порівняймо два підходи.

Поганий контракт: функція створює new і повертає T*

#include <string>

struct Task { int id{}; std::string title; };

Task* create_task_bad(int id, const std::string& title) {
    return new Task{id, title}; // за типом не видно, хто має зробити delete
}

Якщо ви бачите такий код у прикладному проєкті, це майже гарантований витік памʼяті або double-free в майбутньому. Просто тому, що за тиждень ви забудете, хто кому що винен.

Хороший контракт: функція повертає обʼєкт за значенням

#include <string>

struct Task { int id{}; std::string title; };

Task create_task(int id, const std::string& title) {
    return Task{id, title};
}

Тепер у коді використання все стає значно спокійнішим:

#include <vector>

int main() {
    std::vector<Task> tasks;
    tasks.push_back(create_task(1, "Зробити чай"));
}

Жодних new, жодних delete. Володіння живе там, де живуть дані: у tasks.

Ховайте delete в RAII-власника й не випускайте назовні

Бувають випадки, коли з навчальних причин або через особливий формат задачі ви все-таки змушені щось виділяти через new. Наприклад, ви пишете навчальний буфер або мусите взаємодіяти з API, яке повертає «сирий» ресурс. У межах сьогоднішньої теми ми не перелічуватимемо всі можливі готові власники, а зосередимося на одній думці: якщо вже delete зʼявився, то йому не місце в бізнес-логіці — він має бути всередині власника.

Зробімо невелику RAII-обгортку для масиву int, щоб відчути сам принцип. Так, у реальному житті ви частіше взяли б std::vector<int>, але нам важливо побачити саме цей прийом.

struct IntBuffer {
    int* data{nullptr};

    ~IntBuffer() {
        delete[] data;
    }
};

Тепер у прикладному коді можна користуватися буфером без явного delete[]:

#include <iostream>

int main() {
    IntBuffer buf{new int[3]{10, 20, 30}};
    std::cout << buf.data[1] << '\n'; // 20
}

Зверніть увагу, як це змінює «психологію коду». Ви вже не думаєте: «Де б мені не забути delete[]?». Натомість думаєте інакше: «Буфер живе стільки, скільки живе обʼєкт buf». Це і є RAII.

Тут є тонкий момент, який поки що проговоримо обережно, без занурення в майбутні теми. Таких власників не можна бездумно копіювати, інакше ви отримаєте двох власників однієї адреси й знову потрапите в double-free. Тому на поточному етапі тримайтеся простого правила: RAII-власника тримаємо локально й передаємо за посиланням, не намагаючись копіювати його «як число».

Тримайте одну точку відповідальності: delete не має жити у гілках

Навіть якщо ви тимчасово пишете код із ручним керуванням памʼяттю, наприклад у навчальних експериментах, є дуже показова ознака проблеми: якщо у вас delete стоїть у кількох місцях функції, особливо в різних гілках if і перед різними return, то код уже став крихким.

Порівняймо два стилі.

Крихкий стиль: delete у кожній гілці

#include <iostream>

int main() {
    int* p = new int{5};

    bool ok = false;
    if (!ok) {
        delete p;
        return 0;
    }

    std::cout << *p << '\n';
    delete p;
}

Формально цей код можна написати коректно, але це радше вправа на уважність, ніж на здоровий глузд. Кожна нова гілка — це новий шанс щось забути.

Спокійний стиль: звільнення робить власник у деструкторі

#include <iostream>

struct IntOwner {
    int* p{nullptr};
    ~IntOwner() { delete p; }
};

int main() {
    IntOwner x{new int{5}};

    bool ok = false;
    if (!ok) return 0;

    std::cout << *x.p << '\n';
}

Тепер гілок if може бути скільки завгодно, а звільнення не розмазується по коду: воно все одно станеться один раз і в одному місці — у деструкторі власника.

Мінісхема ухвалення рішень без delete

Коли ви проєктуєте фрагмент коду й раптом ловите себе на думці: «А де б мені тут написати delete?», корисно мати в голові коротку «блок-схему». Вона не чарівна, але добре повертає на рейки modern C++.

flowchart TD
    A["Потрібен ресурс / памʼять змінного розміру"] --> B{"Можна зберігати за значенням?"}
    B -->|Так| C["Використовуємо std::string / std::vector / struct"]
    B -->|Ні| D{"Чи можна сховати ресурс у RAII-власнику?"}
    D -->|Так| E["Пишемо невеликий обʼєкт-власник із деструктором"]
    D -->|Ні| F["Переглядаємо дизайн: де проходить межа відповідальності?"]
    C --> G["У прикладному коді немає delete"]
    E --> G

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

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

Помилка № 1: «Я використовую new, бо розмір невідомий».
Це одна з найпоширеніших причин, чому delete зʼявляється там, де йому не місце. Невідомий розмір — не привід іти в new[], а підстава взяти std::vector. Вектор якраз і існує для того, щоб ви не писали код на кшталт «виділив → скопіював → не забув видалити старе». У прикладному коді динамічний масив майже завжди має бути вектором.

Помилка № 2: повертати T* із функції як «результат», не пояснюючи володіння.
Такий інтерфейс змушує того, хто викликає функцію, вгадувати: «Мені потрібно це видаляти чи ні?». Якщо потрібно — де написано, коли і чим? Якщо не потрібно — то чому тоді взагалі вказівник? Набагато безпечніше повертати за значенням (T, std::string, std::vector<T>) або використовувати std::optional<T> там, де результат може бути відсутнім.

Помилка № 3: зберігати «власницькі» вказівники в контейнері без чіткого правила очищення.
std::vector<T*> сам собою не є поганим типом, але для новачка в прикладній задачі це майже завжди погана ідея: ви мусите написати очищення, памʼятати про нього в усіх сценаріях виходу й не допустити подвійного звільнення. У навчальних застосунках майже завжди можна замінити це на std::vector<T> і викреслити цілий клас проблем.

Помилка № 4: вважати, що «після delete вказівник став nullptr».
Після delete p; змінна p далі зберігає стару адресу, просто ця адреса вже не вказує на живий обʼєкт. Це dangling pointer. Обнуляти p = nullptr; корисно як дисципліну, але в modern C++ правильніше не доводити до ситуації, де ви вручну обнуляєте вказівники по всьому коду, а зберігати ресурс усередині RAII-власника або контейнера.

Помилка № 5: розмазувати delete по гілках і «лагодити» витоки, додаючи ще один delete у новому місці.
Це типовий шлях до double-free: спочатку ви забули видалити в одній гілці, потім «додали видалення», але не помітили, що в іншій гілці воно вже було. Якщо ви ловите себе на такому ремонті, правильний крок — зупинитися й перенести звільнення в деструктор власника, щоб воно було зосереджене в одній точці відповідальності.

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