C++ Part I · Module 14 · Smart Pointers and Ownership
make_unique, Custom Deleters and Arrays
Every listing in this module has created its objects with std::make_unique, used about thirty times and explained never. It is usually introduced as the tidier way to write new — you say the type once instead of twice and the word new does not appear — and that is true but it is the smaller of the two reasons. The larger one is a leak: there is a shape of call, perfectly ordinary and still common in real code, in which writing new inside a smart pointer's constructor loses an object, and this chapter measures it as one object still alive after the exception has been caught. Then two things the type can do that nothing so far has needed. A unique_ptr's second template parameter is a deleter, which means the release action does not have to be delete at all — it can close a file, release a device handle, or return a buffer to a pool — and that is what turns a memory-ownership type into a general resource-ownership type. Measuring the size of the result shows that the way you spell that action changes what the pointer costs, by a factor of two. And the array form exists because getting it wrong is undefined behaviour that this platform either ignores completely or crashes on, depending on the element type.
Advanced19 min readC++Part IFundamentalsOwnership
Part I · Module 14 · Chapter 14.4 · make_unique, Custom Deleters and Arrays
1. The Function Every Listing Has Used
auto model = std::make_unique<ReferenceModel>(config);It allocates a ReferenceModel, passing config to its constructor, and returns a std::unique_ptr<ReferenceModel> owning it. The alternative spelling:
std::unique_ptr<ReferenceModel> model(new ReferenceModel(config));Both produce the same object owned the same way. The rest of this chapter is about the three respects in which they are not equivalent, and one widely repeated respect in which they are.
2. The Leak the Obvious Form Permits
Two types, instrumented. A counts itself; B's constructor throws.
struct A { A() { ++live; std::printf(" + A\n"); } ~A() { --live; std::printf(" - A\n"); } };
struct B { B() { throw std::runtime_error("B's constructor failed"); } ~B() { --live; } };
static void takesTwo(A*, B*) {}
static void takesTwoSmart(std::unique_ptr<A>, std::unique_ptr<B>) {}The call:
try { takesTwo(new A, new B); }
catch (const std::exception& e) { std::printf(" caught: %s\n", e.what()); }A. takesTwo(new A, new B) where B's ctor throws:
+ A
caught: B's constructor failed
live A objects: 1 <-- LEAKED (no destructor ran)One A is still alive after the exception was caught, and no destructor line appeared. The A object exists, nothing points to it, and nothing will ever delete it. Chapter 10.6 §5 measured this category with leaks; here it is produced by code that looks entirely reasonable.
The same call with make_unique:
B. the same with make_unique:
+ A
- A
caught: B's constructor failed
live A objects: 0The - A line appears before caught, so the A was destroyed while the exception was propagating — during unwinding, before the handler ran. Nothing leaked.
3. Why It Leaks — and Why the Usual Explanation Is Wrong
The explanation almost always given is that the compiler is allowed to interleave the two new expressions: allocate A, allocate B, then run the two constructors in some order. That was true before C++17. It is not what the measurement above shows, and repeating it will make you predict the wrong things.
Since C++17, function arguments are indeterminately sequenced: each argument's evaluation is complete before the next one begins. They cannot interleave. And the measured order here was A first — the + A line precedes the throw.
So the leak has a simpler cause. In the window between new A finishing and new B throwing, the result of new A is a raw pointer held by nothing. It is a value in flight, on its way to a parameter that will never be initialised. Unwinding destroys objects; Chapter 12.9 §4 measured exactly that. But there is no object here to destroy — only a pointer value, and destroying a pointer frees nothing.
Two consequences worth being precise about.
The order between arguments is unspecified, so the compiler is free to evaluate new B first, in which case nothing of A exists to be lost. The form is not reliably broken; it is permitted to break. That is worse than reliably broken, because it will work in testing.
And make_unique fixes it by closing the window. Each argument's evaluation now completes with the object owned — allocation and the assumption of ownership happen inside one function call, with no gap in which a raw pointer is in flight. The next argument is evaluated afterwards, with A already protected by a destructor that unwinding will run.
The hazard is a raw pointer with no owner, however briefly.
make_uniqueis how you avoid ever creating one.
4. The Allocation Count Is Not a Reason
A claim repeated almost as often as the interleaving story is that make_unique performs fewer allocations. Measured, with a counting operator new:
make_unique<Widget>() : 1 allocation(s)
unique_ptr<Widget>(new Widget) : 1 allocation(s)
make_shared<Widget>() : 1 allocation(s)
shared_ptr<Widget>(new Widget) : 2 allocation(s) <-- TWOmake_unique is one allocation either way. The 1-versus-2 saving is real, but it belongs to make_shared, for a reason about a second piece of state that Chapter 14.5 measures and explains. Attaching it to make_unique is a factual error, and a common one.
So the honest list of reasons to prefer make_unique is: §3's leak, and the two smaller ones in §5. Not performance.
5. Two Smaller Reasons
You say the type once. std::unique_ptr<ReferenceModel> m(new ReferenceModel(config)); names ReferenceModel twice, and the two can drift apart — most dangerously into a base and a derived type, where the result compiles and misbehaves. auto m = std::make_unique<ReferenceModel>(config); names it once.
And the object is value-initialised. std::make_unique<T>() with no arguments value-initialises, which for a scalar means zero:
make_unique<int[]>(6): 0 0 0 0 0 0
make_unique<int>() : 0new int[6] default-initialises instead, leaving indeterminate values — Chapter 3.8 §3's category. An honest note about this measurement: the obvious comparison refused to demonstrate the difference.
new int[6] (default-init, indeterminate): 0 0 0 0 0 0So I re-ran it on a deliberately dirtied heap — allocate six ints, fill them with 0xBAD, free them, then immediately request six ints again:
new int[6] on a dirtied heap : 0 0 0 0 0 0
make_unique<int[]>(6) on the same : 0 0 0 0 0 0Still identical. This allocator handed back zeroed memory both times. The difference between the two forms is therefore a guarantee, not something you can observe here: make_unique promises zeros and new T[n] promises nothing, and on this platform both happened to deliver zeros. That is precisely why the guarantee matters — Chapter 3.8 §2 made the general version of this argument: expecting to see garbage is itself the misconception, because an indeterminate value is very often zero, so the bug stays hidden until the one run where it is not.
6. When You Cannot Use make_unique
Three cases, and the first two are the ones you will actually meet.
When the deleter is not delete — §7. make_unique always produces a unique_ptr with the default deleter, so a custom one requires the constructor.
When the pointer came from somewhere else, such as a C library that allocated it for you. There is nothing to construct; you are adopting an existing pointer.
And when the constructor you need is not accessible to make_unique — a private constructor with a factory function, for instance, since make_unique is not a friend of your class.
In all three the constructor form is correct, and the §3 hazard is managed by keeping the new expression as the only thing in its statement:
std::unique_ptr<FakeFile, decltype(closer)> f(fakeOpen("results.csv"), closer);One allocation, one owner, nothing else in flight.
7. A Deleter for a Resource That Is Not Memory
std::unique_ptr has a second template parameter, defaulted:
template <class T, class D = std::default_delete<T>> class unique_ptr;D is the deleter — the thing invoked on the pointer when the unique_ptr is destroyed. std::default_delete<T> calls delete. Replace it and the type owns something that is not memory.
A stand-in for a file handle, with an open count so the release is observable:
static int openFiles = 0;
struct FakeFile { const char* name; };
static FakeFile* fakeOpen(const char* n) { ++openFiles; std::printf(" opened %s\n", n); return new FakeFile{n}; }
static void fakeClose(FakeFile* f) { --openFiles; std::printf(" closed %s\n", f->name); delete f; }
auto closer = [](FakeFile* f) { if (f) fakeClose(f); };
std::unique_ptr<FakeFile, decltype(closer)> f(fakeOpen("results.csv"), closer);A. a lambda deleter:
opened results.csv
inside the scope, openFiles=1
closed results.csv
after the scope, openFiles=0The handle was closed by scope exit, with no close call written anywhere. This is Chapter 12.9's RAII, and the point is that you did not have to write a class for it. Chapter 12.9 §1 stated the idiom as a class whose constructor acquires and whose destructor releases — which normally means writing that class; a unique_ptr with a deleter is that wrapper, already written, parameterised on the release action.
The if (f) guard matters: the deleter is invoked only when the pointer is non-null for default_delete, but a custom deleter should still tolerate null, because a moved-from unique_ptr holds null (Chapter 13.9 §7) and some paths will call it.
(The [](FakeFile* f) { ... } syntax is a lambda — an unnamed function written where it is used. Module 20 is where they are taught properly; here it is only a way to name the release action inline.)
8. What the Deleter Costs
The deleter is stored in the unique_ptr, so the choice has a measurable size:
B. a function-pointer deleter, and what it costs:
sizeof(unique_ptr<FakeFile>) = 8
sizeof(unique_ptr<FakeFile, void(*)(FakeFile*)>) = 16 <-- a function POINTER is stored
sizeof(unique_ptr<FakeFile, decltype(lambda)>) = 8 <-- a captureless lambda is FREEThree cases, two sizes.
The default deleter is free. std::default_delete<T> is an empty type and the implementation uses empty-base optimisation, so Chapter 14.2 §5's measurement of 8 bytes holds.
A function pointer costs 8 more bytes. void(*)(FakeFile*) is a runtime value — it has to be stored, and it cannot be inlined through, because the compiler does not know at compile time which function it holds.
A captureless lambda is free. Each lambda has its own unique empty type, so the release action is encoded in the type rather than stored as data — and the call can be inlined. Same 8 bytes as the default.
So: spell the deleter as a lambda or a stateless function object, not a function pointer. A doubled pointer size is rarely the thing that matters, but it is a free choice, and it is the difference between a call the compiler can see through and one it cannot.
A capturing lambda or a stateful deleter does have to be stored, and that cost is genuine rather than accidental — if the release action needs a pool handle, the handle has to live somewhere.
9. Arrays
unique_ptr has an array specialisation, and make_unique an array form:
auto arr = std::make_unique<int[]>(4);
arr[0] = 10; arr[3] = 40;C. arrays:
make_unique<int[]>(4): arr[0]=10 arr[3]=40The differences from the single-object form: operator[] is provided and operator*/operator-> are not; the deleter calls delete[]; the size is a runtime argument; and the elements are value-initialised (§5).
It is also usually the wrong tool. std::vector<int> does the same job and knows its own size, which unique_ptr<int[]> does not — Chapter 9.2 §5 measured the consequences of a size that travels separately from its data. The array form earns its place in two situations: a buffer whose size is fixed at creation and never needed again, and interoperation with a C API that wants a contiguous block. Module 19 is where vector is argued for properly.
10. The delete / delete[] Mismatch
Mixing the two forms is undefined behaviour, and it is worth seeing what "undefined" actually did here, because it is not one thing.
With a scalar element type:
{ std::unique_ptr<int> p(new int[4]); } // calls delete, not delete[]about to destroy a unique_ptr<int> holding new int[4]
survived
exit: 0It completed normally. And AddressSanitizer, compiled in, reported nothing — exit 0, no diagnostic. The check exists but is off by default on this platform:
ASAN_OPTIONS=alloc_dealloc_mismatch=1 ./mismatch2_asan
ERROR: AddressSanitizer: alloc-dealloc-mismatch (operator new [] vs operator delete) on 0x6020000000d0
SUMMARY: ... unique_ptr.h:77 in std::default_delete<int>::operator()(int*) const
exit: 134With a class type that has a destructor, the same mistake crashes immediately:
struct Cell { Cell() { ++live; } ~Cell() { --live; } };
{ std::unique_ptr<Cell> p(new Cell[4]); }after new Cell[4], live=4
exit: 133No further output — the process died on destruction. Under ASan:
ERROR: AddressSanitizer: attempting free on address which was not malloc()-ed
SUMMARY: AddressSanitizer: bad-free unique_ptr.h:77 in std::default_delete<Cell>::operator()(Cell*) constnot malloc()-ed is the clue to why the two cases differ. For a class with a destructor, new Cell[4] has to record how many elements to destroy, so the implementation allocates extra space and stores an element-count cookie before the first element, returning a pointer past it. delete receives that offset pointer and hands it to the allocator, which has never seen it. For int there is no destructor to run, no cookie is stored, the pointer is the allocation start, and the free succeeds — while still destroying none of the elements and being undefined behaviour regardless.
11. What to Actually Do
Use std::make_unique — for §3's leak, not for performance.
Do not claim it saves an allocation — measured 1 versus 1.
Use the constructor form only for §6's three cases, with the new alone in its statement.
Spell a deleter as a lambda or stateless function object, not a function pointer — 8 bytes versus 16.
Let a custom deleter tolerate null.
Prefer std::vector to unique_ptr<T[]> unless the size is fixed and irrelevant after creation.
Never pair new[] with delete — and never find out which way your platform fails.
And when a sanitizer is silent, prove it could have spoken.
12. Mistakes Worth Making Once
Writing f(new A, new B). Measured: 1 leaked, after the exception was caught.
Repeating the interleaving explanation. Not true since C++17; the cause is an unowned raw pointer.
Claiming make_unique saves an allocation. It does not; make_shared does.
Naming the type twice and letting the two drift into a base and a derived type.
Assuming new T[n] zeroes. It does not promise to — even where it appears to, as measured.
Using a function-pointer deleter by reflex. Doubles the size and blocks inlining.
A deleter that dereferences without a null check. A moved-from pointer is null.
unique_ptr<T> holding new T[n]. Silent for int, exit 133 for a class type.
Treating a clean sanitizer run as proof. The check may be off.
Reaching for unique_ptr<T[]> where vector belongs.
13. Interview Relevance
"Why prefer make_unique to unique_ptr<T>(new T)?" Exception safety in a multi-argument call, plus saying the type once and value-initialisation. The strong answer gives the leak and can describe the window.
"Why does f(new A, new B) leak?" Because the result of new A is owned by nothing while new B is evaluated. Not because of interleaving — that explanation predates C++17, and knowing the difference is a real discriminator.
"Does make_unique reduce allocations?" No — make_shared does, from two to one. Candidates often mix these up.
"What is unique_ptr's second template parameter?" The deleter — which is what makes it a general resource-ownership type rather than a memory one.
"Does a custom deleter cost anything?" A function pointer doubles the size, 8 to 16; a captureless lambda or stateless functor is free via empty-base optimisation.
"When do you need unique_ptr<T[]>?" Rarely — vector is usually right. And it must never be confused with unique_ptr<T>.
14. What You Should Be Able to Say Now
make_unique<T>(args)allocates, constructs and returns an owningunique_ptr.- Measured:
takesTwo(new A, new B)with a throwingBleftlive A objects: 1— leaked, no destructor. - Measured: the
make_uniqueversion printed- Abeforecaught— destroyed during unwinding, live 0. - The cause is an unowned raw pointer in flight, not argument interleaving — arguments are indeterminately sequenced since C++17.
- The argument order is unspecified, so the form is permitted to leak — which means it will pass tests.
- Measured:
make_uniqueis 1 allocation, same as the constructor form — the 1-versus-2 saving ismake_shared's. - It also names the type once and value-initialises.
- Honest limit: the value-init difference could not be exhibited here, even on a dirtied heap — it is a guarantee, not an observation.
- Three cases need the constructor: a custom deleter, an adopted pointer, an inaccessible constructor.
- The second template parameter is a deleter, which makes the type own non-memory resources — measured closing a file handle at scope exit.
- Measured sizes: 8 default, 16 function pointer, 8 captureless lambda — so prefer a lambda.
- A custom deleter must tolerate null.
- The array form provides
operator[], callsdelete[], and value-initialises — butvectoris usually the right tool. - Measured:
unique_ptr<int>(new int[4])printedsurvived, exit 0; with a class type, exit 133. - ASan missed it by default and caught it only with
alloc_dealloc_mismatch=1— so prove a silent tool could have spoken. - The cookie before the array is why a class type crashes where
intdoes not.
15. Exercises
Exercise 1 — the leak. Explain why takesTwo(new A, new B) can leak, in terms of ownership rather than evaluation order, and say what make_unique changes.
Exercise 2 — correct the folklore. A colleague says the leak happens because the compiler interleaves the two new expressions. Say what is wrong with that since C++17 and why the leak still occurs.
Exercise 3 — the allocation claim. Give the measured allocation counts for all four forms and say which pair the saving belongs to.
Exercise 4 — a deleter. Write a unique_ptr alias owning a handle released by releaseDevice(Handle*), spelled so that the pointer stays 8 bytes.
Exercise 5 — predict the sizes. For the default deleter, a function pointer, a captureless lambda, and a lambda capturing one int by value, give the expected sizeof and say why.
Exercise 6 — the mismatch. Say what std::unique_ptr<Widget> p(new Widget[3]); does for a Widget with a destructor, what it does for Widget = int, and which ASan option is needed.
Exercise 7 — array or vector. Three situations: a 4096-byte scratch buffer; a list of models that grows; a block handed to a C function expecting int* and a count. Choose unique_ptr<T[]> or vector<T> for each.
Exercise 8 — value-initialisation. Say what make_unique<int[]>(6) guarantees, what new int[6] guarantees, and why the measurement in §5 failed to show a difference.
Exercise 9 — the constructor form done safely. Rewrite g(std::unique_ptr<A>(new A), std::unique_ptr<B>(new B)) so no object can be lost, assuming both need custom deleters.
16. Summary
- Prefer
std::make_unique. The real reason is a leak:takesTwo(new A, new B)with a throwingBleft 1 liveAafter the catch;make_uniqueleft 0, destroying it during unwinding. - The cause is a raw pointer that nothing owns, not interleaving — and the order is unspecified, so the form merely may leak.
- It does not save an allocation — 1 versus 1, measured. That saving is
make_shared's, 1 versus 2. - It also names the type once and value-initialises — a guarantee that could not be exhibited here even on a dirtied heap.
- The second template parameter is a deleter, which turns the type into general-purpose RAII with no class to write — measured closing a handle at scope exit.
- Measured: 8 bytes default, 16 with a function pointer, 8 with a captureless lambda. Spell it as a lambda.
- The array form calls
delete[]— and a mismatch printedsurvivedforintbut exited 133 for a class type, with ASan silent by default. - When a tool reports nothing, confirm it could have reported something.
That is exclusive ownership complete: one owner, explicit transfer, a release action of your choosing. Every design so far has been a case where exactly one part of the program is responsible for an object, and that covers most of them. The next chapter is the case it does not cover — several parts of a program that each need an object to stay alive on their own schedule, with no way to say in advance which will finish last. The answer is a second piece of state alongside the object, a count of how many owners remain, and the measurements are worth knowing before you reach for it: the count rising and falling as copies come and go, the size that is twice a unique_ptr's, the two allocations that one spelling costs and another avoids, and a thread-safety guarantee that covers less than its name suggests.
Standards & specifications
- Governing standard
- ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)
Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C++ Programming curriculum.
