C++ / MEMORY OWNERSHIP AND SMART POINTERS
Custom deleters and managing non-memory resources
Wrap any C-style resource in unique_ptr or shared_ptr with a custom deleter so files, sockets and pool slots are released on scope exit.
What you will learn
- Write a stateless deleter struct so a unique_ptr still costs one raw pointer
- Bind fclose, closesocket, or return-to-pool cleanup to a handle's lifetime
- Pick shared_ptr to erase the deleter, unique_ptr to keep cleanup free and inlined
- Give a deleter a pointer typedef to own handles that are not pointers
Understanding Custom deleters and managing non-memory resources
std::unique_ptr<T> is shorthand for std::unique_ptr<T, std::default_delete<T>>, and default_delete does exactly one thing: it calls delete on the stored pointer. Nothing else in the class cares about delete, because that second template parameter is a policy naming what to invoke on the handle when the owner dies. Read that way, a unique_ptr is not a pointer into the heap; it is a scope-bound owner of one handle plus the single call that releases it, whether that call is fclose, sqlite3_close, closesocket, an unlock, or a push back onto a free list. The destructor invokes the deleter only when the stored handle is non-null, so your deleter never needs its own null check.
The deleter is part of the type, so unique_ptr<Conn, ConnClose> and unique_ptr<Conn, void(*)(Conn*)> are unrelated types and a function taking one will not accept the other. That is also where the cost sits: an empty struct with operator() is folded away by the empty base optimization, leaving the smart pointer the size of a bare pointer and a call that inlines, while a function pointer or a capturing lambda must actually be stored, so the object doubles in size and the call goes through the stored value. Because a null function pointer would be fatal at destruction time, the standard disables the handle-only constructor when the deleter is a pointer type, so unique_ptr<FILE, int(*)(FILE*)> f(std::fopen(p, "r")) is a compile error until you pass &std::fclose as well.
shared_ptr goes the other way and type-erases the deleter into the control block it has to allocate for the reference counts anyway. Every shared_ptr<Conn> therefore has the same static type however its object is released, which is what lets one of them delete while another runs a no-op deleter over an object it merely observes; you pay with an indirect call through the block, and make_shared is unavailable because it chooses the deallocation itself. The erasure also drops the null check: a shared_ptr built from pointer plus deleter owns whatever it was handed, so a failed open leaves it calling fclose(nullptr) at the end of the scope. Deleters run from destructors, often mid-unwinding, so they must not throw.
placeholder
<iostream>
<memory>
<stdexcept>
// A typical C API: opaque handle in, explicit close call out.
struct Connection { int id; };
Connection* conn_open(int id) {
std::cout << "open " << id << '\n';
return new Connection{id};
}
void conn_close(Connection* c) {
std::cout << "close " << c->id << '\n';
delete c;
}
// Stateless deleter: an empty type, so storing it costs nothing.
struct ConnClose {
void operator()(Connection* c) const { conn_close(c); }
};
using ConnPtr = std::unique_ptr<Connection, ConnClose>;
using FnPtr = std::unique_ptr<Connection, void (*)(Connection*)>;
ConnPtr make_conn(int id) { return ConnPtr(conn_open(id)); }
void run_queries() {
ConnPtr a = make_conn(1);
ConnPtr b = make_conn(2);
throw std::runtime_error("query failed");
}
int main() {
std::cout << std::boolalpha
<< "stateless deleter is free: "
<< (sizeof(ConnPtr) == sizeof(Connection*)) << '\n'
<< "function pointer deleter is bigger: "
<< (sizeof(FnPtr) > sizeof(ConnPtr)) << '\n';
try {
run_queries();
} catch (const std::exception& e) {
std::cout << "caught: " << e.what() << '\n';
}
FnPtr c(conn_open(3), &conn_close); // pointer deleters must be passed in
}
A custom deleter turns a smart pointer into a generic owner of any handle whose release is a single call, so RAII stops being about memory.
Worked examples
shared_ptr hides the deleter in the control block
Two shared_ptrs with different cleanup actions still have the same static type, which lets one of them own nothing at all.
<iostream>
<memory>
<type_traits>
struct Widget { int id; };
Widget singleton{99}; // static storage: must never be deleted
int main() {
std::shared_ptr<Widget> owned(new Widget{1}, [](Widget* w) {
std::cout << "delete " << w->id << '\n';
delete w;
});
std::shared_ptr<Widget> borrowed(&singleton, [](Widget*) {
std::cout << "no-op deleter\n";
});
std::cout << std::boolalpha
<< "same static type: "
<< std::is_same<decltype(owned), decltype(borrowed)>::value << '\n'
<< "ids: " << owned->id << ' ' << borrowed->id << '\n';
}
Example explained
Line 1The two lambdas are distinct types, yet decltype of both shared_ptrs is std::shared_ptr<Widget>, because the deleter lives in the control block rather than the type.
Line 2The no-op deleter hands out a counted reference to an object the shared_ptr does not own; unique_ptr can express that only by leaking the deleter type into every signature.
Line 3Both deleters fire at the closing brace in reverse declaration order, each control block calling only the callable captured at its construction.
Line 4The cost of that flexibility is the control block allocation plus an indirect call through it, where unique_ptr's stateless deleter would have inlined.
A deleter that recycles instead of freeing
A stateful deleter returns a buffer to its pool, so destroying the handle means releasing a lease rather than releasing memory.
<cstddef>
<iostream>
<memory>
<vector>
struct Buffer { int id; };
class Pool {
std::vector<Buffer*> free_;
public:
Pool() { for (int i = 1; i <= 2; ++i) free_.push_back(new Buffer{i}); }
~Pool() { for (Buffer* b : free_) delete b; }
struct Recycle { // stateful deleter: holds the pool
Pool* pool;
void operator()(Buffer* b) const {
std::cout << "return " << b->id << '\n';
pool->free_.push_back(b);
}
};
using Handle = std::unique_ptr<Buffer, Recycle>;
Handle acquire() {
Buffer* b = free_.back();
free_.pop_back();
std::cout << "acquire " << b->id << '\n';
return Handle(b, Recycle{this});
}
std::size_t available() const { return free_.size(); }
};
int main() {
Pool pool;
{
Pool::Handle a = pool.acquire();
Pool::Handle b = pool.acquire();
std::cout << "available " << pool.available() << '\n';
}
std::cout << "available " << pool.available() << '\n'
<< std::boolalpha
<< "handle is bigger than a pointer: "
<< (sizeof(Pool::Handle) > sizeof(Buffer*)) << '\n';
}
Example explained
Line 1Recycle stores a Pool*, so it is not an empty type and Pool::Handle has to be wider than a raw Buffer* to carry it.
Line 2operator() never frees anything; it pushes the buffer back on the free list, which is why "destroying" a handle can mean recycling.
Line 3acquire returns Handle(b, Recycle{this}), binding the release action at the exact moment of acquisition so no caller can forget it.
Line 4available prints 0 inside the block and 2 after it, because both destructors ran in reverse order at the closing brace.
Owning a handle that is not a pointer
A deleter that defines a pointer typedef lets unique_ptr store an integer handle whose empty value is -1 rather than 0.
<cstddef>
<iostream>
<memory>
// C API where 0 is a perfectly valid handle and -1 means "none".
int slot_open(const char* name) {
static int next = 0;
std::cout << "open " << name << '\n';
return next++;
}
void slot_close(int h) { std::cout << "close " << h << '\n'; }
struct SlotHandle {
int value = -1;
SlotHandle() = default;
SlotHandle(std::nullptr_t) {}
SlotHandle(int v) : value(v) {}
explicit operator bool() const { return value != -1; }
friend bool operator==(SlotHandle a, SlotHandle b) { return a.value == b.value; }
friend bool operator!=(SlotHandle a, SlotHandle b) { return !(a == b); }
};
struct SlotDeleter {
using pointer = SlotHandle; // unique_ptr stores this, not int*
void operator()(SlotHandle h) const { slot_close(h.value); }
};
using SlotPtr = std::unique_ptr<int, SlotDeleter>;
int main() {
SlotPtr a(slot_open("cache"));
SlotPtr b(slot_open("index"));
std::cout << std::boolalpha
<< "a is handle " << a.get().value
<< ", empty? " << (a.get() == nullptr) << '\n';
}
Example explained
Line 1unique_ptr uses SlotDeleter::pointer when the deleter provides it, so the stored value is a SlotHandle and get() hands back a SlotHandle by value.
Line 2The nullptr constructor plus operator== define -1 as the empty state, which is what makes handle 0 survive the destructor's non-null test and get closed.
Line 3A hand-rolled wrapper that wrote if (h) close(h) would silently leak handle 0; the sentinel has to match what the C API actually calls invalid.
Line 4Dereferencing this unique_ptr would be meaningless, but operator* is a member template that is never instantiated, so the type still compiles.
Important notes
A deleter must not throw. It runs from a destructor, frequently while an exception is already unwinding, so catch and log inside it rather than letting anything escape.
The deleter's return value is discarded, so a wrapped fclose quietly swallows a failed final flush. When a close failure means lost data, close explicitly, check the result, and release() the handle so the deleter cannot close it a second time.
Common mistakes
Allocating with std::malloc or a library's own allocator and then wrapping it in a plain std::unique_ptr<T>: default_delete calls delete on memory that delete never allocated, which is undefined behaviour and usually corrupts the heap. The deleter has to call std::free, or that library's own free function.
Writing std::shared_ptr<FILE> f(std::fopen(path, "r"), &std::fclose) without checking the result: a failed open leaves shared_ptr owning a null pointer, and it will still call fclose(nullptr) at the end of the scope. unique_ptr would have skipped the call.
Calling the close function by hand and leaving the smart pointer to close it too. That is a double close, and because operating systems reuse handle numbers, the second one can shut a file or socket that another part of the program just opened; call release() if you really are handing ownership away.
Try it yourself
Change, predict, then run
Build a std::unique_ptr<char, Free> over std::malloc, where Free is an empty struct calling std::free, allocate 32 bytes, fill it with std::snprintf, print it, and add a static_assert that its size equals sizeof(char*). Then change the deleter type to void(*)(void*) with &std::free passed in and see which of those two lines stops compiling.
Open the C++ workspaceCheck your understanding
An open call can fail and return nullptr. Why does unique_ptr<Conn, ConnClose> p(conn_open()) stay safe in that case while shared_ptr<Conn> p(conn_open(), ConnClose{}) can crash at the end of the scope?
- unique_ptr's destructor invokes the deleter only when the stored handle is non-null, while shared_ptr owns whatever pointer it was handed and calls the deleter on it, nullptr included
- shared_ptr's reference count starts at zero for a null pointer, so the deleter runs immediately at construction
- ConnClose{} is copied into the control block, and copying a deleter makes it run twice
- unique_ptr checks whether its deleter is null before calling it, and shared_ptr does not
Show answer
unique_ptr's destructor is specified as "if get() == nullptr, no effect", so a failed acquisition simply never triggers cleanup; shared_ptr's pointer-plus-deleter constructor creates a control block that owns the pointer unconditionally, so conn_close(nullptr) runs. Option 3 is tempting because a null deleter really is a hazard, but the check in unique_ptr's destructor is on the stored handle, not the deleter, which is exactly why the standard forbids constructing a unique_ptr with a pointer-type deleter from a handle alone. The count in option 2 always starts at 1, null or not.