std::unique_ptr: Complete C++ Guide to Exclusive Ownership
이 글의 핵심
A practical deep dive into std::unique_ptr: how exclusive ownership works, every major API, custom deleters, polymorphic factories, PIMPL, containers, and the mistakes that cause leaks and UB.
Introduction: RAII and exclusive ownership
In modern C++, heap resources and many system handles are usually managed with Resource Acquisition Is Initialization (RAII): the constructor acquires, the destructor releases, and both ordinary returns and exception stack unwinding run the same cleanup. Once you are used to that model, raw new / delete scattered through business logic starts to feel fragile—you always wonder who deletes, and on which error path.
std::unique_ptr is how the standard library spells exclusive ownership for a heap object (or, with a custom deleter, anything that is freed by a well-defined function). There is at most one owner at a time: copies are not allowed, and when ownership moves, it moves—which matches how you actually want a single responsibility to change hands in real code.
If you are still thinking in terms of a bare T*, a std::unique_ptr<T> is the type that says “this scope or object owns this address.” Next to std::shared_ptr, you skip reference-count atomics and keep everything local—which is what you want when nothing else needs to keep the object alive.
This guide still goes deep—construction, deleters, arrays, the usual API surface, factories, PIMPL, polymorphic bases, containers, allocators, exception safety, and mistakes that show up in review—but the angle is practical: what I reach for, what I have fixed after the fact, and what still confuses people the first time.
Migrating from raw pointers to unique_ptr
If you are coming from a codebase where Widget* was “the” ownership type, migration is not usually a one-line find-and-replace. You start with the invariants you already have: who is allowed to delete this pointer, and when? Once that is one place only, you wrap that place in a unique_ptr and let the rest of the code see non-owning T* or T& for short-lived work.
A pattern I have used more than once: find call sites that return a newly allocated object. Those become return std::make_unique<Widget>(...); and the return type becomes std::unique_ptr<Widget>. Callers that only used the object get a Widget& or const Widget& parameter instead of a pointer, which makes “borrow vs own” obvious at a glance. Callers that owned the old pointer need to be updated to std::move into their own unique_ptr or to store the new return type in a member. The compile errors you get are almost helpful—they list exactly which ownership edges were never spelled out in the first place.
Another path is PIMPL or private implementation structs: the header holds std::unique_ptr<Impl> and the implementation file owns the concrete struct Impl. The migration story there is not “add smart pointers everywhere” but “move the only delete of Impl into the destructor the compiler calls for you,” which cuts down a whole class of “forgot the branch that frees” bugs.
Interop (C libraries, different DLLs, malloc/free) is where I slow down. A raw pointer in those APIs might still be correct, but the deleter on the unique_ptr must match how the block was allocated—new with default_delete, new[] with array form, or a custom deleter for std::free. Migrating a wrong pairing from raw code just moves the same heap corruption into a fancier type.
Testing as you go: I lean on ASan/UBSan in CI, and I watch for release()—every use should read like a short comment (“hand off to C API that will call X”) rather than a habit. The goal of the migration is not “every pointer is a unique_ptr” but “every owning edge is unambiguous, and the rest are explicit borrows,” which is where unique_ptr shines.
Basic usage: creation, reset, and release
Construction
The typical way to create a unique_ptr to a single object of type T is the non-throwing, exception-safe factory C++14 std::make_unique<T>(args...) (see a dedicated section below). You can also construct from a raw pointer:
#include <memory>
struct Widget { int v; explicit Widget(int x) : v(x) {} };
std::unique_ptr<Widget> a(new Widget(42));
std::unique_ptr<Widget> b = std::make_unique<Widget>(7);
std::unique_ptr<Widget> c; // null — no object
A default-constructed unique_ptr holds a null pointer and is cheap to pass around as “no resource.”
reset
p.reset(new_value) or p.reset() destroys the currently managed object (if any) and replaces it. With no argument, the pointer becomes null after destroying the old object.
p.reset(new Widget(1));
p.reset(); // calls ~Widget() if owned
release
p.release() gives up ownership without deleting: it returns the raw pointer and sets the unique_ptr to null. The caller is then responsible for whatever cleanup policy applies to that address—easy to get wrong if the pointer must be deleted or passed to a C API. Prefer std::move into another unique_ptr, or a well-named function that re-wraps, instead of release unless you are interfacing with legacy code.
std::make_unique (C++14 and beyond)
std::make_unique<T>(args...) allocates with new T(std::forward<Args>(args)...) (or value-initializes for make_unique<T>() on non-array T) and returns std::unique_ptr<T>. Benefits:
- Exception safety when used in complex expressions: the pointer is already owned by a
unique_ptras soon as allocation succeeds, so intermediate exceptions cannot leak the object. - Shorter, clearer than
unique_ptr<T>(new T(...))for the common case. - For arrays,
make_unique<T[]>(n)(C++14) and C++20make_uniqueimprovements forT[n]and bounded arrays have refined overload sets—always include<memory>and a recent language standard in yourCMake//stdsettings when relying on the latestmake_uniquebehavior.
Example:
#include <memory>
struct Resource { int id; explicit Resource(int i) : id(i) {} };
std::unique_ptr<Resource> f() {
return std::make_unique<Resource>(100);
}
If Resource’s constructor can throw, make_unique still provides strong guarantees for the returned unique_ptr in the sense that you never return a “leaked new” in typical patterns.
Custom deleters: function, lambda, and functor
std::unique_ptr has a second template parameter, the deleter type D (default std::default_delete<T>). Non-type stateless deleters can participate in the empty base optimization and keep unique_ptr the size of one pointer. Stateful deleters (e.g. storing a FILE* closer or a function pointer) can increase the size of the unique_ptr object.
Function pointer
#include <cstdlib>
#include <memory>
struct FreeDeleter {
void operator()(void* p) const { std::free(p); }
};
// e.g. std::unique_ptr<void, void (*)(void*)> p(
// static_cast<void*>(std::malloc(n)), &std::free);
using MallocPtr = std::unique_ptr<void, void (*)(void*)>;
(Use a void*-based unique_ptr with free, not new[]/delete[]; a struct functor is often cleaner than a raw function pointer for non-void element types.)
Lambda
#include <memory>
#include <cstdio>
// Deleter type is int(*)(FILE*); &std::fclose matches. Empty unique_ptr does not call fclose.
std::unique_ptr<FILE, int (*)(FILE*)> make_file(const char* path) {
FILE* f = std::fopen(path, "r");
if (!f) {
return {nullptr, &std::fclose}; // or throw
}
return {f, &std::fclose};
}
A captureless lambda can also decay to a function pointer (e.g. +[](FILE* x) { return std::fclose(x); }) when a stateful deleter is not required; a storing deleter (with captures) is a distinct type and must match the unique_ptr’s second template argument.
Functor class
#include <memory>
struct WindowHandle {
void* h;
static void close(void* p);
};
struct CloseWindow {
void operator()(void* p) const { WindowHandle::close(p); }
};
using WindowPtr = std::unique_ptr<void, CloseWindow>;
Rule of thumb: put API-specific cleanup in one deleter type, use using aliases to name the smart pointer, and never mix new with free or malloc with delete.
Array support: std::unique_ptr<T[]>
For dynamically sized arrays on the heap, std::unique_ptr<T[]> uses delete[] via std::default_delete<T[]>.
#include <memory>
#include <algorithm>
int main() {
const std::size_t n = 1024;
std::unique_ptr<int[]> buf = std::make_unique<int[]>(n);
std::fill_n(buf.get(), n, 0);
}
Do not use unique_ptr<T> (single-object form) with new T[n]: that is undefined behavior (wrong delete form). If the size is known at compile time, prefer std::array or a stack buffer; for indefinite extent and std algorithms, a std::vector is usually the better default, but unique_ptr<T[]> remains appropriate for low-level buffers, interop, or non-default construction patterns.
Note (C++17): std::make_unique for T[] can value-initialize or uninitialized elements depending on overload and T; read your standard version’s <memory> wording when micro-optimizing initialization (and prefer vector with explicit resize for clarity in application code).
Move semantics: transferring ownership
unique_ptr is move-only: the copy constructor and copy assignment are deleted. Ownership moves with std::move or Rvalue contexts.
#include <memory>
#include <utility>
std::unique_ptr<int> a = std::make_unique<int>(1);
// std::unique_ptr<int> b = a; // error: copy deleted
std::unique_ptr<int> b = std::move(a);
// a is now "empty" (null)
assert(!a);
assert(*b == 1);
Functions that take ownership should accept std::unique_ptr<T> by value and let callers std::move into the parameter, or take unique_ptr& in rare out-parameter patterns. Functions that only use the object should take T&, T*, or const T& and leave ownership with the caller.
nullptr comparison and boolean validity
A unique_ptr converts to boolean in conditions: it is “true” if it owns a non-null pointer. You can also compare to nullptr (and to other unique_ptr values with the comparison operators; see below).
#include <memory>
#include <cassert>
int main() {
std::unique_ptr<int> p;
if (!p) { /* empty */ }
p = std::make_unique<int>(3);
assert(p != nullptr);
p.reset();
assert(p == nullptr);
}
Avoid “smart boolean” usage that obscures the fact you are really testing emptiness vs a zero integer value; for that distinction, be explicit: empty unique_ptr vs a valid pointer to int{0}.
get(): raw pointer access
p.get() returns the raw T* (or T* in the polymorphic unique_ptr<B, D> case) without transferring ownership. Use it to pass a non-owning view to C APIs, legacy callbacks, or short-lived use—as long as the unique_ptr outlives the use or you document a narrower lifetime contract.
Never delete the result of get() on a unique_ptr that is still alive—double-free. Never store that raw pointer in a long-lived structure unless you have a clear non-owning design (often a raw pointer is OK if the owner is a stable parent object, but you must then prevent use-after-free).
void legacy_consume(int* p);
void f() {
auto u = std::make_unique<int>(9);
legacy_consume(u.get());
}
operator-> and operator*
unique_ptr mimics a pointer: p-> for members and *p for the lvalue of the pointee, when non-null. Dereferencing a null unique_ptr is undefined behavior—as with a raw null pointer.
struct X { int m; };
std::unique_ptr<X> p = std::make_unique<X>();
p->m = 1;
(*p).m = 2;
Constness propagates: const std::unique_ptr<const T> gives const access through operator*.
release(): relinquish ownership (carefully)
p.release() returns the raw pointer and leaves p empty without calling the deleter. This is the escape hatch for:
- Transferring to an API that will call
delete(or a matching deleter) once, after you are sure the rest of the program will notresetthe sameunique_ptr. - C APIs that take ownership in a very specific way.
Anti-pattern: release followed by manual delete on every path—if you can keep a unique_ptr in charge until the end of a scope, you should.
#include <memory>
// Legacy API that takes char* and calls delete[] internally — rare; prefer fixing the API
void hand_off(char* p);
void g() {
std::unique_ptr<char[]> s = std::make_unique<char[]>(16u);
// ... fill s
char* raw = s.release();
hand_off(raw);
}
If you forget the legacy API’s contract, you will leak or double-free.
reset(): replace the managed object
reset destroys the current object (if any) and optionally adopts a new pointer. It is the right tool for rebinding a unique_ptr to a different allocation, or for clearing it.
#include <memory>
int main() {
std::unique_ptr<int> p = std::make_unique<int>(1);
p.reset(new int(2));
p.reset();
}
Because exceptions in destructors and user code between release of the old and adoption of the new are subtle, make_unique and move assignment are often clearer for simple “replace the whole thing” logic.
swap(): exchange pointers
p.swap(q) or std::swap(p, q) efficiently exchanges the stored pointers and deleter state. Useful in algorithms and reordering vector<unique_ptr<...>> elements.
#include <memory>
#include <utility>
int main() {
auto a = std::make_unique<int>(1);
auto b = std::make_unique<int>(2);
swap(a, b);
}
Comparison operators: ==, !=, <, and friends
unique_ptr supports lexicographic comparison: pointer values are compared, and for unique_ptrs with the same deleter, consistent ordering is provided in C++20 as three-way and legacy relational operators, depending on your standard. In practice you most often:
- compare to
nullptr - compare get() to another raw pointer
- or compare
unique_ptrtounique_ptrto sort a container of them
Do not rely on “which address is less” for ownership decisions—ownership is about invariants and lifetimes, not address ordering.
#include <memory>
bool same_resource(const std::unique_ptr<int>& a, const std::unique_ptr<int>& b) {
return a.get() == b.get();
}
Factory functions: returning std::unique_ptr
The idiomatic way to return a heap object with exclusive ownership is:
#include <memory>
#include <stdexcept>
#include <string>
struct Plugin {
std::string name;
explicit Plugin(std::string n) : name(std::move(n)) {}
virtual ~Plugin() = default;
virtual int run() = 0;
};
struct EchoPlugin : Plugin {
explicit EchoPlugin(std::string n) : Plugin(std::move(n)) {}
int run() override { return 0; }
};
std::unique_ptr<Plugin> make_plugin(const std::string& type) {
if (type == "echo")
return std::make_unique<EchoPlugin>("echo");
throw std::invalid_argument("unknown plugin");
}
Caller receives a single owner; the destructor of the most-derived type runs through virtual~Plugin() on delete because the base class must be polymorphic and virtual destructor (or a known, non-base-owned design—rare in factory patterns).
Never return std::unique_ptr<Derived> as std::unique_ptr<Base> with incompatible deleters; use the same default deleter and virtual destructor.
PIMPL idiom with std::unique_ptr
Pointer to implementation (PIMPL) keeps compile-time dependencies small by hiding private members in a single struct in the .cpp file.
Header (interface):
// widget.hpp
#pragma once
#include <memory>
class Widget {
public:
Widget();
~Widget();
Widget(Widget&&) noexcept;
Widget& operator=(Widget&&) noexcept;
// copy if you need copy; often deleted for handle types
Widget(const Widget&); // if implemented in .cpp
Widget& operator=(const Widget&);
void do_work();
private:
struct Impl;
std::unique_ptr<Impl> pimpl_;
};
Source:
// widget.cpp
#include "widget.hpp"
struct Widget::Impl { int x = 0; };
Widget::Widget() : pimpl_(std::make_unique<Impl>()) {}
Widget::~Widget() = default; // must see Impl — defined here
Widget::Widget(Widget&&) noexcept = default;
Widget& Widget::operator=(Widget&&) noexcept = default;
void Widget::do_work() { ++pimpl_->x; }
Why = default the destructor in the .cpp: unique_ptr<Impl> is an incomplete type in the header; the destructor must be instantiated where Impl is complete, otherwise the program is ill-formed (often a hard error about delete on incomplete type).
Move operations can be = default in the .cpp as well, once the destructor is correctly placed, so the compiler can generate the right special members.
This pattern pairs naturally with the Rule of Zero in the public API while keeping one indirection to the private state.
Polymorphism: base-class pointers and virtual behavior
A std::unique_ptr<Base> can own a Derived, if virtual ~Base() is present (or you know the static type and do not delete through the base—fragile for polymorphism).
#include <memory>
struct Base { virtual ~Base() = default; virtual int f() = 0; };
struct D1 : Base { int f() override { return 1; } };
struct D2 : Base { int f() override { return 2; } };
std::unique_ptr<Base> make(int code) {
if (code == 1) return std::make_unique<D1>();
return std::make_unique<D2>();
}
Interfaces in headers often expose a factory and non-copyable unique_ptr-based ownership, which prevents object slicing on assignment.
Containers: std::vector<std::unique_ptr<T>>
Move-only types require move-aware container operations. std::vector<std::unique_ptr<Widget>> works well: emplace or push_back(std::make_unique<Widget>(...)), and algorithms that move elements.
#include <memory>
#include <vector>
struct W { int id; explicit W(int i) : id(i) {} };
int main() {
std::vector<std::unique_ptr<W>> v;
v.push_back(std::make_unique<W>(1));
v.emplace_back(std::make_unique<W>(2));
for (auto& p : v)
p->id += 10;
}
Sorting and erase move elements; do not copy unique_ptr into another container without std::move.
Parallelism: moving unique_ptr out of a container for worker threads is a common “hand off exclusive work item” idiom, guarded by a mutex or a concurrent queue of unique_ptr (e.g. std::queue<std::unique_ptr<Job>> with care).
Custom allocators and std::unique_ptr
The standard does not provide a single allocate_unique in all C++17 implementations (C++20/C++23 may add helpers depending on the platform); a portable pattern is a unique_ptr with a custom deleter that pairs the allocator’s deallocate (and optionally stores the allocator, at the cost of size).
#include <memory>
#include <cstddef>
template <class T, class Alloc>
struct Deleter {
Alloc a;
void operator()(T* p) const {
using Tr = std::allocator_traits<Alloc>;
Tr::destroy(a, p);
Tr::deallocate(a, p, 1);
}
};
template <class T, class Alloc, class... Args>
std::unique_ptr<T, Deleter<T, Alloc>> allocate_unique(Alloc& a, Args&&... args) {
using Tr = std::allocator_traits<Alloc>;
T* p = Tr::allocate(a, 1);
try {
Tr::construct(a, p, std::forward<Args>(args)...);
} catch (...) {
Tr::deallocate(a, p, 1);
throw;
}
Deleter<T, Alloc> d{a};
return {p, d};
}
In practice many codebases use std::vector with a polymorphic allocator (std::pmr in C++17) for contiguous data; pool allocators often wrap malloc/free-style with custom deleters on unique_ptr. Always document where the memory came from and which deleter is legal.
Exception safety: strong guarantee patterns
std::make_uniquein a return statement: one allocation, immediately owned—no “raw new leak on exception in surrounding code” in the usual factory pattern.- Function try blocks and RAII members: a class that only holds
unique_ptrmembers often gets default special members and destructors that cannot throw in well-behaved deleters. - Never do two raw
newcalls to initialize twounique_ptrs in one statement; usemake_uniquetwice in separate variables or a struct ofunique_ptrs, or a helper.
#include <memory>
// Bad: if second new throws, first might leak in old-style line:
// f(std::unique_ptr<int>(new int(1)), may_throw());
// (some fixes via evaluation order; still prefer make_unique)
// Good: strong cleanup with unique_ptrs
struct Pair {
std::unique_ptr<int> a;
std::unique_ptr<int> b;
Pair() : a(std::make_unique<int>(1)), b(std::make_unique<int>(2)) {}
};
For notable C++11-era guidance, the constructor issue with shared_ptr + new + arbitrary expressions is the classic make_shared / make_unique story; the same “own immediately” style applies to unique_ptr.
Performance: what “zero overhead” means
unique_ptrwithdefault_delete: same size as a rawT*, no vtable, no refcount.- Destruction is typically one
delete(ordelete[]for array form) — same as a manual delete at scope exit. - Custom deleters that are function pointers or large functors can increase the object size of the
unique_ptritself; stateless class deleters are often “empty” and subsumed in the EBO, but not always in every implementation—measure if it matters in hot paths. - Do not pass
unique_ptrby const reference to sink it—taking it by value and moving is the usual pattern; avoid unnecessarymovein return (named return is often automatically moved).
Micro-optimization rarely starts with “replace unique_ptr with new” — start with algorithms, allocations count, and cache behavior; keep unique_ptr for correctness.
Common mistakes: what not to do
- Mixing
new[]withdelete(non-arrayunique_ptr) ornewwithdelete[]. - Dangling
get()or raw pointers that outlive theunique_ptrowner. release()without a guaranteed cleanup path in the same logical ownership story.- Deleting the result of
get()while theunique_ptrstill owns the same address. - Polymorphic base with no virtual destructor while deleting through
std::unique_ptr<Base>(undefined behavior in typical delete-via-base scenarios). - Passing
unique_ptr&to code that stores the rawget()longer than theunique_ptr’s scope. - Copying
shared_ptrinto places whereunique_ptrwas enough, adding atomic cost and accidental lifetime extension. - Thread A
resets while Thread B usesget()with no synchronization — data race and potential use-after-free.
Each of these is fixable with clear ownership rules, short-lived raw views, and synchronization where lifetimes are shared at the thread level (often shared_ptr + mutex, or message passing unique_ptr between stages).
What I always do
When I have a say in the code, I default to std::make_unique for single objects on the heap. I care less about saving a line than about one place where a raw new might interact badly with the rest of an expression, and the pointer is owned as soon as the allocation succeeds. For APIs that are meant to take ownership, I take std::unique_ptr by value so the caller has to std::move and the contract is obvious; if the function only uses the object, I take T&, T*, or const T& and do not touch ownership.
On polymorphic bases, I do not skip a virtual destructor: if a std::unique_ptr<Base> is going to own a Derived, deleting through the base has to be defined. For PIMPL, I put ~MyType() in the .cpp file where the nested Impl is complete, often = default, so the generated destructor sees a full Impl and the default deleter is legal. For dynamic arrays, I usually reach for std::vector first; I only use unique_ptr<T[]> when I have a concrete reason (interop, a fixed buffer with unusual lifetime, and so on).
I treat get() as a borrow that must not outlast the unique_ptr unless the architecture really means that, in which case I would rather name the relationship than stash raw pointers in random structs. I use release() when an external API’s contract is “you give us this pointer and we will free it the right way”—and I treat that like a code smell until the free path is written down next to the call.
At boundaries—C, DLLs, custom allocators—I write down who allocates and who frees. Mixing new/delete, new[]/delete[], or malloc/free is not a style issue; it is a correctness issue. In containers, I remember that unique_ptr is move-only: sort, erase, and push_back need moves, not copies. That is a feature: the type system stopped an accidental double owner.
In CI, I still want ASan/UBSan and sensible clang-tidy rules around smart pointers. The habits above are what keep me from debugging the same ownership bug twice; the tools catch what the habits miss.
Common confusions
People often conflate “I have a pointer” with “I own it.” A double free when the program ends usually means something still calls delete (or a C free) on an address a unique_ptr already deleted—there should be a single owner and a single deleter on that path. If a factory “leaks,” the story is often release() to an API that does not free the way you think, or a C wrapper that never frees; Valgrind, ASan, and reading the C documentation fix more than hunches.
A delete of an incomplete type for PIMPL usually means the destructor is inlined in the header where Impl is only forward-declared. Moving ~Class() to the .cpp (with = default there) is the fix—boring, but the error message can send you in circles until you have seen it once.
Slicing and wrong runtime destructors show up when you treat stack Derived as Base in the wrong way, or when virtual ~Base() is missing. Factories that return std::unique_ptr<Base> with std::make_unique<Derived>() and a virtual dtor on the base are the pattern I actually want in that situation.
Spontaneous crashes in threaded code, when one thread resets and another still uses get(), are classic data races. You either pass unique_ptr between stages with clear handoff (often with a queue and a mutex) or, if the lifetime really is shared, you reach for something like std::shared_ptr and still synchronize access to the data, not the pointer mechanics alone.
If vector will not take a unique_ptr, the usual gotcha is an attempted copy or a place where a template deduced a copy. Use emplace_back with std::make_unique, or push_back(std::move(p)). Dereferencing an empty unique_ptr is the same as dereferencing a null raw pointer: UB. Check with if (p) (or a guard) when emptiness is possible.
A forest of different lambda deleter types can bloat object code; I consolidate with named functor deleters and a using alias per resource family. And at a DLL boundary, if one side allocates with new and the other releases with a mismatched delete and runtime, you can corrupt the heap; I keep allocate and free in one module or export a C-style free function that matches the allocation story.
Bottom line: most “mysterious” unique_ptr bugs are the old bugs—who deletes, and when?—now with better compiler errors when you do own the type system.
Real examples: end-to-end code
The following is a self-contained illustration tying together RAII ownership, a virtual interface, a PIMPL-style component, a vector of unique_ptr, swap for reordering, and nullptr handling. It is not minimalistic on purpose: it is meant to read like a small subsystem you might see in a plugin host or tooling binary.
#include <algorithm>
#include <cstddef>
#include <iostream>
#include <memory>
#include <string>
#include <utility>
#include <vector>
// --- 1) Polymorphic interface (virtual destructor is mandatory) ---
class Task {
public:
virtual ~Task() = default;
virtual std::string name() const = 0;
virtual int run(int x) = 0;
};
class AddOne final : public Task {
public:
explicit AddOne(std::string n) : name_(std::move(n)) {}
std::string name() const override { return name_; }
int run(int x) override { return x + 1; }
private:
std::string name_;
};
class MulTwo final : public Task {
public:
explicit MulTwo(std::string n) : name_(std::move(n)) {}
std::string name() const override { return name_; }
int run(int x) override { return x * 2; }
private:
std::string name_;
};
// --- 2) Factory returning std::unique_ptr<Base> ---
std::unique_ptr<Task> make_task(const std::string& type, std::string label) {
if (type == "add")
return std::make_unique<AddOne>(std::move(label));
if (type == "mul")
return std::make_unique<MulTwo>(std::move(label));
return nullptr;
}
// --- 3) PIMPL (destructor in .cpp pattern simulated in one file) ---
class Pipeline {
struct Impl {
int counter = 0;
std::vector<std::unique_ptr<Task>> steps;
};
public:
Pipeline() : pimpl_(std::make_unique<Impl>()) {}
// In a real split compilation unit, you would = default the destructor
// in the .cpp file where class Pipeline::Impl { ... } is complete.
~Pipeline() = default;
Pipeline(Pipeline&&) noexcept = default;
Pipeline& operator=(Pipeline&&) noexcept = default;
Pipeline(const Pipeline&) = delete;
Pipeline& operator=(const Pipeline&) = delete;
void add(std::unique_ptr<Task> t) {
if (!t) return;
pimpl_->steps.push_back(std::move(t));
}
int execute(int seed) {
int v = seed;
for (auto& p : pimpl_->steps) {
if (p) {
v = p->run(v);
++pimpl_->counter;
}
}
return v;
}
std::size_t size() const { return pimpl_->steps.size(); }
void move_front_to_back() {
if (pimpl_->steps.size() < 2) return;
// swap: exclusive ownership, no copies
std::swap(pimpl_->steps.front(), pimpl_->steps.back());
}
private:
std::unique_ptr<Impl> pimpl_;
};
// --- 4) Array buffer + move-only pipeline ---
int main() {
Pipeline p;
p.add(make_task("add", "a1"));
p.add(make_task("mul", "m1"));
p.add(nullptr); // safely ignored in add()
// seed=3 -> +1 = 4, *2 = 8
std::cout << "result = " << p.execute(3) << '\n';
p.move_front_to_back();
// Different order, still valid unique_ptrs inside vector
std::cout << "after swap, result = " << p.execute(3) << '\n';
// Small unique_ptr to array (value-init integers)
const std::size_t n = 4;
std::unique_ptr<int[]> scratch = std::make_unique<int[]>(n);
for (std::size_t i = 0; i < n; ++i) scratch[i] = static_cast<int>(i);
if (scratch != nullptr) {
std::cout << "scratch[0] = " << scratch[0] << '\n';
}
return 0;
}
Reading notes:
make_taskcan return null; callers (hereadd) can treat null as a no-op, mirroring how many file-open helpers work.Pipeline’s move-only design matchesunique_ptr’s and avoids accidental slicing or shallow copies of a containedvector<unique_ptr<Task>>.swapon the vector elements movesunique_ptrownership; no copies occur.
Summary
If you only change one habit after reading this, make it: one clear owner, one clear delete path—std::unique_ptr is the standard way to express that for heap objects. It stays lightweight with the default deleter, refuses copies so you cannot accidentally share ownership, and lines up with PIMPL, factories, and vector of unique_ptr in real projects. Get comfortable with make_unique, deleters that match the real allocation story, the array vs single-object split, the PIMPL + out-of-line destructor detail, and short-lived get(); the rest is mostly tooling (ASan, tidy rules) cleaning up the mistakes that used to be silent.
Further reading (on this blog): shared_ptr vs unique_ptr tradeoffs, RAII and smart pointer overview, and the broader smart pointers in modern C++ for how unique_ptr fits the larger picture.