JavaRush /Курси /C++ SELF /Custom deleter у std::unique_ptr: handle і FILE*

Custom deleter у std::unique_ptr: handle і FILE*

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

1. Навіщо потрібен custom deleter

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

Такий обʼєкт часто називають handle («ручка ресурсу»): це не сам ресурс, а обʼєкт, вказівник або число, через яке ви ним керуєте.

Найзрозуміліший навчальний приклад — FILE* із Сі. Це вказівник, але він не означає «памʼять, виділену через new». Це «ручка відкритого файлу». Відкривається він через fopen, а закривається через fclose. І якщо ви спробуєте закрити файл через delete, це приблизно те саме, що намагатися вимкнути компʼютер пультом від телевізора: кнопки є, але сенсу немає, а наслідки можуть бути неприємними.

Саме тут і зʼявляється головна ідея: std::unique_ptr — це не «розумний delete», а універсальна RAII-обгортка. Просто за замовчуванням вона справді викликає delete. Але ми можемо навчити її викликати будь-яку функцію звільнення.

Що таке deleter і як unique_ptr розуміє, чим звільняти

Deleter — це обʼєкт або функція, що вміє «правильно звільнити ресурс». Для звичайного std::unique_ptr<int> deleter за замовчуванням — це щось на кшталт «виклич delete».

Але якщо ресурс звільняється інакше, ми можемо сказати unique_ptr: «коли будеш знищуватися (або виконувати reset()), не роби delete, а виклич ось цю функцію».

Синтаксис має такий вигляд:

std::unique_ptr<T, Deleter> p;

Тобто у unique_ptr зʼявляється другий параметр шаблону — тип видаляча. І тут є важливий момент: std::unique_ptr<FILE, FileCloser> і std::unique_ptr<FILE, AnotherCloser> — це різні типи, тому їх не можна просто так присвоювати один одному.

Тому в реальному коді майже завжди створюють псевдонім типу (using), щоб не повторювати «складне імʼя» в усьому проєкті.

2. FILE* під RAII: fclose без ручного закриття

Мініприклад: FILE* + fclose через функтор-deleter

Зараз зробимо практичну річ: файл закриватиметься автоматично, навіть якщо ми вийдемо з функції раніше. Це і є RAII, тільки не для new/delete, а для fopen/fclose.

Почнемо з невеликого deleter. Найзрозуміліший варіант — struct з operator() (такий обʼєкт називають функтором):

#include <cstdio>

struct FileCloser {
    void operator()(std::FILE* f) const {
        if (f) {
            std::fclose(f);
        }
    }
};

Зверніть увагу: deleter без проблем приймає nullptr. Це добра звичка, тому що unique_ptr може бути порожнім, і ситуація, коли «закривати нічого», цілком нормальна.

Тепер оголосимо зручний псевдонім типу:

#include <memory>
#include <cstdio>

using FilePtr = std::unique_ptr<std::FILE, FileCloser>;

І відкриємо файл так, щоб він одразу перейшов у володіння:

#include <cstdio>
#include <memory>

using FilePtr = std::unique_ptr<std::FILE, FileCloser>;

int main() {
    FilePtr f(std::fopen("log.txt", "w"));
    if (!f) {
        return 1;
    }
} // тут автоматично викличеться fclose через FileCloser

Тут важливо відчути головну ідею: ми не писали delete, але отримали ту саму зручність, що й зі звичайним unique_ptr: ресурси звільняються автоматично — під час виходу з області видимості.

Вбудовуємо в застосунок: логер на FILE* без ручного fclose

Щоб не було відчуття «приклад заради прикладу», давайте акуратно продовжимо навчальний консольний проєкт (умовно TodoLite). Додамо можливість писати лог у файл, але так, щоб не довелося розставляти ручний fclose по всьому коду.

Зробимо контекст застосунку з «ручкою» лог-файлу:

#include <cstdio>
#include <memory>
#include <string>

struct FileCloser {
    void operator()(std::FILE* f) const {
        if (f) std::fclose(f);
    }
};

using FilePtr = std::unique_ptr<std::FILE, FileCloser>;

struct AppContext {
    FilePtr log;
};

Тепер — функція запису в лог. Зверніть увагу: у цій функції ми не володіємо файлом, а лише використовуємо його. Тому беремо контекст за посиланням і звертаємося до ctx.log.get().

#include <cstdio>
#include <string>

void write_log(AppContext& ctx, const std::string& line) {
    if (!ctx.log) return;
    std::fputs(line.c_str(), ctx.log.get());
    std::fputs("\n", ctx.log.get());
}

Ініціалізація контексту:

#include <cstdio>

AppContext make_context() {
    AppContext ctx;
    ctx.log = FilePtr(std::fopen("todolite.log", "a"));
    return ctx;
}

Навіть якщо ви забудете закрити файл, під час завершення main (або при виході з поточного блоку) unique_ptr викличе deleter, і файл коректно закриється.

Deleter як вказівник на функцію: &std::fclose

Іноді хочеться обійтися взагалі без struct FileCloser. У випадку FILE* це справді можливо, бо в нас уже є функція звільнення std::fclose. Ми можемо зберігати вказівник на функцію як deleter.

#include <cstdio>
#include <memory>

int main() {
    using FilePtr = std::unique_ptr<std::FILE, decltype(&std::fclose)>;

    FilePtr f(std::fopen("log.txt", "w"), &std::fclose);
    if (!f) return 1;

    std::fputs("Hello!\n", f.get());
}

Тут є два нюанси.

Перший: deleter передається в конструктор другим аргументом. Якщо ви забудете &std::fclose, код не скомпілюється, тому що unique_ptr не зможе «здогадатися», який deleter слід використовувати.

Другий: тип FilePtr вийшов громіздкішим і більш «шаблонним». У навчальному проєкті це нормально, але у великому коді часто віддають перевагу struct FileCloser + using, бо так код простіше читати й підтримувати.

get() і release() під час роботи з C-API

У реальних проєктах ви майже завжди опинятиметеся в ситуації: «у мене є RAII-власник, але бібліотечна функція приймає сирий T*». І це нормально: більшість C-API не знає про unique_ptr.

Рішення просте: передавайте get() і памʼятайте, що володіння при цьому не передається.

Невеликий приклад: друкуємо рядок у файл.

#include <cstdio>

void print_line(std::FILE* f) {
    if (!f) return;
    std::fputs("line\n", f);
}

int main() {
    FilePtr f(std::fopen("a.txt", "w"));
    print_line(f.get());
} // fclose викличеться автоматично

Якщо ви чітко памʼятаєте, що get() не передає володіння, то майже автоматично уникаєте двох головних бід: подвійного звільнення й use-after-free.

З release() історія інша: він робить unique_ptr порожнім і віддає сирий вказівник. Але з custom deleter зʼявляється додаткова пастка: ресурс треба закривати не через delete, а саме тим способом, для якого ви й створювали deleter.

#include <cstdio>

int main() {
    FilePtr f(std::fopen("a.txt", "w"));
    std::FILE* raw = f.release();  // f більше не володіє

    if (raw) {
        std::fclose(raw);          // закриваємо вручну (не delete!)
    }
}

Це робочий код, але він повертає нас у світ «ручного керування ресурсами». Тому найбезпечніший стиль використання release() — «негайно передати ресурс наступному власнику»:

#include <cstdio>

int main() {
    FilePtr a(std::fopen("a.txt", "w"));
    std::FILE* raw = a.release();

    FilePtr b(raw);        // b тепер власник
} // b закриє файл, a вже порожній

Якщо ви бачите release(), це майже завжди привід подумки запитати себе: «хто тепер власник і хто тепер зобовʼязаний звільняти?».

Схема життєвого циклу FILE* під керуванням unique_ptr

Іноді допомагає візуалізація. Ось проста блок-схема: «створили ресурс → працюємо → автоматичне закриття»:

flowchart TD
    A["std::fopen(...) -> FILE*"] --> B["FilePtr(file) бере ресурс у володіння"]
    B --> C["Працюємо: f.get() передаємо у fputs/fprintf"]
    C --> D["Вихід з області видимості / reset()"]
    D --> E["FileCloser::operator() -> std::fclose(file)"]

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

3. Механіка unique_ptr і правила простого дизайну

Коли unique_ptr викликає deleter

Корисно проговорити цей механізм, бо багато помилок зводяться до такого: «я думав, воно звільняється тут, а воно звільняється там».

unique_ptr викликає deleter у тих самих ситуаціях, у яких для звичайного випадку він викликав би delete:

Подія в житті unique_ptr Що відбувається з ресурсом
unique_ptr знищується (вихід з області видимості) викликається deleter
reset() без аргументу викликається deleter для старого ресурсу, вказівник стає nullptr
reset(new_ptr) deleter для старого ресурсу, потім зберігається новий
присвоєння перенесенням (p = std::move(q)) deleter для старого ресурсу p, потім p забирає ресурс q
release()
deleter не викликається, відповідальність переходить назовні

Ось чому release() лишається «небезпечною кнопкою» і у випадку custom deleter: ви «винесли ресурс з-під RAII», і тепер зобовʼязані закрити його вручну правильною функцією, а не чим завгодно.

Як не ускладнювати дизайн: три практичні правила

Дуже легко перетворити custom deleter на «шаблонну магію заради шаблонної магії». Тут допомагають три принципи.

Перший принцип: якщо ресурс трапляється в проєкті частіше ніж один раз, не пишіть unique_ptr<..., decltype(lambda)> у явному вигляді всюди. Зробіть struct Deleter і using Alias = unique_ptr<...>. Це знову робить страшний тип читабельним і не змушує читача коду розбирати decltype на ходу.

Другий принцип: deleter має бути нудним. Його завдання — одне: звільнити ресурс. Не потрібно додавати в deleter логування, повторні спроби, «якщо не закрилося — відкрий ще раз» та інші сценарії. Якщо вам потрібна складна політика, краще винести її в окрему функцію, яка приймає власника ресурсу й явно виконує потрібну логіку.

Третій принцип: намагайтеся не використовувати release(), якщо у вас немає дуже чіткої відповіді на запитання «кому далі передаю володіння». І особливо не використовуйте release() «просто щоб отримати T*». Щоб просто подивитися, є get().

Ресурсна «ручка» не обовʼязково є вказівником

Щоб закріпити ідею, корисно побачити, що custom deleter — це взагалі про «будь-які ресурси», а не лише про файли.

Уявімо умовну зовнішню бібліотеку на Сі, яка створює певний ресурс і повертає вказівник:

struct Connection; // тип прихований у бібліотеці

Connection* connect_create();
void connect_close(Connection* c);

У вашому коді ви робите те саме, що й з FILE*: пишете deleter і отримуєте RAII-власника.

#include <memory>

struct ConnectionCloser {
    void operator()(Connection* c) const {
        if (c) connect_close(c);
    }
};

using ConnPtr = std::unique_ptr<Connection, ConnectionCloser>;

І тепер логіка стає «правильною за замовчуванням»: створили, загорнули, а далі просто передаєте ConnPtr туди, де потрібне володіння, або get() туди, де потрібне лише використання.

Навіть якщо конкретний ресурс у реальній бібліотеці не є вказівником (інколи handle — це число, наприклад int), сам принцип лишається тим самим. Сьогодні ми не заглиблюємося в такі варіанти, але важлива загальна думка: unique_ptr + deleter — це спосіб сказати коду: «ось правила звільнення, виконуй їх автоматично».

4. Типові помилки під час використання custom deleter

Помилка № 1: використовувати std::unique_ptr<T> за замовчуванням для ресурсу, який не можна звільняти через delete.
Це найпоширеніша логічна помилка: раз «у мене вказівник», значить, «потрібен unique_ptr без другого параметра». Але якщо ресурс закривається функцією close_xxx, fclose, DestroyHandle тощо, то unique_ptr зобовʼязаний знати правильний deleter. Інакше ви отримаєте невизначену поведінку: у найкращому разі — падіння, у найгіршому — тихе псування памʼяті.

Помилка № 2: закрити ресурс вручну, поки він усе ще під unique_ptr.
Наприклад, std::fclose(f.get()) при ще живому FilePtr f. Це виглядає невинно, але насправді ви закрили ресурс «раніше часу», а потім unique_ptr спробує закрити його ще раз у деструкторі. З погляду моделі володіння це подвійне звільнення, тільки не через delete, а через fclose.

Помилка № 3: зберігати «сире спостереження» надто довго.
Якщо ви взяли std::FILE* raw = f.get(), а потім десь зробили f.reset(), то raw перетворюється на потенційно висячий вказівник. У навчальних прикладах це рідко проявляється, але в реальних програмах такі «спостерігачі» часто переживають власника й перетворюються на помилку, яка виринає вночі, коли ви вже спите.

Помилка № 4: release() без плану «хто наступний власник».
release() — це легальний інструмент, але він різко вимикає RAII і повертає відповідальність програмісту. Початківці часто використовують release() «просто щоб дістати вказівник», хоча для цього є get(). Якщо ви викликали release(), у вас має бути дуже конкретна відповідь на запитання «хто тепер звільнить ресурс і чим саме».

Помилка № 5: ускладнювати deleter до рівня «мініфреймворку».
Deleter — не місце для бізнес-логіки. Чим більше в ньому умов і побічних ефектів, тим складніше зрозуміти, що станеться під час виходу з області видимості (а це може трапитися будь-де). Хороший deleter зазвичай уміщується в 3–5 рядків і виконує рівно одну операцію звільнення.

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