Ch18.1: new, delete, and ::std::unique_ptr
1. What new Actually Does
One expression, four steps:
square *p = new square{2.0};
- call
::operator new(sizeof(square)), which asks the implementation's heap for memory; - run the constructor in that memory;
- hand you the pointer;
- and if step 1 fails, throw
::std::bad_alloc.
Step 4 is one of the biggest historical mistakes in C++. Failure to allocate memory is not
recoverable and not exceptional in any useful sense: it is the end of the program.
Making it a thrown exception means every new in your program is a hidden
control-flow edge that can leave a function, every function that calls new
must be prepared to throw, and every caller must decide what "out of memory" means in a
design that cannot do anything about it. The honest answer is that allocation failure
terminates (Ch18.7), and C++ chose the
opposite in 1998, when it had no usable error channel at all.
That is why new is not noexcept:
::std::printf("noexcept(new square{2.0}) = %d\n", noexcept(new square{2.0}));
::std::printf("noexcept(::operator new(8)) = %d\n", noexcept(::operator new(8)));
noexcept(new square{2.0}) = 0
noexcept(::operator new(8)) = 0
and why, under herbceptions, the failure of new does not arrive as a
throws error at all: it arrives as a legacy exception wrapped in an
itanium_exception payload:
#include <fast_io.h>
int main()
{
try
{
void *p = ::operator new(static_cast<::std::size_t>(-1) / 2u);
::fast_io::io::println("allocated ", p != nullptr);
::operator delete(p);
}
catch throws (::std::error e)
{
::fast_io::io::perrln("caught by herbception: ", e);
}
}
caught by herbception: [itanium_exception](St9bad_alloc)std::bad_alloc
Read that carefully. A feature of the language core, allocation, drags the entire legacy
exception machinery — and therefore the unwinder and RTTI — into a program
that is otherwise written entirely with herbceptions. There is no way to ask
new for a return value. (You can ask ::operator new(n,
::std::nothrow), but
Ch18.7 explains why that is worse: the
nothrow overloads are implemented by calling the throwing ones and
catching, and the standard does not even let you replace them.)
2. delete Is Worse, Because It Is Unchecked
delete pcalls the destructor, then::operator delete(p);- it does not know the size of the allocation unless the compiler
passes it (sized deallocation), so a mismatched
operator deletecorrupts the heap; - it does not know whether
pis still alive, so a doubledeleteis a heap corruption that often survives to production; - it does not know the dynamic type, so deleting through a base pointer without a virtual destructor destroys only the base subobject — UB with a leak;
deleteversusdelete[]is a spelling mistake with no diagnostic.
Every one of those is a rule the programmer must remember, in every function, forever. That is the definition of a bad API.
3. ::std::unique_ptr: A Pointer With a Destructor
::std::unique_ptr<T> wraps one raw pointer and one deleter, and does
what a C++ RAII wrapper does:
void use() throws
{
::std::unique_ptr<square> p{::std::make_unique<square>(2.0)};
::fast_io::io::println(p->area()); // p is destroyed here, exactly once
}
It solves leaks, and only leaks. It does not solve the four delete
problems above: it cannot stop you from keeping a second raw pointer to the same object
(use-after-free), it cannot stop you from moving it into two owners' hands at once if you
cheat with release(), it does not tell you what a
unique_ptr<shape> means at the call site, and it does not stop type
confusion, because the pointee's dynamic type is still not checked by anything.
4. The Only Reason People Reach For It
Heterogeneity. You have several shapes and you want them in one container, and the container holds one type:
struct shape
{
virtual double area() const noexcept = 0;
virtual ::std::string_view name() const noexcept = 0;
virtual ~shape() noexcept = default;
};
struct square final : shape
{
double side{};
explicit square(double s) noexcept : side{s} {}
double area() const noexcept override { return side * side; }
::std::string_view name() const noexcept override { return "square"; }
};
struct circle final : shape
{
double r{};
explicit circle(double x) noexcept : r{x} {}
double area() const noexcept override { return 3.0 * r * r; }
::std::string_view name() const noexcept override { return "circle"; }
};
and then, in main:
::fast_io::vector<::std::unique_ptr<shape>> shapes;
shapes.push_back(::std::make_unique<square>(2.0));
shapes.push_back(::std::make_unique<circle>(1.0));
double total{};
for (auto const &p : shapes)
{
total += p->area();
}
total=7 sizeof(unique_ptr)=8 sizeof(vector)=24
It works. It is also, measured, one heap allocation for the container and one for
every single element, an 8-byte pointer plus allocator header per shape, a
virtual call per element, RTTI for both classes, and a type the call site cannot
describe. Ch18.6 writes the same program as
::fast_io::vector<shape> — a vector of values, with no
new in your code and no virtual anywhere.
5. Why We Do Not Use ::std::unique_ptr
From Portable-Cpp-Guideline, with the reasoning that matters here:
- It encourages OOP. Its natural home is a pointer to a base class, which is exactly the design we are trying to leave behind.
- It is not ABI-stable and it is measurably slow. Passing a
unique_ptrby value is a non-trivial type, so it is passed indirectly and the callee must run the destructor; libc++'s own documentation on[[clang::trivial_abi]]forunique_ptrreports up to 1.6% improvement on large server macrobenchmarks from that change alone. - It is not a pointer to memory, it is a pointer to a thing someone else
frees, and that is why it cannot express a POSIX file descriptor, a
HANDLE, a database connection or an mmap region: the API that created the resource is the API that must release it, and it is notdelete. Wrapping the resource in a class with a destructor is the answer, not aunique_ptrwith a custom deleter. - Custom deleters are a performance trap. A lambda, a function pointer and a function object each have their own codegen, their own size and their own inlining behaviour, and it is hard to avoid the slow one by accident.
unique_ptr<T[]>is a footgun: the[]is easy to forget, and dropping it is UB.- You cannot build data structures out of it. A tree of
unique_ptrnodes destroys itself recursively and blows the stack; a linked list of them is the worst of both worlds. - It is not usable in freestanding code, because
::std::make_uniqueis not freestanding andnewis not guaranteed to exist (Ch20: Freestanding). - It destroys type information at the call site.
unique_ptr<shape>says "a shape, somewhere else, and I own it". The valueshapesays "a shape". One of those is a sentence a reader can use; the other is a rumour about the heap. - It makes you reach for
nullptrto mean "empty", which then needs a check at every use, which then gets forgotten.
And the summary sentence from the guideline: ::std::unique_ptr is not a
smart pointer, it is a questionable pointer type that can be harmful.
6. What To Write Instead
| You want | Not this | This |
|---|---|---|
| many objects of one type | ::fast_io::vector<::std::unique_ptr<T>> |
::fast_io::vector<T> |
| many representations behind one type | vector<unique_ptr<base>> + virtual |
a type-erased value type (Ch18.6) |
| a resource with a lifetime | unique_ptr<X, deleter> |
a class with a destructor that owns exactly that resource (Ch14.1) |
| optional ownership | unique_ptr<T> that may be null |
an empty container, or ::std::optional<T> for a cheap
trivially-copyable value |
| dynamic allocation of a buffer | unique_ptr<::std::byte[]> |
::fast_io::vector<::std::byte>, or an arena you own |
7. Rules of Thumb
- Never write
newordeletein application code. If a library gives you a raw pointer to free, wrap it in your own owner class immediately, in one place, and never let the raw pointer travel. - Never write
::std::unique_ptr. It is a symptom, not a solution: the symptom is that you are modelling an object graph instead of values. - If you inherit
::std::unique_ptrfrom legacy code, do not "improve" it in place. Contain it: give your own type a value interface and keep the pointer private. - Remember that
newis a legacy-throwing function, so anoexceptfunction must not call it — the compiler will stop you if your contracts are honest. - When you need to allocate, say where the memory comes from and what happens when it
is not there. That is a design decision, and
newmakes it silently, for you, once, forever.