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 |
|
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 рядків і виконує рівно одну операцію звільнення.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ