1. Чесний контракт
Коли ви вперше бачите помилку компіляції «використання вилученої функції» або «копіювальний конструктор недоступний», може здатися, що компілятор просто вередує. Насправді в цей момент він виконує роль суворого охоронця: не пускає вас туди, де далі майже неминучий баг. Non-copyable тип — це тип, для якого копіювання заборонене. І не тому, що «авторові було лінь», а тому, що копія руйнує сам сенс.
Найважливіша думка така: заборона копіювання — це частина інтерфейсу типу. Це такий самий елемент дизайну, як і твердження «у функції два параметри, а не пʼять». Ви заздалегідь фіксуєте: «цей обʼєкт не можна “ксерокопіювати”, з ним можна працювати лише як з єдиним екземпляром».
Щоб не плутатися в термінах, зафіксуймо невелику табличку. Вона не про синтаксис, а про зміст.
| Ситуація в моделі | Що сталося б під час копіювання | Тому тип… |
|---|---|---|
| Унікальне володіння ресурсом (памʼять, дескриптор, «ручка») | Два власники починають вважати ресурс своїм | …роблять non-copyable |
| Унікальна ідентичність (одна «картка доступу», один «сеанс») | Зʼявляються «дві однакові особистості» | …роблять non-copyable |
| Mutex‑подібна ексклюзивність (один «замок») | Зʼявляються дві «копії замка», які вже не синхронізують один і той самий обʼєкт | …роблять non-copyable |
Зараз розберемо три найтиповіші життєві сценарії. І так, в усіх трьох випадках мова C++ допомагає нам тим, що помилку ми отримуємо під час компіляції, а не «за два дні на сервері о 03:00».
2. Сценарій № 1: unique ownership — «власник має бути один»
Якщо ви коли-небудь тримали в руках ключ від квартири, то вже розумієте unique ownership: ключ можна передати іншій людині, але якщо просто створити його копію «з повітря», правила гри зміняться. У програмуванні історія така сама: деякі обʼєкти за своєю природою мають мати рівно одного власника.
У C++ найвідоміший приклад унікального володіння — std::unique_ptr. Він прямо заявляє: «я володію обʼєктом, і двох власників тут бути не може». Тому std::unique_ptr не копіюється. Якщо ви покладете його в поле структури, структура теж автоматично стане non-copyable. Це якраз Rule of Zero в дії: поведінка типу випливає з поведінки його полів.
Мініприклад: власник числа в динамічній памʼяті. Так, число — ресурс іграшковий, але принцип тут видно чудово.
#include <memory>
struct IntOwner {
std::unique_ptr<int> p;
};
int main() {
IntOwner a{std::make_unique<int>(42)};
// IntOwner b = a; // помилка компіляції: копіювання заборонено
}
Тут важливо не те, що ми володіємо int. Важливо те, що ми володіємо чимось, що не можна безпечно «розмножити» за замовчуванням. Саме це й виражає non-copyable.
Іноді початківець намагається «обійти заборону», бо «мені ж треба передати обʼєкт у функцію». І саме тут зʼявляється доросла думка: якщо обʼєкт — власник, то функція має чесно сказати, що вона з ним робить. Якщо функція лише читає, вона приймає const&. Якщо змінює стан власника — приймає &. Якщо ж функція намагається прийняти власника за значенням, тобто потребує копії, то її дизайн не збігається з моделлю володіння.
Ось невелика демонстрація на рівні сигнатур, без складних трюків:
void observe(const IntOwner& o) {
(void)o; // читаємо, але не копіюємо
}
void modify(IntOwner& o) {
(void)o; // змінюємо, але не копіюємо
}
int main() {
IntOwner a{std::make_unique<int>(10)};
observe(a); // OK
modify(a); // OK
}
На цьому етапі корисно запамʼятати просте правило: якщо тип non-copyable, приймайте його за посиланням (або за вказівником, якщо допускаєте відсутність значення, але це вже окремий дизайн-контракт).
3. Сценарій № 2: файлові типи та володіння дескриптором
Файл — чудовий приклад ресурсу, що живе «зовні» вашої програми. Коли ви відкриваєте файл для запису, то отримуєте певну «ручку» (handle) до системного ресурсу. А далі починається магія ОС: курсор запису, буфери, права доступу, блокування, помилки… Це не той випадок, коли хочеться випадково створити копію обʼєкта й отримати дві сутності, які думають, що керують одним і тим самим.
У стандартній бібліотеці потоки файлового введення/виведення (std::ifstream, std::ofstream) не копіюються. І це логічно: «копія потоку» — доволі туманна конструкція за змістом. Тому будь-які ваші типи, які містять файловий потік, теж зазвичай автоматично стають non-copyable.
Тепер акуратно привʼяжімо це до нашого навчального застосунку. Уявімо, що ми пишемо просту консольну програму MiniNotes: вона зберігає нотатки в памʼяті, у векторі, і виводить їх на екран. Сьогодні додамо до неї логування дій у файл: «add», «list», «remove». Лог — це як «чорна скринька»: він має бути єдиним і справжнім.
Зробімо найпростіший логер, який володіє std::ofstream:
#include <fstream>
#include <string>
struct FileLogger {
std::ofstream out;
explicit FileLogger(const std::string& path) : out(path) {}
void log(const std::string& msg) {
out << msg << '\n';
}
};
Код виглядає невинно, але має важливу властивість: FileLogger не копіюється, бо std::ofstream не копіюється. І це добре: ми не хочемо, щоб хтось випадково створив іще один логер «на ту саму ручку» з незрозумілим станом.
Тепер покажімо, як це використати в MiniNotes. Ми не будемо робити весь інтерфейс застосунку, а зосередимося на ключовому моменті: передаємо логер за посиланням.
#include <iostream>
#include <string>
#include <vector>
struct Note {
int id{};
std::string text;
};
void add_note(std::vector<Note>& notes, FileLogger& lg, const std::string& text) {
int new_id = static_cast<int>(notes.size()) + 1;
notes.push_back(Note{new_id, text});
lg.log("add id=" + std::to_string(new_id)); // записали у файл
}
І приклад використання:
#include <iostream>
#include <vector>
int main() {
FileLogger lg{"app.log"};
std::vector<Note> notes;
add_note(notes, lg, "Купити молоко");
std::cout << notes.size() << '\n'; // 1
}
Зверніть увагу на «архітектурний» ефект: логер стає центральним ресурсом, який ми не копіюємо і не розкидаємо по всій програмі. Ми або зберігаємо його як поле в «контексті застосунку», або передаємо туди, де він потрібен, за посиланням.
Якщо ви зараз думаєте: «А що, якщо мені потрібні два логери?» — це нормально. Але два логери мають бути двома різними обʼєктами, створеними свідомо: наприклад, один пише в "app.log", інший — у "error.log". Це не «копія», а «два різні ресурси».
Іноді корисно явно підкреслити заборону копіювання, навіть якщо його й так заборонено «на рівні полів». Тоді повідомлення компілятора стає очікуванішим, а контракт типу — очевиднішим:
#include <fstream>
#include <string>
struct FileLogger {
std::ofstream out;
explicit FileLogger(const std::string& path) : out(path) {}
FileLogger(const FileLogger&) = delete;
FileLogger& operator=(const FileLogger&) = delete;
};
Так, це трохи надмірно, але новачкам це часто допомагає: ви буквально бачите словами — «копіювати не можна».
4. Сценарій № 3: mutex‑подібні типи — «ексклюзивність не можна ксерити»
Слово «mutex» у назві лекції може лякати, якщо ви ще не вивчали багатопотоковість. І це нормально: зараз ми не будемо говорити про потоки й синхронізацію як окрему тему. Нам потрібен лише образ: є обʼєкт, який гарантує ексклюзивність. Ексклюзивність — це коли «всередину» може зайти тільки хтось один.
Навіть в однопотоковому світі таке трапляється постійно. Наприклад, у вас є «картка доступу» до серверної. Вона одна. Якщо ви її скопіюєте, сенс зникне: тепер у вас дві картки доступу, і хтось може зайти туди, куди не повинен. У програмуванні така «картка доступу» часто має вигляд обʼєкта, який уособлює право на дію.
Змоделюймо такий обʼєкт простим типом AccessCard. Сам по собі він дешевий — це всього лише int, — але за змістом його не можна копіювати, бо ID має бути унікальним і єдиним.
struct AccessCard {
int id{};
explicit AccessCard(int v) : id(v) {}
AccessCard(const AccessCard&) = delete;
AccessCard& operator=(const AccessCard&) = delete;
};
Тепер зробімо «кімнату», до якої можна увійти, лише маючи картку. І спеціально передамо параметр за посиланням, щоб ніхто не міг випадково передати копію.
#include <iostream>
void enter_server_room(const AccessCard& card) {
std::cout << "Увійшли з карткою #" << card.id << '\n'; // Увійшли з карткою #7
}
Приклад використання:
int main() {
AccessCard card{7};
enter_server_room(card);
// AccessCard copy = card; // помилка: копіювання заборонено
}
Сенс тут не в тому, що int — «поганий тип». Сенс у тому, що ми виразили бізнес-правило засобами мови: «картку не можна копіювати». І тепер компілятор — ваш союзник: він не дасть випадково написати код, який порушує модель безпеки.
Якщо ви десь бачите в стандартній бібліотеці типи, схожі на «замки» або «синхронізатори», то дуже часто вони теж non-copyable. Причина та сама: «копія замка» не створює другий замок, який захищає те саме; найчастіше це просто безглуздо.
5. Non-copyable у застосунку: контекст, посилання і межі володіння
Коли в проєкті зʼявляється non-copyable обʼєкт, у новачка зазвичай виникають два питання. Перше: «Як тепер усе це передавати через функції?» Друге: «Де це зберігати?» Обидва розвʼязуються однією ідеєю: non-copyable ресурс має мати зрозумілого власника, а решта коду повинна працювати через посилання.
Повернімося до MiniNotes і створімо «контекст застосунку», який зберігає і дані, і ресурси. Це зручно: у main ви створюєте все разом один раз, а далі передаєте єдиний обʼєкт контексту за посиланням.
#include <vector>
struct AppContext {
FileLogger logger;
std::vector<Note> notes;
explicit AppContext(const std::string& log_path)
: logger(log_path) {}
};
Тепер функції працюють з AppContext&:
#include <string>
void add_note(AppContext& app, const std::string& text) {
int new_id = static_cast<int>(app.notes.size()) + 1;
app.notes.push_back(Note{new_id, text});
app.logger.log("add id=" + std::to_string(new_id));
}
І main:
int main() {
AppContext app{"app.log"};
add_note(app, "Прочитати книжку з C++");
}
Що дає така організація?
Вона робить «ресурсні» обʼєкти — логер, зʼєднання, ексклюзивний токен — частиною одного явного «кореня володіння». Цей корінь створюється в main і живе до кінця програми. Тому посилання на його поля безпечні, якщо ви не порушуєте базову дисципліну часу життя.
Водночас дуже важливо не починати «лікувати» non-copyable тип обхідними рішеннями. Наприклад, не варто думати: «О, раз копіювати не можна, я покладу вказівник і рознесу його всюди». Вказівник — це окремий контракт («може бути nullptr»), і він вимагає дисципліни щодо часу життя. У межах нашої сьогоднішньої теми найдружніший для новачка шлях — посилання і чіткий власник.
6. Помилки компілятора про deleted copy
Помилки компіляції — це як повідомлення від дуже чесного друга. Вони не завжди ввічливі й не завжди короткі, але зазвичай кажуть правду. Коли тип non-copyable, ви найчастіше побачите одну з двох ситуацій.
Перша ситуація: ви самі заборонили копіювання через = delete, і компілятор скаже, що ви намагаєтеся використати вилучену функцію. Це виглядає приблизно так — за змістом, не дослівно: «use of deleted function AccessCard::AccessCard(const AccessCard&)».
Друга ситуація: ви не забороняли копіювання вручну, але воно «заборонилося саме», бо одне з полів не копіюється, наприклад std::ofstream або std::unique_ptr. Тоді повідомлення буде схожим: «copy constructor is implicitly deleted», а далі компілятор підкаже, яке саме поле стало причиною. І це, до речі, дуже корисно: він ніби каже «дивіться, ви намагаєтеся копіювати власника ресурсу».
Майже завжди алгоритм виправлення однаковий: ви дивитеся на місце, де передаєте або повертаєте обʼєкт за значенням, і замінюєте це на передавання за посиланням.
Наприклад, ось так писати не варто, бо параметр за значенням потребує копії:
void bad(FileLogger lg) {
(void)lg;
}
А ось так — уже нормально:
void good(FileLogger& lg) {
lg.log("привіт");
}
На цьому етапі корисно зробити паузу й прийняти одну філософську думку: non-copyable типи змушують вас писати чесніші сигнатури. Якщо функція каже «дай мені обʼєкт за значенням», вона майже обіцяє, що їй потрібна незалежна копія. Якщо копія неможлива, то й обіцянка від самого початку була неправильною.
7. Типові помилки
Помилка № 1: забороняють копіювання «частково» — лише конструктор або лише присвоювання.
Іноді пишуть T(const T&) = delete;, але забувають про operator=(const T&). У результаті один сценарій копіювання заборонено, а інший формально лишається можливим, або спроба його використати дає дивне повідомлення. Для новачка найнадійніша звичка — забороняти обидві операції, щоб тип був послідовно non-copyable.
Помилка № 2: продовжують приймати non-copyable тип за значенням «за звичкою».
Дуже легко автоматично написати void f(T t), бо так робили з int і std::string. Але для non-copyable типів це не просто «дорого» — це неможливо. Правильний крок — зупинитися й вибрати контракт: const T& для читання, T& для зміни. Це робить код не лише компільованим, а й зрозумілішим.
Помилка № 3: намагаються «обійти заборону» через сирі вказівники замість нормального дизайну володіння.
Коли копіювання заборонено, виникає спокуса всюди зберігати T*, «бо вказівник копіюється». Так, адреса копіюється, але водночас ви не розвʼязали питання «хто власник» і «коли обʼєкт знищиться». Часто виходить так: один обʼєкт уже зник, а вказівники на нього ще гуляють програмою. У межах сьогоднішньої теми краще триматися стратегії: один власник + посилання.
Помилка № 4: роблять тип non-copyable без причини й дивуються, чому «тепер незручно».
Non-copyable — сильне обмеження, і воно має бути виправдане моделлю. Якщо обʼєкт логічно є значенням, як-от Point, User чи Report з минулих лекцій, то заборона копіювання лише ускладнить життя: доведеться всюди писати посилання, думати про час життя й витрачати зайву увагу. Забороняйте копіювання там, де копія справді руйнує зміст: унікальне володіння, унікальна ідентичність, ексклюзивність.
Помилка № 5: плутають = delete (вилучена функція) і delete (звільнення памʼяті).
Через однакове слово в мові мозок новачка інколи «склеює» ці поняття. T(const T&) = delete; не звільняє памʼять і не «видаляє обʼєкт» — це лише заборона виклику функції на етапі компіляції. А delete p; — це операція звільнення памʼяті, і ми вже обговорювали, чому ручне керування памʼяттю небезпечне. Це два різні світи з однаковою вивіскою.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ