JavaRush /Курси /C++ SELF /Non-copyable типи: сценарії unique ownership, mutex‑подіб...

Non-copyable типи: сценарії unique ownership, mutex‑подібні

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

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; — це операція звільнення памʼяті, і ми вже обговорювали, чому ручне керування памʼяттю небезпечне. Це два різні світи з однаковою вивіскою.

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