Custom Deleters and make_unique / make_shared
Smart pointers were designed with heap-allocated objects in mind, but many C++ programs manage resources that are not released with delete: file handles closed with fclose, OpenGL objects deleted through driver APIs, SDL surfaces freed via SDL_FreeSurface, or reference-counted COM objects decremented through Release(). Custom deleters let you attach arbitrary cleanup logic to a smart pointer so that the same ownership guarantees that protect heap memory extend to every kind of resource.
The problem: resources that don't use delete
The default deleter for both unique_ptr and shared_ptr calls delete on the stored pointer. That is exactly right for objects created with new, but it is wrong — and often undefined behaviour — for resources acquired through other means. A FILE* opened with fopen must be closed with fclose, not delete. An OpenGL vertex-buffer ID returned by glGenBuffers is just an integer handle — calling delete on it is nonsense; the driver must be told to release it via glDeleteBuffers. Before custom deleters, programmers wrote manual RAII wrapper classes for each such resource type. Custom deleters eliminate most of that boilerplate by letting you specify the cleanup function inline, at the point you create the smart pointer.
// Without custom deleter: manual RAII wrapper required
struct FileCloser {
void operator()(FILE* f) const { if (f) std::fclose(f); }
};
using UniqueFile = std::unique_ptr<FILE, FileCloser>;
UniqueFile f(std::fopen("data.txt", "r"));
// FileCloser::operator() called when f goes out of scope
// With custom deleter inline (C++14 lambda):
auto file_deleter = [](FILE* f) { if (f) std::fclose(f); };
std::unique_ptr<FILE, decltype(file_deleter)> f2(std::fopen("data.txt", "r"), file_deleter);unique_ptr custom deleters
For unique_ptr, the deleter is part of the type: the second template parameter specifies the deleter type, and an instance of that deleter is stored inside the smart pointer itself. This design means that unique_ptr<T, Deleter1> and unique_ptr<T, Deleter2> are different types — you cannot assign one to the other or store them in the same container without type erasure. This is intentional: it allows the compiler to eliminate all runtime overhead when the deleter is known at compile time.
The most common patterns, in order of preference, are captureless lambdas, capturing lambdas, function pointers, and stateful function objects. Each has different size implications for the smart pointer itself:
// 1. Captureless lambda (preferred) — no size increase
// An empty lambda type has sizeof == 1, but unique_ptr uses EBO (empty-base
// optimization) so the deleter adds zero bytes. Size == sizeof(T*) == 1 word.
auto del = [](Investment* p) {
makeLogEntry(p);
delete p;
};
std::unique_ptr<Investment, decltype(del)> pInv(nullptr, del);
static_assert(sizeof(pInv) == sizeof(Investment*)); // true on all major compilers
// 2. Capturing lambda — size grows with captured state
int log_level = 3;
auto del_with_cap = [log_level](Investment* p) {
if (log_level > 0) makeLogEntry(p);
delete p;
};
// sizeof(del_with_cap) == sizeof(int) → unique_ptr grows by sizeof(int)
std::unique_ptr<Investment, decltype(del_with_cap)> pInv2(nullptr, del_with_cap);
// 3. Function pointer — unique_ptr grows from 1 word to 2 words
// (raw pointer + function pointer, no EBO possible for non-empty types)
void delInvmt(Investment* p) { makeLogEntry(p); delete p; }
std::unique_ptr<Investment, decltype(&delInvmt)> pInv3(nullptr, delInvmt);
// sizeof(pInv3) == 2 * sizeof(void*) — always, regardless of optimization
// 4. Stateless functor — same as captureless lambda (EBO applies)
struct LogAndDelete {
void operator()(Investment* p) const { makeLogEntry(p); delete p; }
};
std::unique_ptr<Investment, LogAndDelete> pInv4(nullptr, LogAndDelete{});
static_assert(sizeof(pInv4) == sizeof(Investment*)); // EBO → no size increase| Deleter type | Size of unique_ptr | Notes |
|---|---|---|
| Captureless lambda | 1 word (pointer only) | EBO applies — preferred choice |
| Stateless functor | 1 word (pointer only) | EBO applies — same as captureless lambda |
| Function pointer | 2 words | No EBO for function pointers; always two words |
| Capturing lambda | 1 word + capture size | Grows with the amount of captured state |
| Stateful functor | 1 word + struct size | Depends on how much state the functor holds |
Scott Meyers' recommendation in Effective Modern C++ (Item 18) is clear: prefer captureless lambdas. They give you inline readability, zero size overhead, and the compiler can inline the call trivially. If you need to parameterize the cleanup behaviour, prefer a lambda that captures values over a function pointer, since the capture approach keeps the logic co-located and EBO may still apply on some implementations.
The factory function pattern
A very common use of custom deleters is a factory function that returns a unique_ptr whose deleter dispatches to the correct destructor based on which derived type was created. This is how you correctly write a factory that returns a base-class pointer without requiring the base class to have a virtual destructor — the deleter captures and invokes the right cleanup at the type-erasure boundary:
class Investment { /* no virtual destructor needed */ };
class Stock : public Investment { /* ... */ };
class Bond : public Investment { /* ... */ };
// Deleter knows the exact concrete type at construction time
auto makeInvestment(/* args */) {
auto delInvmt = [](Investment* p) {
makeLogEntry(p); // always log before release
delete p; // safe: deleter knows the concrete type
};
std::unique_ptr<Investment, decltype(delInvmt)> pInv(nullptr, delInvmt);
if (/* stock conditions */)
pInv.reset(new Stock(/* args */));
else
pInv.reset(new Bond(/* args */));
return pInv; // return type deduced as unique_ptr<Investment, decltype(delInvmt)>
}
// Callers hold the base-type pointer; cleanup is correct regardless of which
// concrete type was actually created — the deleter closes over the right dispatch.
auto pInvestment = makeInvestment(/* args */);make_unique and custom deleters
std::make_unique does not accept a custom deleter. The function always produces a unique_ptr<T> with the default deleter, because its primary purpose is to avoid writing new directly and to guarantee exception safety in complex expressions — goals that are orthogonal to custom cleanup. When you need a custom deleter, you must write the constructor call yourself and use reset to attach the raw pointer:
// make_unique — no custom deleter support
auto p1 = std::make_unique<Widget>(); // default deleter (delete)
// auto p2 = std::make_unique<Widget>(myDel); // ← does not compile
// When you need a custom deleter: construct directly
auto del = [](FILE* f) { if (f) std::fclose(f); };
std::unique_ptr<FILE, decltype(del)> fp(std::fopen("data.bin", "rb"), del);
// Or use reset() to avoid the raw pointer appearing twice:
std::unique_ptr<FILE, decltype(del)> fp2(nullptr, del);
fp2.reset(std::fopen("data.bin", "rb")); // nullptr-safe if fopen failsThe reset(nullptr, del) pattern is safer when the acquisition can fail (return nullptr) because you avoid passing a null pointer directly to the constructor. The smart pointer checks for null automatically on destruction and skips the deleter call.
Converting unique_ptr to shared_ptr
A unique_ptr can be implicitly converted to a shared_ptr of the same element type, and when that conversion happens the custom deleter is preserved. The shared_ptr takes ownership, creates a control block, and stores the custom deleter inside it — the fact that the deleter came from a unique_ptr is completely transparent to callers. This makes factory functions that return unique_ptr equally usable in contexts where shared ownership is needed later:
// Factory returns unique_ptr with custom deleter auto pInv = makeInvestment(/* args */); // unique_ptr<Investment, LambdaType> // Implicit conversion to shared_ptr — deleter is carried over std::shared_ptr<Investment> pShared = std::move(pInv); // pShared now holds a control block that stores the lambda deleter. // Callers see only shared_ptr<Investment> — no lambda type visible in the type.
shared_ptr custom deleters — type erasure
shared_ptr handles custom deleters differently from unique_ptr in a fundamental way: the deleter type is not a template parameter. Instead, the deleter is passed as a second argument to the constructor, and the standard uses type erasure to store it in the control block on the heap. This means that two shared_ptr<Widget> instances with completely different custom deleters have exactly the same type — they can be stored in the same vector, passed to the same function, and even swapped with one another.
// Two deleters — different functions, same shared_ptr<Widget> type
auto loggingDel = [](Widget* pw) { makeLogEntry(pw); delete pw; };
auto customDel = [](Widget* pw) { /* fancy cleanup */ delete pw; };
std::shared_ptr<Widget> pw1(new Widget, loggingDel);
std::shared_ptr<Widget> pw2(new Widget, customDel);
// Same type! Can go in the same container:
std::vector<std::shared_ptr<Widget>> vpw;
vpw.push_back(pw1);
vpw.push_back(pw2); // ✓ no type conflict — both are shared_ptr<Widget>
// Compare with unique_ptr: would be TWO different vector types:
// vector<unique_ptr<Widget, decltype(loggingDel)>> vs
// vector<unique_ptr<Widget, decltype(customDel)>> — incompatibleThe price for this flexibility is that the deleter must be stored somewhere — and that somewhere is the control block, a heap-allocated structure that shared_ptr always allocates when managing an object. As Scott Meyers explains in Item 19 of Effective Modern C++, a shared_ptr itself is always the size of two raw pointers: one pointing to the managed object and one pointing to the control block. The deleter (and allocator, reference counts, weak counts) live in the control block, not in the smart pointer itself. This means a shared_ptr with an enormous stateful deleter is the same size as one with a captureless lambda — the size difference is only in the heap-allocated control block.
// shared_ptr is always two pointers, regardless of deleter
struct HeavyDeleter {
std::array<char, 1024> buffer; // 1 KB of state
void operator()(Widget* p) const { delete p; }
};
std::shared_ptr<Widget> p1(new Widget); // default deleter
std::shared_ptr<Widget> p2(new Widget, HeavyDeleter{}); // 1 KB deleter
static_assert(sizeof(p1) == sizeof(p2)); // both == 2 * sizeof(void*)
// The 1 KB lives in the control block on the heap, not in p2 itselfThe control block
Every shared_ptr that owns an object points to a control block — a heap-allocated structure that contains the reference count, the weak reference count, the custom deleter (if any), and the custom allocator (if any). The reference count tracks how many shared_ptr instances share ownership; when it drops to zero the managed object is destroyed. The weak count tracks how many weak_ptr instances exist; when it too drops to zero the control block itself is freed.
The most dangerous consequence of the control-block design is this: a control block is created once, at the point the first shared_ptr takes ownership of a raw pointer. If you accidentally create two shared_ptr instances from the same raw pointer, each gets its own control block and its own reference count — and the object will be destroyed twice, giving undefined behaviour.
Widget* raw = new Widget; std::shared_ptr<Widget> sp1(raw); // control block #1 created, ref count = 1 std::shared_ptr<Widget> sp2(raw); // control block #2 created, ref count = 1 // ← TWO separate ref counts for the SAME object! // sp1 goes out of scope → ref count #1 → 0 → Widget destroyed ← first destruction // sp2 goes out of scope → ref count #2 → 0 → Widget destroyed ← double-free: UB! // Solution: use make_shared (single allocation of object + control block together) // or copy/assign existing shared_ptr instead of constructing from raw pointer auto sp = std::make_shared<Widget>(); // object and control block allocated together auto sp2_safe = sp; // copy — shares the SAME control block
The same problem arises if code inside a class creates a shared_ptr from this. The solution is std::enable_shared_from_this: inherit from it, and use shared_from_this() to obtain a shared_ptr that shares the existing control block rather than creating a new one:
class Widget : public std::enable_shared_from_this<Widget> {
public:
void process() {
// ✗ Wrong: creates a second control block for *this → double-free
// auto sp = std::shared_ptr<Widget>(this);
// ✓ Correct: returns a shared_ptr sharing the existing control block
auto sp = shared_from_this();
processedWidgets.push_back(sp);
}
private:
static std::vector<std::shared_ptr<Widget>> processedWidgets;
};
// Note: shared_from_this() throws std::bad_weak_ptr if the Widget is not
// already managed by a shared_ptr — it cannot create ownership out of nothingmake_shared and custom deleters
Like make_unique, std::make_shared does not support custom deleters. This is a genuine limitation: make_shared's main advantage is that it allocates the managed object and the control block in a single memory allocation — but to do that, the object must be constructed by make_shared itself, leaving no opportunity to attach a custom deleter. When you need a custom deleter, you must call the shared_ptr constructor directly, which performs two separate allocations:
// make_shared: one allocation (object + control block together) — no custom deleter
auto p1 = std::make_shared<Widget>(); // ← preferred when no custom deleter
// Constructor: two allocations (object separately + control block separately)
auto deleter = [](Widget* p) { /* custom cleanup */ delete p; };
std::shared_ptr<Widget> p2(new Widget, deleter); // ← required for custom deleter
// When using a custom deleter for non-heap resources, new is irrelevant:
auto file_del = [](FILE* f) { if (f) std::fclose(f); };
std::shared_ptr<FILE> fp(std::fopen("data.txt", "r"), file_del);Common real-world patterns
Custom deleters appear frequently at the boundary between C-style resource management APIs and modern C++. The pattern is always the same: identify the cleanup function the C API requires, wrap it in a lambda or functor, and attach it to a smart pointer at the acquisition site.
auto fdel = [](FILE* f) { if (f) std::fclose(f); };
std::unique_ptr<FILE, decltype(fdel)> fp(std::fopen("data.bin", "rb"), fdel);
// or: using UniqueFile = std::unique_ptr<FILE, decltype(&std::fclose)>;
// UniqueFile fp(std::fopen("data.bin", "rb"), std::fclose);// Wrap an integer ID in a struct so unique_ptr can hold it
struct GlBufferDeleter {
using pointer = GLuint; // override the default "T*" pointer type
void operator()(GLuint id) const { glDeleteBuffers(1, &id); }
};
std::unique_ptr<GLuint, GlBufferDeleter> vbo([] {
GLuint id; glGenBuffers(1, &id); return id;
}());auto sdl_del = [](SDL_Surface* s) { SDL_FreeSurface(s); };
std::unique_ptr<SDL_Surface, decltype(sdl_del)>
surf(SDL_LoadBMP("icon.bmp"), sdl_del);// shared_ptr: custom deleter is NOT a template parameter
auto conn = std::shared_ptr<sqlite3>(
open_db("app.db"), // raw acquisition
[](sqlite3* db) { sqlite3_close(db); } // deleter as constructor arg
);
// Multiple owners can share the connection; closes exactly once when all releaseSummary
unique_ptr: deleter type is part of the smart pointer type
Specify it as the second template parameter. Different deleters → different types. No type erasure.
Prefer captureless lambdas as unique_ptr deleters
EBO (empty-base optimization) keeps the smart pointer at one word. Function pointers grow it to two words.
shared_ptr: deleter is a constructor argument, not a template parameter
Type erasure stores the deleter in the heap control block. All shared_ptr<T> with any deleter have the same type.
shared_ptr size is always two pointers, regardless of deleter size
Deleter is stored in the control block. The pointer pair (managed object, control block) is always what the smart pointer holds.
Neither make_unique nor make_shared supports custom deleters
Use the constructor directly. For make_shared, the combined allocation trades a custom deleter for one fewer heap allocation.
Never create two shared_ptrs from the same raw pointer
Each constructor call creates a new control block. Two control blocks for one object → double-free UB. Use make_shared or copy an existing shared_ptr.
Use enable_shared_from_this to create shared_ptr from this
shared_from_this() returns a shared_ptr sharing the existing control block. Constructing shared_ptr(this) creates a second control block and causes UB.