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 |
|
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 строк и делает ровно одну операцию освобождения.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ