Unique Ownership (std::unique_ptr) and EBO
Unique Ownership (std::unique_ptr) and EBO
Section titled “Unique Ownership (std::unique_ptr) and EBO”std::unique_ptr is the default smart pointer for exclusive ownership of heap-allocated objects. It is zero-overhead relative to a raw pointer, supports custom deleters with Empty Base Optimization, And enforces move-only semantics that make ownership transfers explicit at the call site.
2.1 Definition
Section titled “2.1 Definition”std::unique_ptr<T> is a smart pointer that holds a heap-allocated object via exclusive ownership. Exactly one unique_ptr owns the pointed-to object at any time. When the unique_ptr is destroyed, The object is deleted [N4950 §20.11.1].
Layout (default deleter, no EBO savings):┌──────────────────┐│ T* ptr_ │ (1 pointer, 8 bytes on x86_64)└──────────────────┘sizeof(std::unique_ptr<T>) == sizeof(T*)2.2 Construction: std::make_unique
Section titled “2.2 Construction: std::make_unique”Always prefer std::make_unique<T>(args...) over new T(args...) [N4950 §20.11.3]. The reasons are:
- Exception safety:
make_uniqueperforms a single allocation. Expressions likef(unique_ptr<T>(new T), may_throw())can leak if evaluation order causesnew Tto succeed butmay_throw()throws before theunique_ptris constructed. - No raw
newexposure: Thenewexpression is hidden inside the factory, preventing accidental raw pointer use.
#include <memory>#include <string>
struct Widget { std::string name; int value; Widget(std::string n, int v) : name(std::move(n)), value(v) {}};
std::unique_ptr<Widget> create_widget() { return std::make_unique<Widget>("sensor", 42);}
void use_widget() { auto w = create_widget(); // w owns the Widget. When w goes out of scope, the Widget is deleted.}2.3 Move-Only Semantics
Section titled “2.3 Move-Only Semantics”unique_ptr deletes its copy constructor and copy assignment operator. Ownership can only be Transferred via move:
#include <memory>
void sink(std::unique_ptr<int> p) { // sink now owns the int}
void transfer_demo() { auto p = std::make_unique<int>(10);
// auto q = p; // ERROR: copy is deleted auto q = std::move(p); // OK: p is now nullptr, q owns the int
sink(std::move(q)); // OK: q is now nullptr, sink owns the int
// *p; // UB: p is nullptr}2.4 Custom Deleters
Section titled “2.4 Custom Deleters”std::unique_ptr<T, D> accepts a second template parameter: the deleter type D. The deleter is a callable invoked instead of delete when the unique_ptr is destroyed [N4950 §20.11.1.2].
When the deleter is stateless (empty class, no captured data), the compiler applies Empty Base Optimization (EBO) and the deleter consumes zero bytes:
#include <memory>#include <cstdio>
struct FileDeleter { void operator()(std::FILE* fp) const { if (fp) std::fclose(fp); }};
using UniqueFile = std::unique_ptr<std::FILE, FileDeleter>;
void custom_deleter_demo() { // sizeof(UniqueFile) == 8 (just the pointer; FileDeleter is empty, EBO applies) UniqueFile fp(std::fopen("data.bin", "rb")); if (!fp) return; // fp is automatically closed when it goes out of scope}When the deleter captures state (a lambda with captures), its size is added:
#include <memory>
void stateful_deleter_demo() { // This lambda captures nothing -> EBO applies, sizeof == 8 auto deleter1 = [](int* p) { delete[] p; }; std::unique_ptr<int, decltype(deleter1)> p1(new int[10], deleter1);
// This lambda captures a value -> sizeof == 8 (pointer) + 4 (int capture) int log_id = 42; auto deleter2 = [log_id](int* p) { // Log the deletion (for demonstration; std::println in C++23) delete[] p; }; std::unique_ptr<int, decltype(deleter2)> p2(new int[10], deleter2); // sizeof(p2) == 16 on most platforms}2.5 Array Specialization
Section titled “2.5 Array Specialization”std::unique_ptr<T[]> manages arrays. It calls delete[] instead of delete and provides operator[] instead of operator* and operator->:
#include <memory>#include <cstddef>
void array_demo() { auto arr = std::make_unique<int[]>(100); arr[0] = 42; // ~unique_ptr<int[]>() calls delete[] arr}2.6 unique_ptr with Polymorphism
Section titled “2.6 unique_ptr with Polymorphism”unique_ptr is the canonical way to manage polymorphic objects. The deleter calls delete on the Base-class pointer, which correctly invokes the derived-class destructor via virtual dispatch [N4950 §11.7.3]. The base class must have a virtual destructor.
#include <iostream>#include <memory>#include <vector>#include <string>
class Shape {public: virtual ~Shape() = default; virtual double area() const = 0; virtual std::string name() const = 0;};
class Circle : public Shape { double radius_;public: explicit Circle(double r) : radius_(r) {} double area() const override { return 3.14159265 * radius_ * radius_; } std::string name() const override { return "Circle(r=" + std::to_string(radius_) + ")"; }};
class Rectangle : public Shape { double w_, h_;public: Rectangle(double w, double h) : w_(w), h_(h) {} double area() const override { return w_ * h_; } std::string name() const override { return "Rect(" + std::to_string(w_) + "x" + std::to_string(h_) + ")"; }};
void draw_shapes(const std::vector<std::unique_ptr<Shape>>& shapes) { for (const auto& s : shapes) { std::cout << s->name() << " area=" << s->area() << "\n"; }}
int main() { std::vector<std::unique_ptr<Shape>> shapes; shapes.push_back(std::make_unique<Circle>(5.0)); shapes.push_back(std::make_unique<Rectangle>(3.0, 4.0)); shapes.push_back(std::make_unique<Circle>(1.5));
draw_shapes(shapes); // Each Shape"s correct destructor runs when the vector is destroyed. // Output: // Circle(r=5.000000) area=78.5398 // Rect(3.000000x4.000000) area=12 // Circle(r=1.500000) area=7.06858}2.7 unique_ptr as a Class Member
Section titled “2.7 unique_ptr as a Class Member”unique_ptr as a class member simplifies resource management and eliminates the need for manual delete in destructors. Because unique_ptr is move-only, the class itself becomes move-only unless you explicitly implement move operations.
#include <iostream>#include <memory>#include <string>#include <utility>
class Engine { std::string model_;public: explicit Engine(std::string model) : model_(std::move(model)) { std::cout << "Engine(" << model_ << ") constructed\n"; } ~Engine() { std::cout << "Engine(" << model_ << ") destroyed\n"; } std::string model() const { return model_; }};
class Car { std::string name_; std::unique_ptr<Engine> engine_;public: // Constructor: engine is passed as a unique_ptr (ownership transfer) Car(std::string name, std::unique_ptr<Engine> engine) : name_(std::move(name)), engine_(std::move(engine)) {}
// Rule of five: // Copy constructor: DELETED (unique_ptr is move-only) Car(const Car&) = delete; Car& operator=(const Car&) = delete;
// Move constructor: transfers engine ownership Car(Car&& other) noexcept : name_(std::move(other.name_)), engine_(std::move(other.engine_)) {}
// Move assignment: transfers engine ownership Car& operator=(Car&& other) noexcept { if (this != &other) { name_ = std::move(other.name_); engine_ = std::move(other.engine_); } return *this; }
// Destructor: engine_ is automatically deleted (no manual cleanup needed) ~Car() = default;
Engine* engine() const { return engine_.get(); } std::string name() const { return name_; }};
std::unique_ptr<Car> make_car(const std::string& name) { return std::make_unique<Car>( name, std::make_unique<Engine>("V8") );}
int main() { auto car = make_car("Mustang"); std::cout << car->name() << " has " << car->engine()->model() << "\n";
auto car2 = std::move(car); // ownership transfer std::cout << car2.name() << " has " << car2.engine()->model() << "\n"; // car is now nullptr (engine was moved)}2.8 unique_ptr in Containers
Section titled “2.8 unique_ptr in Containers”std::vector<std::unique_ptr<T>> is the standard pattern for polymorphic collections with unique Ownership. Elements must be moved in; the vector takes ownership.
#include <iostream>#include <memory>#include <vector>#include <algorithm>
struct Task { virtual ~Task() = default; virtual void execute() const = 0;};
struct PrintTask : Task { std::string msg_; explicit PrintTask(std::string msg) : msg_(std::move(msg)) {} void execute() const override { std::cout << "Print: " << msg_ << "\n"; }};
struct ComputeTask : Task { int a_, b_; ComputeTask(int a, int b) : a_(a), b_(b) {} void execute() const override { std::cout << "Compute: " << a_ << " + " << b_ << " = " << (a_ + b_) << "\n"; }};
int main() { std::vector<std::unique_ptr<Task>> tasks;
// Push via make_unique (move semantics) tasks.push_back(std::make_unique<PrintTask>("hello")); tasks.push_back(std::make_unique<ComputeTask>(3, 4)); tasks.push_back(std::make_unique<PrintTask>("world"));
// Emplace (constructs in-place) tasks.emplace_back(std::make_unique<ComputeTask>(10, 20));
// Execute all tasks for (const auto& task : tasks) { task->execute(); }
// Remove a task by index (unique_ptr handles cleanup) tasks.erase(tasks.begin() + 1); // removes ComputeTask(3,4)
std::cout << "After removal (" << tasks.size() << " tasks):\n"; for (const auto& task : tasks) { task->execute(); }
// Sort: unique_ptr compares the pointer value, not the pointed-to object. // To sort by the object, use a custom comparator. // std::ranges::sort(tasks, [](const auto& a, const auto& b) { ... });}2.9 unique_ptr and Incomplete Types (Pimpl Idiom)
Section titled “2.9 unique_ptr and Incomplete Types (Pimpl Idiom)”unique_ptr can hold a pointer to an incomplete type in a header file, as long as the deleter is the default (stateless) deleter [N4950 §20.11.1]. This enables the pimpl (pointer to Implementation) idiom: hide implementation details from the header, reducing compilation Dependencies.
The key constraint: the destructor (or any function that calls delete) must be defined in the Source file where the type is complete. A default-declared destructor in the header will attempt to Generate the body at each call site, causing errors.
// ---- widget.h ----#ifndef WIDGET_H#define WIDGET_H
#include <memory>#include <string>
class Widget {public: Widget(std::string name); ~Widget(); // MUST be declared (not defaulted) in the header
Widget(const Widget&) = delete; Widget& operator=(const Widget&) = delete;
Widget(Widget&&) noexcept; Widget& operator=(Widget&&) noexcept;
void process() const; std::string name() const;
private: // Incomplete type: Impl is forward-declared, not defined here. struct Impl; std::unique_ptr<Impl> impl_;};
#endif// ---- widget.cpp ----#include "widget.h"#include <iostream>
// Complete definition of Impl, hidden from widget.h consumersstruct Widget::Impl { std::string name_; std::vector<int> data_; // ... potentially heavy implementation details
explicit Impl(std::string name) : name_(std::move(name)), data_(1000, 0) {}};
Widget::Widget(std::string name) : impl_(std::make_unique<Impl>(std::move(name))) {}
// Destructor defined HERE where Impl is completeWidget::~Widget() = default;
Widget::Widget(Widget&&) noexcept = default;Widget::Widget& operator=(Widget&&) noexcept = default;
void Widget::process() const { std::cout << "Processing " << impl_->name_ << " (data size: " << impl_->data_.size() << ")\n";}
std::string Widget::name() const { return impl_->name_; }// ---- main.cpp ----#include "widget.h"
int main() { Widget w("sensor-01"); w.process(); // Output: Processing sensor-01 (data size: 1000)}2.10 sizeof(unique_ptr) Comparison Across Types
Section titled “2.10 sizeof(unique_ptr) Comparison Across Types”The size of unique_ptr depends on the deleter type. With the default deleter (stateless, zero-size via EBO), sizeof(unique_ptr<T>) == sizeof(T*) on all major implementations [N4950 §20.11.1].
#include <iostream>#include <memory>#include <cstdio>#include <functional>
struct FileDeleter { void operator()(std::FILE* fp) const { if (fp) std::fclose(fp); }};
struct CaptureDeleter { int log_level_; void operator()(int* p) const { delete[] p; }};
struct LargeDeleter { double a_, b_, c_, d_; void operator()(int* p) const { delete[] p; }};
int main() { std::cout << "sizeof(unique_ptr<int>): " << sizeof(std::unique_ptr<int>) << " bytes\n"; // Output: 8 bytes (one pointer)
std::cout << "sizeof(unique_ptr<double>): " << sizeof(std::unique_ptr<double>) << " bytes\n"; // Output: 8 bytes (one pointer; T doesn't affect size)
std::cout << "sizeof(unique_ptr<int[]>): " << sizeof(std::unique_ptr<int[]>) << " bytes\n"; // Output: 8 bytes (array specialization, same deleter)
std::cout << "sizeof(unique_ptr<FILE, FileDeleter>): " << sizeof(std::unique_ptr<std::FILE, FileDeleter>) << " bytes\n"; // Output: 8 bytes (FileDeleter is empty, EBO applies)
std::cout << "sizeof(unique_ptr<int, CaptureDeleter>): " << sizeof(std::unique_ptr<int, CaptureDeleter>) << " bytes\n"; // Output: 16 bytes (8 pointer + 4 captured int + padding)
std::cout << "sizeof(unique_ptr<int, LargeDeleter>): " << sizeof(std::unique_ptr<int, LargeDeleter>) << " bytes\n"; // Output: 40 bytes (8 pointer + 32 deleter state)
std::cout << "sizeof(unique_ptr<int, std::function<void(int*)>>): " << sizeof(std::unique_ptr<int, std::function<void(int*)>>) << " bytes\n"; // Output: 48 bytes (8 pointer + ~40 for std::function)}| Deleter Type | State | sizeof(unique_ptr<int, D>) | EBO |
|---|---|---|---|
Default (std::default_delete) | Empty | 8 | Yes |
| Empty stateless struct | Empty | 8 | Yes |
| Lambda with 0 captures | Empty | 8 | Yes |
Lambda with 1 int capture | 4 bytes | 16 (aligned) | No |
std::function | ~40 bytes | ~48 | No |
2.11 make_unique vs Raw new
Section titled “2.11 make_unique vs Raw new”Beyond exception safety, make_unique has another advantage: it prevents accidental use of the raw Pointer. The new expression returns a raw pointer that can escape before being wrapped:
#include <memory>#include <iostream>
// BAD: raw pointer escapes, can be used after deletionvoid bad_pattern() { std::unique_ptr<int> p(new int(42)); int* raw = p.get(); // ... later code could use 'raw' after 'p' is destroyed or reset}
// GOOD: make_unique never exposes a raw pointer in the expressionvoid good_pattern() { auto p = std::make_unique<int>(42); int* raw = p.get(); // explicit, intentional // ... but at least the creation was exception-safe}
// DANGER: exception-unsafe pattern with multiple allocationsvoid dangerous(int x, int y) { // If 'new int(y)' throws, 'new int(x)' leaks! // auto p = std::unique_ptr<int>(new int(x)); // throw std::runtime_error("oops"); // leak! // auto q = std::unique_ptr<int>(new int(y));
// SAFE: make_unique + separate statements auto p = std::make_unique<int>(x); auto q = std::make_unique<int>(y); // If this throws, both p and q are cleaned up. (void)p; (void)q;}2.12 Reset and Release
Section titled “2.12 Reset and Release”unique_ptr provides reset() and release() for explicit ownership management:
#include <iostream>#include <memory>
int main() { auto p = std::make_unique<int>(42); std::cout << *p << "\n"; // 42
// reset: destroy current object and take ownership of a new one p.reset(new int(99)); std::cout << *p << "\n"; // 99
// reset(nullptr): destroy current object, become null p.reset(); std::cout << (p == nullptr) << "\n"; // 1 (true)
// release: surrender ownership WITHOUT deleting p = std::make_unique<int>(7); int* raw = p.release(); // p becomes nullptr, raw points to the int std::cout << *raw << "\n"; // 7 std::cout << (p == nullptr) << "\n"; // 1 (true) delete raw; // caller is now responsible for cleanup}Intuition
Section titled “Intuition”Exclusive ownership: std::unique_ptr is like a deed to a house, only one person can own it at a time, and when they leave, the house is demolished.
Why it matters: unique_ptr provides automatic memory management without garbage collection overhead, preventing memory leaks and dangling pointers.
The key insight: unique_ptr has zero overhead compared to raw pointers, it’s the default choice for heap-allocated objects.
Common Pitfalls
Section titled “Common Pitfalls”Using unique_ptr with Arrays Incorrectly
Section titled “Using unique_ptr with Arrays Incorrectly”unique_ptr<T> (singular) calls delete. Not delete[]. Using it with array-allocated memory is Undefined behavior:
#include <memory>
int main() { // WRONG: unique_ptr<int> calls delete, not delete[] // std::unique_ptr<int> p(new int[10]); // UB on destruction
// CORRECT: use unique_ptr<T[]> for arrays std::unique_ptr<int[]> arr(new int[10]);
// CORRECT: prefer std::vector for dynamic arrays // std::vector<int> v(10);}Forgetting to Move When Transferring Ownership
Section titled “Forgetting to Move When Transferring Ownership”#include <memory>#include <vector>
void consume(std::unique_ptr<int> p) { // takes ownership}
int main() { auto p = std::make_unique<int>(42);
std::vector<std::unique_ptr<int>> v; // v.push_back(p); // ERROR: copy is deleted v.push_back(std::move(p)); // OK: moves ownership into the vector
// consume(p); // ERROR: copy is deleted consume(std::move(p)); // ERROR: p is already null from the move above! // consume(std::move(v[0])); // OK}Returning unique_ptr from Functions
Section titled “Returning unique_ptr from Functions”Returning unique_ptr by value does not require std::move at the return site. Named Return Value Optimization (NRVO) and implicit move semantics handle it:
#include <memory>
std::unique_ptr<int> make_value() { auto p = std::make_unique<int>(42); return p; // OK: implicit move (C++11+). Do NOT write return std::move(p); // Writing std::move(p) here prevents NRVO and is a pessimization.}
std::unique_ptr<int> make_value_alt() { return std::make_unique<int>(42); // OK: prvalue, no move needed}See Also
Section titled “See Also”- RAII Patterns
- Shared Ownership (std::shared_ptr) and Control Block
- Weak References (std::weak_ptr)
- Common Pitfalls
Summary
Section titled “Summary”This topic covers the essential concepts and techniques related to unique ownership (std::unique_ptr) and ebo, including key principles and practical applications.
Key concepts include:
- core concepts and definitions
- key principles and frameworks
- practical applications
- common techniques and methods
- evaluation and critical analysis