C++ProgrammingSenior C++ Developer

What distinguishes **std::unique_ptr**'s custom deleter support from **std::shared_ptr**'s in terms of type erasure and object size implications?

Pass interviews with Hintsage AI assistant

Answer to the question

C++11 introduced std::unique_ptr and std::shared_ptr to replace the unsafe std::auto_ptr. Both support custom deleters for managing non-memory resources like file handles or database connections. However, their architectural approaches differ fundamentally due to their ownership models and performance requirements.

std::unique_ptr implements exclusive ownership and stores its deleter as part of its type (the second template parameter). If the deleter is stateful, it occupies space within the unique_ptr object itself alongside the managed pointer. std::shared_ptr implements shared ownership via a control block allocated on the heap, where the deleter is type-erased and stored separately from the shared_ptr object.

This architectural difference leads to distinct size characteristics. A std::unique_ptr with a stateless deleter occupies exactly the same space as a raw pointer thanks to the Empty Base Optimization. Conversely, std::shared_ptr maintains a constant size (typically two pointers) regardless of the deleter's size or complexity, because the deleter resides in the separately allocated control block.

#include <memory> #include <cstdio> #include <iostream> struct FileDeleter { void operator()(FILE* fp) const { if (fp) std::fclose(fp); } }; struct StatefulDeleter { int flags = 0xDEAD; void operator()(FILE* fp) const { if (fp) std::fclose(fp); } }; int main() { // unique_ptr with stateless deleter: size == pointer size (8 bytes on 64-bit) std::unique_ptr<FILE, FileDeleter> up(nullptr); // shared_ptr: constant size (16 bytes) regardless of deleter std::shared_ptr<FILE> sp(nullptr, FileDeleter{}); std::cout << "Unique (stateless): " << sizeof(up) << " bytes "; std::cout << "Shared (any deleter): " << sizeof(sp) << " bytes "; // unique_ptr with stateful deleter: larger size (16 bytes: pointer + int + padding) std::unique_ptr<FILE, StatefulDeleter> up2(nullptr, StatefulDeleter{}); std::shared_ptr<FILE> sp2(nullptr, StatefulDeleter{}); std::cout << "Unique (stateful): " << sizeof(up2) << " bytes "; std::cout << "Shared (stateful): " << sizeof(sp2) << " bytes "; }

Situation from life

A development team needed to manage legacy database connection handles (void*) returned by a C API. These handles required specific cleanup via db_disconnect() rather than delete. The application created thousands of handles per second in tight loops, making memory footprint and allocation performance critical.

The first approach considered was a custom RAII wrapper class ConnectionGuard that stored the handle and called db_disconnect() in its destructor. Pros included complete control over the interface and the ability to add connection-specific methods. Cons involved significant boilerplate code for every resource type, the reinvention of pointer semantics, and incompatibility with standard library algorithms designed for smart pointers.

The second solution utilized std::shared_ptr<void> with a lambda deleter capturing the disconnect function. Pros included immediate availability using standard components and the future-proof ability to share ownership if needed. Cons included mandatory heap allocation for the control block, atomic reference counting overhead unsuitable for high-frequency unique ownership, and a fixed object size of 16 bytes regardless of the lightweight nature of the handle.

The third approach employed std::unique_ptr<void, decltype(&db_disconnect)> with a function pointer deleter, or preferably a stateless functor. Pros included zero overhead when using stateless functors thanks to Empty Base Optimization (matching raw pointer size of 8 bytes), no heap allocations, and perfect expression of exclusive ownership semantics. Cons included the verbosity of the type signature and the inability to change deleters at runtime.

The team selected the third solution with a stateless functor deleter. This choice eliminated heap allocations entirely, reduced the wrapper size to 8 bytes, and removed atomic operation overhead while maintaining automatic cleanup.

The result was a 40% reduction in memory usage and significant latency improvements in the connection pooling system, achieving exception safety without compromising performance.

What candidates often miss


Why does std::unique_ptr require a complete type at the point of destruction when using the default deleter, while std::shared_ptr does not?

Answer: std::unique_ptr with the default deleter calls delete on the managed pointer. The C++ standard requires that delete on a pointer to T has T defined as a complete type to invoke the destructor and calculate the size for deallocation. If unique_ptr's destructor is instantiated where T is only forward-declared, compilation fails. std::shared_ptr captures the deleter (which knows how to destroy T) at construction time in the control block. Since the deleter is type-erased and stored separately, shared_ptr can later be destroyed where T is incomplete. This distinction is crucial for the Pimpl (Pointer to Implementation) idiom: shared_ptr allows hiding implementation details in source files while unique_ptr requires either complete types or explicit custom deleters defined where the implementation is visible.


Why does std::make_unique not support custom deleters, and what is the recommended alternative?

Answer: std::make_unique (introduced in C++14) provides exception-safe allocation but only returns std::unique_ptr<T> or std::unique_ptr<T[]>, which use std::default_delete. The function cannot deduce the deleter type from arguments because the deleter type must be part of the unique_ptr template signature, and factory functions cannot implicitly deduce custom deleter types without explicit template parameters. The recommended alternative is direct construction: std::unique_ptr<T, CustomDeleter>(new T(args), CustomDeleter{...}). This approach explicitly specifies the deleter type in the template while allowing custom resource cleanup logic, though it requires manual exception handling or careful construction order to maintain exception safety guarantees.


How does the Empty Base Optimization affect std::unique_ptr memory layout when using stateless deleters, and why is this unavailable to std::shared_ptr?

Answer: std::unique_ptr inherits from its deleter class when the deleter is a class type. If the deleter contains no data members (stateless), C++ applies the Empty Base Optimization (EBO), allowing the empty base subobject to occupy zero bytes. Consequently, sizeof(std::unique_ptr<T, StatelessDeleter>) equals sizeof(T*), achieving zero-overhead abstraction. std::shared_ptr cannot utilize EBO because it must support type erasure: any shared_ptr of the same T must have the same size regardless of the deleter. Therefore, shared_ptr stores the deleter in the heap-allocated control block rather than within the shared_ptr object itself. This design enables runtime polymorphism of deleters but forces a heap allocation and prevents the stack-space optimization that unique_ptr enjoys.