C++ Custom Deleters for unique_ptr and shared_ptr: Function Pointers, Lambdas, and Functors
Key takeaways
When custom deleters are needed, comparing function pointers·lambdas·function objects, differences in type·storage between unique_ptr and shared_ptr, file·socket RAII, and performance.
What is Custom Deleter?
A smart pointer calls delete when it goes out of scope, which is wrong for a FILE*, a socket descriptor, or memory from malloc. A custom deleter replaces that final call. This post compares function pointers, lambdas, and function objects as deleters, explains why the deleter is part of the type for unique_ptr but not for shared_ptr, and walks through file, array, malloc, and socket examples.
auto deleter = [](int* p) {
std::cout << "Deleting: " << *p << std::endl;
delete p;
};
std::unique_ptr<int, decltype(deleter)> ptr(new int(10), deleter);
When Custom Deleters Are Needed
When cleanup other than standard delete/delete[] is needed, use custom deleters.
- C API/Platform Handles:
FILE*(fclose), socket descriptors (close/closesocket),HANDLE,DIR*, etc. - Allocation Method Mismatch:
malloc/free, returning to custom pool, custom aligned allocation, etc. - Arrays: When holding
new T[n]withshared_ptr<T>and needdelete[](legacy code), calldelete[]in deleter. Preferstd::unique_ptr<T[]>/std::vector/ C++17shared_ptr<T[]>when possible. - Observation·Debugging: When adding logging or profiling hooks at deletion.
- Third-party Objects: When there’s a contract like “this pointer must only be released with library X’s
release”.
Smart pointers are tools that bundle ownership + cleanup at scope end, and custom deleters are the part that changes “what to call at scope end”. This directly relates to the “Relationship with RAII” section below.
Function Pointer vs Lambda vs Function Object
| Method | Characteristics | In unique_ptr | In shared_ptr |
|---|---|---|---|
| Function Pointer | No state, type fixed as void(*)(T*) | Deleter size is one pointer | Stored in control block, type still shared_ptr<T> |
| Stateless Lambda | decltype(lambda) is unique type (different for each lambda) | Different Del makes different unique_ptr types | Deleter stored in control block at runtime, can be treated as same shared_ptr<T> |
| Capturing Lambda | Deleter object grows by capture size | unique_ptr object size increases (empty base optimization may not apply) | Copied·moved to control block |
| Function Object (struct) | Easy to specify template·state·noexcept in operator() | Good for team convention reuse | Same |
Production Guide: For named deleters reused by team, use function object like struct FileCloser { void operator()(FILE*) const; } for easier testing·documentation·single definition. Stateless lambdas are common for one-liners. Function pointers are used when directly connecting with C API or wanting to fix binary size.
Difference in unique_ptr vs shared_ptr (Deleter Perspective)
- std::unique_ptr<T, D>: Deleter type
Dis template argument, so differentDmeans differentunique_ptrtype itself. Assignment likeptr1 = ptr2only works with sameTand sameD. - std::shared_ptr
: Deleter is type-erased with pointer type T*in control block. Soshared_ptr<int>created with different lambdas/deleters are treated as same static type and can be assigned·compared (assuming managed object is valid).
Performance: Stateless deleters in unique_ptr often get compiler optimization without extra storage. shared_ptr deleter calls may be indirect calls, so profiling is recommended for ultra-high-frequency paths.
Relationship with RAII
RAII is “resource acquisition is initialization and cleanup in destructor”. When using C API, destructor is just a function like fclose, so putting custom deleter in smart pointer allows unique_ptr<FILE, FileCloser> form to align with same scope rules for C++ objects. The fact that deleter is called by stack unwinding even on exception is same as regular RAII classes. Use shared_ptr only when ownership sharing is needed; for single ownership, unique_ptr with deleter type exposed at compile-time is more explicit in intent.
Performance Considerations (Deleter Itself)
unique_ptr+ stateless deleter: Almost no overhead, often just holdingT*pointer.unique_ptr+ heavy deleter: Deleter object increasesunique_ptrobject size. Avoid capturing more than necessary.- shared_ptr: Reference count updates and control block are base costs. Deleter is usually one indirect call inside control block. Rather than using
shared_ptrjust for custom deleter, better to first judge if sharing is really needed.
make_shared and allocation count - when creating shared_ptr with custom deleter, often can’t use make_shared, resulting in 2 allocations, which should be considered in cost.
unique_ptr Deleter
// Function pointer
void myDeleter(int* p) {
std::cout << "Deleting" << std::endl;
delete p;
}
std::unique_ptr<int, decltype(&myDeleter)> ptr(new int(10), myDeleter);
// Lambda
auto deleter = [](int* p) { delete p; };
std::unique_ptr<int, decltype(deleter)> ptr2(new int(20), deleter);
The two forms look alike but behave differently. With a function-pointer deleter, the unique_ptr stores the pointer to myDeleter next to the managed pointer, so it is twice the size of a raw pointer, and the deleter has to be passed to the constructor every time: std::unique_ptr<int, void(*)(int*)> p(new int(1)); does not compile (current libstdc++ reports no matching constructor; older versions had a constructed with null function pointer deleter static assertion), because a default-constructed function pointer would be null. A captureless lambda has no state, so the deleter takes no space. Since C++20, captureless lambdas are also default-constructible, which means std::unique_ptr<int, decltype(deleter)> p(new int(20)); works without repeating deleter.
One trap with function pointers is decltype(&std::fclose) and similar. Since C++20, taking the address of most standard library functions is unspecified, because implementations may add overloads or default arguments. It compiles on current toolchains, but a small struct that calls std::fclose is the portable spelling, and it keeps the unique_ptr pointer-sized as a bonus.
shared_ptr Deleter
// shared_ptr: not included in type
auto deleter = [](int* p) {
std::cout << "Deleting" << std::endl;
delete p;
};
std::shared_ptr<int> ptr(new int(10), deleter);
Here the deleter goes into the control block, and ptr is an ordinary std::shared_ptr<int>. That is convenient, since functions taking shared_ptr<int> accept it without knowing how it is freed, and it is the reason shared_ptr works well across a library boundary: the code that allocated the object also decided how to free it, even if the last owner lives in another module. std::get_deleter<decltype(deleter)>(ptr) recovers a pointer to the stored deleter if you ever need it.
Two differences from unique_ptr matter in practice. The deleter is copied into the control block, so it must be copy-constructible; a lambda that captures a std::unique_ptr cannot be used here. And the deleter runs when the last owner releases the object, which may be on a different thread from the one that created it. A deleter that touches thread-affine state (a GUI object, an OpenGL context) can then fail or crash in a place that has nothing to do with the code that made the shared_ptr.
Practical Examples
Example 1: FILE* Management
#include <cstdio>
auto fileDeleter = [](FILE* f) {
if (f) {
std::cout << "Closing file" << std::endl;
std::fclose(f);
}
};
std::unique_ptr<FILE, decltype(fileDeleter)> file(
std::fopen("data.txt", "r"),
fileDeleter
);
if (file) {
char buffer[100];
std::fgets(buffer, sizeof(buffer), file.get());
}
The if (f) check inside the deleter is harmless but not needed: if fopen fails, file holds nullptr, and unique_ptr’s destructor never calls the deleter for a null pointer. The check becomes necessary only if the same deleter is used with shared_ptr, which does call its deleter even when constructed from a null pointer.
A less obvious limitation is that a deleter has no way to report failure. fclose flushes buffered output, and for a file opened for writing, that flush can fail (full disk, network file system error). Inside the deleter the return value is discarded, and a destructor cannot usefully throw. When writing, I close explicitly at the end of the successful path and check the result, if (std::fclose(file.release()) != 0) { /* report */ }, and leave the deleter as the cleanup for error and exception paths only. The same applies to any handle whose close can fail meaningfully, such as close() on a file descriptor after writes.
Deleters must also not throw. unique_ptr’s destructor is noexcept, so an exception escaping the deleter calls std::terminate.
Example 2: Array Deletion
// ❌ Using delete (arrays need delete[])
// std::unique_ptr<int> ptr(new int[10]);
// ✅ Array specialization
std::unique_ptr<int[]> ptr(new int[10]);
// ✅ Custom deleter
auto deleter = [](int* p) { delete[] p; };
std::unique_ptr<int, decltype(deleter)> ptr2(new int[10], deleter);
The commented-out first line is worth taking seriously, because it compiles. std::unique_ptr<int> accepts any int*, and its default deleter calls delete, not delete[]. The mismatch is undefined behavior: for int it often appears to work, while for a class type the destructors of all but the first element are skipped and the allocator may corrupt its heap. AddressSanitizer reports it as alloc-dealloc-mismatch (operator new [] vs operator delete). std::unique_ptr<int[]> is the right fix, since it also disables operator* and operator-> and provides operator[]; the custom-deleter version is mainly useful for understanding what the specialization does.
Example 3: C Library Resources
#include <cstdlib>
// malloc/free
auto deleter = [](int* p) {
std::cout << "free" << std::endl;
std::free(p);
};
std::unique_ptr<int, decltype(deleter)> ptr(
static_cast<int*>(std::malloc(sizeof(int))),
deleter
);
Example 4: Socket Management
In POSIX environments, fd is often int. Below is an example of putting one int on heap and passing pointer to deleter (in production, rather than specializing unique_ptr for int itself, often use wrapper with different type as shown in “Alternative”).
#include <unistd.h> // close
struct SocketDeleter {
void operator()(int* sock) const {
if (sock && *sock >= 0) {
::close(*sock);
std::cout << "Closing socket" << std::endl;
}
delete sock;
}
};
extern int createSocket(); // e.g., socket(...)
std::unique_ptr<int, SocketDeleter> socket(new int(createSocket()));
The heap-allocated int works, but it spends an allocation to hold four bytes, and every access becomes *socket. It exists only because unique_ptr<T, D> always stores a pointer; unique_ptr<int, FdCloser> still holds an int*, not an int. A deleter can change the stored type with a nested using pointer = ...;, but that type must behave like a nullable pointer (comparable with nullptr), which a plain int does not.
Alternative: for integer handles, a small move-only class is usually clearer than bending unique_ptr:
class UniqueFd {
int fd_ = -1;
public:
UniqueFd() = default;
explicit UniqueFd(int fd) noexcept : fd_(fd) {}
UniqueFd(UniqueFd&& o) noexcept : fd_(std::exchange(o.fd_, -1)) {}
UniqueFd& operator=(UniqueFd&& o) noexcept {
if (this != &o) { reset(); fd_ = std::exchange(o.fd_, -1); }
return *this;
}
~UniqueFd() { reset(); }
void reset() noexcept { if (fd_ >= 0) ::close(fd_); fd_ = -1; }
int get() const noexcept { return fd_; }
int release() noexcept { return std::exchange(fd_, -1); }
};
-1 plays the role of nullptr, copying is implicitly deleted because the move operations are declared, and get() returns the raw descriptor for system calls. On Windows, the same class wraps SOCKET with INVALID_SOCKET and closesocket, and a HANDLE wrapper has to decide whether nullptr or INVALID_HANDLE_VALUE means “empty”, since different Win32 functions use different ones.
Deleter details that change behavior
Reach for a custom deleter when the resource is released by something other than delete: fclose for FILE*, close for a socket or file descriptor, free for memory from malloc, or a library’s own destroy function. If the same resource type appears in many places, wrapping it once in a small RAII class or a type alias (using FilePtr = std::unique_ptr<FILE, FileCloser>;) keeps every call site correct.
Two details decide whether the deleter is free and safe. A stateless function object or captureless lambda type keeps unique_ptr the size of a raw pointer, while a function-pointer deleter stores the pointer inside every unique_ptr. And unique_ptr never calls its deleter for a null pointer, but shared_ptr created from a null pointer with a custom deleter does call it, so a deleter such as fclose must check for null when used with shared_ptr. Deleters must not throw; if a failed close has to be reported, close explicitly before the smart pointer goes out of scope and check the result there.
Frequently Asked Questions (FAQ)
Q. Why does sizeof of my unique_ptr change depending on the deleter?
A. The deleter is part of unique_ptr’s type and is stored inside it. A stateless deleter, such as a captureless lambda or an empty function object, is typically optimized away, so the unique_ptr stays the size of a raw pointer. A function-pointer deleter adds one pointer, and a std::function deleter adds considerably more. shared_ptr keeps the deleter in its control block, so its type and size are not affected.
Related Articles
- C++ RAII Explained | Why Constructors and Destructors Prevent Leaks
- C++ nullptr vs NULL: The Overload Bug NULL Causes and What std::nullptr_t Fixes
- std::unique_ptr: Exclusive Ownership, make_unique and Custom Deleters
- shared_ptr and weak_ptr Internals: Control Blocks and Cycles