Custom Deleters and Intrusive Reference Counting
So far, the smart pointers we have discussed manage "objects created with new"—calling delete upon destruction, which happens naturally. However, the real world is far more complex. The resources you need to manage might be a FILE* returned by fopen (which requires fclose to close), memory allocated by malloc (which requires free to release), a POSIX file descriptor int (which requires close to close), an SDL window, an OpenGL texture, or a CUDA stream—each resource has its own release function. If a smart pointer could only delete, it would be too weak.
Custom deleters are the key mechanism that allows smart pointers to adapt to various "non-standard" resources. Intrusive reference counting is an important alternative to std::shared_ptr in performance-sensitive and memory-constrained scenarios. We discuss these two topics together today because they revolve around the same core problem: how to make C++ smart pointers manage resources that are "not created with new".
Three Forms of Deleters
A custom deleter is essentially a "callable object"—invoked when the smart pointer is destructed, responsible for releasing the resource. It can be a function pointer, a lambda expression, or a function object (functor). These three forms have their own characteristics; we will explain them one by one, starting with the simplest.
Function Pointers: The Most Intuitive Way
Function pointers are the easiest form of deleter to understand. You pass the address of a function, and the smart pointer calls it upon destruction. However, function pointers have a disadvantage: they increase the size of std::unique_ptr, because std::unique_ptr needs to store this function pointer additionally.
#include <cstdio>
#include <memory>
int main() {
// Define a deleter for FILE*
auto file_deleter = [](FILE* f) { std::fclose(f); };
// unique_ptr manages FILE* with a custom deleter
std::unique_ptr<FILE, decltype(file_deleter)> file(std::fopen("test.txt", "w"), file_deleter);
if (file) {
std::fprintf(file.get(), "Hello, RAII!\n");
}
// fclose is automatically called here
}You can also use std::function to simplify the type declaration and avoid handwriting the function pointer type:
#include <functional>
// ...
std::unique_ptr<FILE, std::function<void(FILE*)>> file2(std::fopen("test.txt", "w"), file_deleter);sizeof comparison—a function pointer deleter doubles the size of std::unique_ptr:
sizeof(unique_ptr<FILE, void(*)(FILE*)>) = 16
sizeof(unique_ptr<FILE>) = 8Note: The values above were tested on the x86_64-linux-gnu platform (g++ 15.2.1). Implementations may vary slightly on different platforms and compilers. See Godbolt for full verification code.
Lambdas: Flexible and Modern
Lambdas are the most commonly used deleter form in modern C++. A captureless lambda can be converted to a function pointer, so it has the same memory overhead as a function pointer. However, a lambda with captures becomes a stateful deleter, increasing the size of std::unique_ptr.
// Captureless lambda: same size as function pointer (16 bytes)
auto deleter1 = [](FILE* f) { std::fclose(f); };
std::unique_ptr<FILE, decltype(deleter1)> p1(nullptr, deleter1);
// Lambda with capture: size increases to store the captured variable (24 bytes)
int close_code = 0;
auto deleter2 = [close_code](FILE* f) {
std::fclose(f);
// use close_code...
};
std::unique_ptr<FILE, decltype(deleter2)> p2(nullptr, deleter2);Function Objects: The Most Efficient Way
Function objects (functors) are the best choice for stateless deleters—they have neither the storage overhead of function pointers nor the naming issues of lambdas. The key is Empty Base Optimization (EBO): if a class has no data members (an empty class), the compiler can optimize its size to 0. std::unique_ptr typically implements EBO by inheriting from the deleter type, so an empty deleter does not increase the size of std::unique_ptr.
struct FileDeleter {
void operator()(FILE* f) const {
std::fclose(f);
}
};
// FileDeleter is empty, EBO applies
// sizeof(unique_ptr<FILE, FileDeleter>) == sizeof(FILE*) == 8
std::unique_ptr<FILE, FileDeleter> file(std::fopen("test.txt", "w"));Zero Overhead for Stateless Deleters: Deep Dive into EBO
"Zero overhead" is not an empty phrase—Empty Base Optimization (EBO) is an optimization technique in C++ compilers: when an empty class (no data members, no virtual functions) is used as a base class, the compiler can optimize its size to 0 bytes, requiring no additional memory space. A typical implementation of std::unique_ptr stores the deleter as a base class (via inheritance), so when the deleter is an empty class, the entire std::unique_ptr contains only a raw pointer.
Let's verify this (on x86_64-linux-gnu, g++ 15.2.1):
#include <memory>
#include <cstdio>
#include <functional>
struct FileClose {
void operator()(FILE* f) const { std::fclose(f); }
};
int main() {
using UniqueFP = std::unique_ptr<FILE, FileClose>;
using FuncFP = std::unique_ptr<FILE, std::function<void(FILE*)>>;
static_assert(sizeof(UniqueFP) == sizeof(FILE*), "EBO should apply");
static_assert(sizeof(FuncFP) > sizeof(FILE*), "std::function adds overhead");
}Typical output on a 64-bit platform (g++ 15.2.1, -O0):
sizeof(unique_ptr<FILE, default_delete>) = 8
sizeof(unique_ptr<FILE, FileClose>) = 8 <-- EBO applied
sizeof(unique_ptr<FILE, void(*)(FILE*)>) = 16 <-- Function pointer overhead
sizeof(unique_ptr<FILE, lambda>) = 16 <-- Lambda (captureless)
sizeof(unique_ptr<FILE, std::function<...>>) = 32 <-- std::function overheadSee Godbolt for full verification code.
The data is clear: empty deleters (including the default deleter and empty function objects) do not increase the size of std::unique_ptr. Only stateful deleters (such as lambdas capturing variables, function objects with data members, or function pointers) increase the size.
This is why the author recommends using function objects over function pointers in performance-sensitive scenarios—function objects can achieve zero overhead through EBO, while function pointers always require additional storage space.
FILE* Management, C API Encapsulation in Action
Now that we have mastered the basic principles of deleters, let's look at a few actual encapsulation scenarios. The first is the most common C API encapsulation: using std::unique_ptr to manage FILE*.
Expand (24 lines)Collapse
#include <cstdio>
#include <memory>
struct FileDeleter {
void operator()(FILE* f) const {
if (f) std::fclose(f);
}
};
using UniqueFile = std::unique_ptr<FILE, FileDeleter>;
UniqueFile open_file(const char* name, const char* mode) {
UniqueFile file(std::fopen(name, mode));
if (!file) {
// Handle error (throw exception or return nullptr)
}
return file;
}
// Usage
void write_log() {
auto log = open_file("log.txt", "a");
std::fprintf(log.get(), "System started\n");
}The second scenario is encapsulating malloc/free:
struct MallocDeleter {
void operator()(void* p) const {
std::free(p);
}
};
using UniqueMalloc = std::unique_ptr<void, MallocDeleter>;
// Usage
UniqueMalloc buffer(std::malloc(1024));SDL/OpenGL Resource Management Example
Graphics programming is full of resources that require specific release functions. Using std::unique_ptr with a custom deleter can manage them elegantly:
struct SDLWindowDeleter {
void operator()(SDL_Window* w) const {
if (w) SDL_DestroyWindow(w);
}
};
using UniqueSDLWindow = std::unique_ptr<SDL_Window, SDLWindowDeleter>;
// Usage
UniqueSDLWindow window(SDL_CreateWindow("Title", SDL_WINDOWPOS_CENTERED, ...));Here is a detail worth noting: an OpenGL texture ID is a GLuint (an integer), not a pointer. But std::unique_ptr can only manage pointer types. So we place the GLuint on the heap (new GLuint), and then use std::unique_ptr to manage this heap-allocated GLuint. The deleter calls both glDeleteTextures and delete upon destruction. Although this "indirection" looks imperfect, it is standard practice in reality.
Deleters for shared_ptr: Type Erasure
The previous discussion focused on deleters for std::unique_ptr—where the deleter type is part of the std::unique_ptr type. The deleter for std::shared_ptr has a fundamental difference: the deleter type is not part of the std::shared_ptr type; it is "erased" and stored in the control block.
This means you can use the same std::shared_ptr type to hold objects with different deleters:
void close_file(FILE* f) { std::fclose(f); }
auto p1 = std::shared_ptr<FILE>(std::fopen("a.txt", "w"), close_file);
auto p2 = std::shared_ptr<FILE>(std::fopen("b.txt", "w"), [](FILE* f){ std::fclose(f); });
// p1 and p2 have the same type std::shared_ptr<FILE>This flexibility of "runtime polymorphism" is an advantage of std::shared_ptr deleters, but it comes at a cost: the deleter is stored in the control block (extra heap allocation), and each destruction requires calling the deleter through a function pointer. According to benchmarks (g++ 15.2.1, -O2, 100,000 iterations), the creation and destruction of std::shared_ptr is about 30-50% slower than std::unique_ptr, with the main overhead coming from the memory allocation of the control block. See Godbolt for full test code.
Principles of Intrusive Reference Counting
Custom deleters solve the problem of "non-standard release," but the overhead of std::shared_ptr itself (control block, atomic operations, extra heap allocation) is still significant in performance-sensitive or memory-constrained scenarios. Intrusive reference counting provides an alternative: embedding the reference count inside the object, rather than allocating a control block externally.
The core idea of the intrusive approach is simple: the object knows "how many people hold me." The reference count exists as a member variable of the object, rather than being allocated in a separate control block. This means no extra heap allocation (saving the memory and management overhead of the control block), and access to the reference count is local (on the same cache line as the object's other members).
#include <atomic>
class RefCounted {
public:
RefCounted() : ref_count(0) {}
void add_ref() { ref_count++; }
void release() {
if (--ref_count == 0) {
delete this;
}
}
protected:
virtual ~RefCounted() = default;
private:
std::atomic<int> ref_count; // or int for single-threaded
};Any object that needs shared management can simply inherit RefCounted to gain reference counting capability:
class Texture : public RefCounted {
// ...
};intrusive_ptr Implementation and Application Scenarios
With the reference counting base class, we also need a smart pointer to automatically manage the calls to add_ref and release. This is intrusive_ptr (similar to boost::intrusive_ptr):
Expand (32 lines)Collapse
template<typename T>
class intrusive_ptr {
public:
intrusive_ptr() : ptr_(nullptr) {}
explicit intrusive_ptr(T* p) : ptr_(p) {
if (ptr_) ptr_->add_ref();
}
~intrusive_ptr() {
if (ptr_) ptr_->release();
}
// Copy constructor
intrusive_ptr(const intrusive_ptr& other) : ptr_(other.ptr_) {
if (ptr_) ptr_->add_ref();
}
// Move constructor
intrusive_ptr(intrusive_ptr&& other) noexcept : ptr_(other.ptr_) {
other.ptr_ = nullptr;
}
// Assignment operators omitted for brevity...
T* get() const { return ptr_; }
T& operator*() const { return *ptr_; }
T* operator->() const { return ptr_; }
private:
T* ptr_;
};The usage is almost identical to std::shared_ptr, but the underlying mechanism is completely different—no control block, no extra heap allocation:
auto tex = std::make_unique<Texture>(); // Create object
intrusive_ptr<Texture> shared_tex(tex.release()); // Transfer ownershipThe core difference between the intrusive approach and std::shared_ptr is: the control block of std::shared_ptr is allocated on the heap outside the object (requiring extra new), while the intrusive approach places the counter directly inside the object. This means there is only one memory allocation (the object itself), and accessing the reference count does not require jumping to another memory location (more cache-friendly).
The intrusive approach also has some limitations: the object must inherit from the reference counting base class (intrusiveness), it is inconvenient to manage objects of existing types (like standard library types), and you must decide on the thread safety of the reference count yourself. However, it is precisely this flexibility of "you decide" that makes the intrusive approach very attractive in embedded systems—in single-threaded scenarios, you can use a normal int counter; in multi-threaded scenarios, you need to switch the counter to std::atomic<int>, which introduces the overhead of atomic operations. See Godbolt for a full multi-threaded implementation example.
Embedded in Action: Hardware Handle Management
In embedded systems, resources are usually not "objects created with new," but hardware handles—DMA channels, SPI buses, GPIO pins, etc. The "release" of these handles is not delete, but calling specific HAL functions. Custom deleters + std::unique_ptr (or the intrusive approach) are ideal tools for managing such resources.
struct SpiHandle {
SPI_TypeDef* instance; // Hardware register base
DMA_HandleTypeDef* hdma_tx;
};
struct SpiDeleter {
void operator()(SpiHandle* h) const {
if (h) {
HAL_SPI_DeInit(h->instance);
// Disable DMA, clear interrupts...
delete h; // If the handle itself was allocated with new
}
}
};
using UniqueSpi = std::unique_ptr<SpiHandle, SpiDeleter>;
UniqueSpi spi1(new SpiHandle{SPI1, &hdma_spi1_tx});This pattern is very common in embedded driver development. std::unique_ptr + stateless deleters are suitable for "exclusive use" scenarios (only one module holds it at a time), while intrusive reference counting is suitable for "shared use" scenarios (multiple modules hold it simultaneously). Both are lighter and more suitable for resource-constrained environments than std::shared_ptr.
Summary
Custom deleters allow smart pointers to break the limitation of "only managing new/delete," capable of adapting to any type of resource release method. The three deleter forms—function pointers, lambdas, and function objects—each have pros and cons: function objects can achieve zero overhead through EBO and are the first choice for performance-sensitive scenarios; lambdas are convenient to write but watch out for size increases due to captures; function pointers are the most intuitive but double the size of std::unique_ptr.
Intrusive reference counting is an effective alternative to std::shared_ptr in performance and memory-constrained scenarios. By embedding the reference count inside the object, it eliminates the heap allocation of the control block and extra indirect access. The cost is modifying the object type (intrusiveness), but in performance-sensitive fields like embedded systems and game engines, this trade-off is usually worth it.
In the next article, we will discuss scope_guard—a more general RAII variant that can manage not only resources but also any operation that needs to be executed when exiting a scope.
Reference Resources
- cppreference: std::unique_ptr, Deleters
- Empty Base Optimization and no_unique_address
- Boost intrusive_ptr documentation
- C++ Core Guidelines: R.20-24
- P0468R0: An Intrusive Smart Pointer Proposal
Verification Code
The technical assertions in this article are verified by the following code (on x86_64-linux-gnu platform, g++ 15.2.1):
Deleter sizeof verification: Godbolt Link
- Verify memory usage when function pointers, lambdas, and function objects are used as deleters
- Verify the impact of Empty Base Optimization (EBO) on
std::unique_ptrsize
Deleter performance benchmark: Godbolt Link
- Compare performance differences between
std::unique_ptrandstd::shared_ptrwhen using custom deleters - Test conditions: 100,000 iterations, -O2 optimization level
- Compare performance differences between
Intrusive reference counting complete implementation: Godbolt Link
- Complete
intrusive_ptrimplementation - Single-threaded and multi-threaded versions of the reference counting base class
- Comparison demonstration with
std::shared_ptr
- Complete
Compilation and execution method:
cmake -B build -DCMAKE_CXX_COMPILER=g++ -DCMAKE_BUILD_TYPE=Release
cmake --build build
./build/benchmarkOr compile directly with g++:
g++ -std=c++23 -O2 -Wall main.cpp -o benchmark
./benchmark