1. Навіщо потрібен =delete
Коли ви вперше бачите =delete, легко подумати: «Ага, це як delete p; — зараз будемо звільняти памʼять». І саме тут C++ охоче підставляє ніжку: це різні «delete», просто слово те саме. delete p; — це оператор звільнення динамічної памʼяті (і ми домовилися, що в прикладному коді намагаємося туди не лізти). А =delete — це спосіб сказати компілятору: «Функція є частиною інтерфейсу, але викликати її не можна».
Фактично, =delete — це спосіб вбудувати «червону кнопку» просто в тип: якщо хтось спробує зробити заборонену річ, проєкт навіть не збереться. І це чудово, бо компілятор виявляє помилку раніше, ніж користувач отримає segmentation fault (або ваш тімлід зустріне вас у коридорі).
Уявіть просту картину: функцію оголошено, вона бере участь у виборі перевантажень, але позначена як «видалена». Якщо код спробує її використати, компілятор видасть помилку на кшталт «use of deleted function».
Ця ідея важлива з двох причин. По-перше, ви можете забороняти не лише конструктори чи копіювання, а й звичайні функції та перевантаження. По-друге, видалена функція інколи дає зрозумілішу діагностику, ніж щось на кшталт «ой, у вас тут не сходяться типи, я вибрав дивне перевантаження — і тепер усе зламалося».
До речі, стандартна бібліотека теж активно використовує видалені функції, щоб заборонити беззмістовні або небезпечні операції. Наприклад, у деяких типів можуть бути видалені конструктори за замовчуванням. У чернетках стандарту C++ такі місця трапляються регулярно — можна натрапити, скажімо, на щось на кшталт atomic_ref() = delete; як на явну заборону певного сценарію використання.
2. Забороняємо створення «порожнього» обʼєкта
Типова проблема для новачків: ми створюємо тип, а потім раптом виявляємо, що «обʼєкт без даних» узагалі не має сенсу. Але мова за замовчуванням дозволяє написати T t{};. У результаті в застосунку зʼявляються сутності в дивному стані: наче обʼєкт є, а користуватися ним не можна — як «обліковий запис без логіна», тільки гірше, бо компілятор не знає про ваш життєвий досвід.
Якщо обʼєкт обовʼязково має містити значення, забороніть конструктор за замовчуванням.
#include <iostream>
struct TaskId {
int value{};
TaskId() = delete; // забороняємо "порожній" id
explicit TaskId(int v) : value(v) {}
};
int main() {
TaskId id{10};
std::cout << id.value << '\n'; // 10
}
Зверніть увагу на психологічний ефект: тип сам підказує правила гри. Вам не потрібно сподіватися, що всі прочитають коментар «не можна створювати без id». Коментарі читають рідко. Компілятор — завжди.
Тепер вбудуймо це в наш навчальний застосунок. Нехай у нас буде проста модель задачі — невеликий todo-менеджер, який ми розвиваємо ще з часів struct, vector і функцій.
#include <string>
struct Task {
int id{};
std::string title;
bool done{false};
};
Задача з id=0 може бути валідною або ні — залежно від ваших правил. Але якщо ви вирішите, що id має бути строго додатним і задаватися під час створення, то Task() теж можна заборонити (або зробити конструктор, який вимагає id і title).
3. Забороняємо копіювання: non-copyable типи
Найцікавіше починається тоді, коли ви забороняєте копіювання. Тут важливо не плутати: заборона копіювання — це не «оптимізація», а передусім сенс. Є типи, які за своєю природою не можна копіювати, бо копія руйнує модель світу.
Типові випадки такі: обʼєкт володіє чимось унікальним — файлом, зʼєднанням, «єдиним генератором id», «єдиним активним сеансом». Якщо такий обʼєкт скопіювати, у вас раптом зʼявляться «два єдиних» власники. А це звучить як сумнівна філософія й іще гірша інженерія.
Як заборонити копіювання
Синтаксис заборони копіювання зазвичай такий: видаляємо копіювальний конструктор і оператор копіювального присвоювання.
#include <iostream>
struct IdGenerator {
int nextId{1};
IdGenerator() = default;
IdGenerator(const IdGenerator&) = delete;
IdGenerator& operator=(const IdGenerator&) = delete;
int allocate() {
return nextId++;
}
};
int main() {
IdGenerator gen;
std::cout << gen.allocate() << '\n'; // 1
std::cout << gen.allocate() << '\n'; // 2
}
Чому це корисно для нашого застосунку? Бо генератор id — чудовий приклад «унікальної сутності». Якщо його випадково скопіювати, дві копії почнуть видавати однакові id, і ваша база задач перетвориться на фестиваль колізій: «задача 7» траплятиметься частіше, ніж слово «компілятор» на лекції з C++.
=delete і сигнатури функцій: для передавання за значенням зазвичай потрібна копія
Коли ви забороняєте копіювання, виникає важливий практичний момент: не всі сигнатури функцій сумісні з типами non-copyable. І це не «обмеження мови», а прямий наслідок сенсу: якщо тип не можна копіювати, то функція, яка приймає його за значенням, одразу викликає питання.
Покажемо це на нашому IdGenerator. Якщо ми захочемо написати функцію, яка «показує поточний nextId», нам не потрібно копіювати генератор — достатньо посилання.
#include <iostream>
struct IdGenerator {
int nextId{1};
IdGenerator(const IdGenerator&) = delete;
IdGenerator& operator=(const IdGenerator&) = delete;
};
void printState(const IdGenerator& gen) {
std::cout << gen.nextId << '\n';
}
int main() {
IdGenerator gen;
printState(gen); // 1
}
А от так робити не можна — і це правильно:
// void printState(IdGenerator gen); // потребувало б копіювання: помилка
І тут компілятор стає вашим союзником: він не дасть «випадково» спроєктувати API так, ніби цей тип можна копіювати. Вам доведеться бути чесним: або const& для читання, або & для зміни.
Заборона копіювання без «дір»: видаляємо обидві операції
Є дуже поширена помилка в дизайні: заборонили копіювальний конструктор, але забули заборонити копіювальне присвоювання (або навпаки). Тоді тип виходить «напівзабороненим»: створити копію не можна, але можна присвоїти значення наявному обʼєкту — і це може бути беззмістовно, небезпечно або просто дивно.
Подивімося на прикладі.
struct Weird {
int x{};
Weird(const Weird&) = delete; // заборонили копію під час створення
// Weird& operator=(const Weird&) = ??? // забули
};
Тепер Weird b = a; заборонено, але b = a; може раптом виявитися дозволеним (якщо компілятор згенерує присвоювання). Це виглядає як правило «копіювати не можна, але якщо дуже хочеться — то можна». Зазвичай так не задумують.
Тому в навчальній і реальній практиці найчастіше діє просте правило: якщо тип за змістом non-copyable, видаляємо і копіювальний конструктор, і копіювальне присвоювання. Тоді «дір» не лишається.
4. Керуємо інтерфейсом: видаляємо небажані перевантаження
Досі ми використовували =delete як інструмент для спеціальних функцій — конструкторів і копіювання. Але він так само корисний і для «звичайних» методів, коли ви хочете заборонити конкретну форму виклику.
Типова ситуація з реального світу: ви пишете функцію setTitle, і вам важливо, щоб заголовок був саме std::string, а не сирий const char*. Чому? Наприклад, тому, що ви хочете чітко відокремлювати «рядок як обʼєкт» від «вказівника на памʼять, де десь лежать символи». Або тому, що ви робите перевантаження й не хочете випадкових неявних перетворень.
Зробімо маленький компонент для нашого застосунку — «редактор задачі».
#include <string>
struct TaskEditor {
std::string title;
void setTitle(const std::string& s) {
title = s;
}
void setTitle(const char*) = delete; // забороняємо цю форму виклику
};
Тепер editor.setTitle("hi"); не скомпілюється, хоча рядковий літерал технічно міг би підійти. Зате editor.setTitle(std::string{"hi"}); — спрацює. Звучить це трохи суворо, але інколи суворість просто означає: «майбутні баги не пройдуть».
Важливо: це не універсальна рекомендація «завжди забороняйте const char*». Це лише приклад того, що =delete уміє керувати інтерфейсом, а не тільки життєвим циклом.
5. Приклад: TaskRepository, який не можна копіювати
Зберімо невеликий, але цілісний фрагмент «todo-застосунку», де =delete справді захищає дизайн.
Ідея така: у нас є сховище задач TaskRepository, усередині якого лежать std::vector<Task> і IdGenerator. За змістом репозиторій — це «центр даних». Випадково передавати його в параметри або повертати за значенням (не обговорюючи перенесення) — погана ідея: можна отримати дві незалежні копії списку задач і розбіжність станів.
Зробімо репозиторій non-copyable:
#include <string>
#include <vector>
struct Task {
int id{};
std::string title;
bool done{false};
};
struct IdGenerator {
int nextId{1};
IdGenerator(const IdGenerator&) = delete;
IdGenerator& operator=(const IdGenerator&) = delete;
int allocate() { return nextId++; }
};
struct TaskRepository {
std::vector<Task> tasks;
IdGenerator gen;
TaskRepository() = default;
TaskRepository(const TaskRepository&) = delete;
TaskRepository& operator=(const TaskRepository&) = delete;
void addTask(const std::string& title) {
tasks.push_back(Task{gen.allocate(), title, false});
}
};
Зауважте: ми нічого не «звільняємо» вручну, не пишемо деструкторів і не ліземо в new/delete. Ми просто кажемо: «Копіювати не можна». Це і є здоровий підхід сучасного C++: нехай стандартні компоненти роблять важку роботу, а ви описуєте сенс і обмеження.
Перевірмо в main, що все працює як слід.
#include <iostream>
#include <string>
#include <vector>
int main() {
TaskRepository repo;
repo.addTask("Написати C++ код");
repo.addTask("Прочитати помилки компілятора");
std::cout << repo.tasks.size() << '\n'; // 2
}
Якщо хтось згодом спробує зробити TaskRepository copy = repo;, компілятор зупинить це на місці — і ви заощадите години налагодження в стилі «чому список задач роздвоївся».
6. Коли застосовувати =delete
Коли застосовувати =delete, а коли залишити «як є»? Хороша евристика така: =delete потрібен там, де неправильне використання типу ймовірне й дороге, а правильне використання можна виразити явно.
Зручно тримати в голові просту схему:
flowchart TD
A[Хочете заборонити операцію?] --> B{Операція справді некоректна за змістом?}
B -->|Так| C[Забороняємо через = delete]
B -->|Ні| D{Операція небажана, але інколи потрібна?}
D -->|Так| E[Не забороняємо: шукаємо інший дизайн або документуємо]
D -->|Ні| F[Залишаємо як є]
Тут важливо саме «за змістом некоректна». Наприклад, «створити обʼєкт без обовʼязкового id» — це часто некоректно. «Копіювати власника унікального ресурсу» — теж некоректно. А от «заборонити всі конструктори на всяк випадок» — зазвичай уже перебір.
Явна заборона і заборона «через поля»: два джерела non-copyable
Іноді студент бачить помилку компіляції «копіювання видалено» і думає: «Але ж я не писав =delete!». І це нормально: операція могла стати недоступною автоматично через склад полів.
Наприклад, якщо у вас поле — std::unique_ptr, то копіювання всього типу забороняється, бо unique_ptr сам по собі не копіюється. І це корисний захист: «унікальне володіння» має залишатися унікальним.
Ми не заглиблюватимемося в деталі, але важливо розуміти різницю хоча б на рівні читання коду: заборона може бути «явно заявлена» через =delete, а може «піднятися» з полів. В обох випадках результат однаковий: компілятор забороняє операцію. Різниця лише в тому, наскільки це очевидно з інтерфейсу.
У багатьох командах люблять писати =delete навіть тоді, коли поле вже робить тип non-copyable, — просто щоб намір було видно відразу, не змушуючи читача «проводити розслідування по полях».
Мінітаблиця: що найчастіше видаляють
Щоб закріпити матеріал, зведімо типові заборони в маленьку таблицю. Це не «закон C++», а практичний набір прийомів.
| Що забороняємо | Як пишеться | Коли це доречно |
|---|---|---|
| Створення «порожнього» обʼєкта | |
В обʼєкта немає розумного стану за замовчуванням |
| Копія під час створення | |
Копія ламає сенс (унікальність, володіння, ідентичність) |
| Копія в наявний обʼєкт | |
Зазвичай забороняють разом із копіювальним конструктором |
| Конкретне перевантаження | |
Хочемо заборонити небезпечний або небажаний варіант виклику |
7. Типові помилки під час роботи з =delete
Помилка № 1: плутати =delete і delete p;.
delete p; — це про звільнення динамічної памʼяті. =delete — це про інтерфейс і заборону виклику функції. Плутанина між цими поняттями зазвичай призводить до дивних висновків на кшталт «заборонив конструктор, отже памʼять звільниться» (спойлер: ні).
Помилка № 2: заборонити лише копіювальний конструктор, але залишити копіювальне присвоювання.
Тоді тип стає «дивно напівкопійованим». У найкращому разі ви отримаєте заплутаний інтерфейс, у найгіршому — несподіване копіювання стану там, де ви його не чекали. Якщо копіювання заборонено за змістом, найчастіше видаляють обидві операції: і T(const T&), і operator=.
Помилка № 3: видалити конструктор за замовчуванням і забути залишити нормальний спосіб створення.
Це виглядає як двері без ручки: «обʼєкт не можна створити ніяк». Забороняючи T(), переконайтеся, що є хоча б один конструктор, який створює валідний обʼєкт (наприклад, explicit T(int)).
Помилка № 4: намагатися передавати non-copyable тип за значенням.
Параметр за значенням майже завжди означає копію або спробу копіювання. Для non-copyable типів потрібно звикати до const T& для читання і T& для зміни. І так, спочатку це незвично, але з часом ви починаєте бачити інтерфейси «за змістом», а не «за інерцією».
Помилка № 5: використовувати =delete «на всяк випадок», без чітко сформульованої причини.
Заборона — це частина контракту типу. Якщо ви не можете пояснити, чому операцію заборонено (унікальність, володіння, беззмістовний стан, небезпечне перевантаження), то є великий шанс, що така заборона лише ускладнить використання типу і змусить людей шукати обхідні шляхи замість правильного дизайну.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ