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: ресурсы освобождаются автоматически, на выходе из scope.

Встраиваем в приложение: логгер на 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["Выход из scope / reset()"]
    D --> E["FileCloser::operator() -> std::fclose(file)"]

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

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

Когда unique_ptr вызывает deleter

Полезно проговорить механику, потому что многие ошибки — это «я думал, оно освобождается тут, а оно освобождается там».

unique_ptr вызывает deleter в тех же ситуациях, где он бы вызвал delete для обычного случая:

Событие в жизни unique_ptr Что происходит с ресурсом
unique_ptr уничтожается (выход из scope) вызывается 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 туда, где нужно владение, или c.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 — не место для бизнес-логики. Чем больше в нём условий и побочных эффектов, тем сложнее понимать, что произойдёт при выходе из scope (а это может случиться в любом месте). Хороший deleter обычно укладывается в 3–5 строк и делает ровно одну операцию освобождения.

1
Задача
C++ SELF, 43 уровень, 2 лекция
Недоступна
Временный сейф
Временный сейф
1
Задача
C++ SELF, 43 уровень, 2 лекция
Недоступна
Чек со списком
Чек со списком
1
Задача
C++ SELF, 43 уровень, 2 лекция
Недоступна
Линии для C API
Линии для C API
1
Задача
C++ SELF, 43 уровень, 2 лекция
Недоступна
Счётчик закрытий
Счётчик закрытий
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ