Ch18.7: Class-Level operator new and operator delete
1. When a Class Owns Its Allocation
A class can replace the allocation used by new on itself by declaring a
static member named operator new. When you write
new T(...), the compiler looks for T::operator new first, and
only falls back to the global one if the class does not have one.
The full set you must think about:
| Form | Called by | Required? |
|---|---|---|
static void *operator new(::std::size_t) |
new T |
yes, if you write any of them |
static void operator delete(void *) |
delete p when the delete is not sized |
yes — if it is missing, the global one is used on memory you allocated yourself |
static void operator delete(void *, ::std::size_t) |
delete p (sized deallocation, C++14 and later) |
yes — this is the one the compiler will usually call |
static void *operator new[](::std::size_t) |
new T[n] |
only if you also want arrays pooled |
static void *operator new(::std::size_t, void *) |
placement new (p) T |
never: the global placement form must not be replaced |
Mismatch is the classic defect: a class that declares operator new and
forgets operator delete gets its memory from your pool and returns it to the
C++ heap. That is heap corruption with no diagnostic, and it usually shows up as a crash
in an unrelated allocation a second later.
2. A Complete, Correct Example
#include <fast_io.h>
#include <cstddef>
#include <cstdint>
#include <cstdlib>
struct pooled
{
::std::int_least32_t v{};
static void *operator new(::std::size_t n) noexcept
{
void *p{::std::malloc(n)};
if (p == nullptr)
{
::std::abort();
}
return p;
}
static void operator delete(void *p, ::std::size_t) noexcept
{
::std::free(p);
}
static void operator delete(void *p) noexcept
{
::std::free(p);
}
};
int main()
{
try
{
::fast_io::io::println("sizeof(pooled)=", sizeof(pooled));
pooled *p = new pooled{};
p->v = 7;
::fast_io::io::println("v=", p->v);
delete p;
::fast_io::io::println("done: no counter, no null, no bad_alloc");
}
catch throws (::std::error e)
{
::fast_io::io::perrln("error: ", e);
}
}
sizeof(pooled)=4
v=7
done: no counter, no null, no bad_alloc
Five things to notice, each of which is a rule:
- Allocation failure calls
::std::abort(). Notnullptr, not::std::bad_alloc. There is no return value for a caller to ignore, no half-initialised object to leak, and no legacy exception anywhere near your program. Section 3 is the full argument for this. sizeof(pooled)is still 4.operator newis a static function; it adds nothing to the object. Allocation policy is not state.- The destructor is still your destructor.
delete pruns~pooled()and thenpooled::operator delete, in that order. - Both
operator deleteoverloads are needed because the compiler passes the size when it knows it and does not when it does not (arrays, polymorphic deletes, some library code paths). - No bookkeeping. Nothing is counted, nothing is tracked. If you want to know how many objects exist, that is a diagnostic build's job, taken with a sanitizer or a link-time interposer, not a static data member that ships with the class.
3. Allocation Failure Terminates
Heap allocation failure must terminate the program. Not "be handled", not "be reported", not "be retried": terminate, immediately, in the place where the memory ran out. This page exists because C++ chose the opposite.
Making heap allocation failure an exception is one of the biggest historical
mistakes in C++. Think about what the language promised: that failing to get
memory is an exceptional condition a caller can recover from, that it travels
through a typed channel, and that whole-program invariants survive it. None of that is
true. Stack exhaustion is undefined behaviour, so the two failure modes cannot both be
"handled". Unwinding out of an allocation failure runs destructors, which allocate again.
The libraries underneath you abort anyway: glibc's xmalloc does not return
null, it dies. On Linux the OOM killer arrives before malloc returns null.
And every new in the program becomes an invisible control-flow edge that can
leave any function, which is a source of exception-safety bugs in code that looks like it
has no error handling at all.
So we say what should have been said in 1998: allocation failure aborts. If it happens, the program is over, the diagnostic is a core dump, and that is the honest outcome. Everything else is theatre that costs every caller a check and every function an invisible exit.
There are exactly four things an allocation function can do when the memory is not there, and only one of them is worth writing:
| What it does | Verdict |
|---|---|
throw ::std::bad_alloc |
the language default. A legacy exception from 1998, dragged into a herbception
program as [itanium_exception](St9bad_alloc)
(Ch18.1) |
return nullptr |
never. It is legal only because the function is
noexcept, and then new T yields a null pointer: every
caller must check, no caller does, and the object is never constructed |
::std::nothrow / ::std::nothrow_t |
never — see below |
::std::abort() |
this one |
Why ::std::abort() is the right answer
From Portable-Cpp-Guideline, five reasons, all of which apply to any program
that is not a kernel:
- Destructors can allocate again. Unwinding out of an allocation failure runs destructors, and a destructor that allocates can fail too, so the "handle it" path is a cycle with no guaranteed exit.
- libsupc++ already has an emergency heap, and even with it there are
paths that end in
::std::terminate. Removing the emergency heap has been tried and changes nothing observable. - The libraries under you do not handle it either. glibc calls
xmalloc, which aborts onmallocfailure. You cannot make your program's failure story more graceful than the C library's. - Linux overcommits. You will usually be killed by the OOM killer
before
mallocreturns null, so the branch you carefully wrote is dead code on the platform you actually ship. - Every
newin the program is an invisible control-flow edge (Ch18.1); making allocation failure fatal removes all of them at once.
There is also a consistency argument that the guideline makes explicitly: stack exhaustion is undefined behaviour in C++ and heap exhaustion is not. The two failure modes cannot both be "handled", so treating one of them as fatal and the other as recoverable is theatre.
Never ::std::nothrow
::std::nothrow looks like the middle ground and is not: libstdc++ implements
the nothrow operator new by calling the throwing one inside
try/catch(...) and returning null on any exception:
_GLIBCXX_WEAK_DEFINITION void *
operator new (std::size_t sz, const std::nothrow_t&) noexcept
{
__try
{
return ::operator new(sz);
}
__catch (...)
{
return nullptr;
}
}
So ::std::nothrow guarantees nothing about throwing — the throw happens
and is caught for you — and the standard does not let you replace
operator new(std::nothrow_t const&) the way it lets you replace
operator new(). There is no benefit and one less thing you control. Do not
use it, and do not pass ::std::nothrow to new: if you want a
non-failing allocation path, write malloc and check it, and if you cannot do
anything useful with the failure, call ::std::abort().
4. Why You Should Not Do It
- It hides a design decision inside a type. The caller writes
new Tand cannot see, choose, or override where the memory comes from — and cannot pass an allocator. A factory functionmake_pooled(...)or a container taking an allocator makes the same decision visible at the call site, which is where decisions belong. - It is invisible to the standard containers.
::fast_io::vector<pooled>does not callpooled::operator new; it asks its allocator, which asks the global heap. So the "pooling" you thought you added applies to exactly the objects you writenewfor by hand — which, as we said, you should not be writing. - It bakes an ABI in. If the library's
operator newchanges, every caller must be rebuilt, because the allocation and the free no longer come from the same place. - It interacts badly with polymorphic deletion. A class-level
operator deleteis only found if the static type used at thedeleteexpression has it. Through a base pointer without a virtual destructor, you get the base'soperator delete— or the global one — on memory your derived class allocated. - You now have to test it. Allocation bugs are the hardest kind to attribute, so the moment you write a custom allocator you own a new class of defect that no sanitizer will describe clearly.
5. What To Do Instead
| Goal | Class operator new |
Better |
|---|---|---|
| faster allocation of one hot type | static void *operator new(size_t) |
A pool object you pass to a container, or an arena type (Ch14.1) |
| no exceptions from allocation | operator new that returns null, or ::std::nothrow |
malloc inside your own type, ::std::abort() when it
fails, and throw throws for everything that is a real error
(Ch18.6) |
| bulk lifetime | operator delete that leaks |
A region type with a destructor that releases everything in one call |
| diagnostics | a counter in operator new |
A link-time interposer for ::operator new in a test build, or a
sanitizer |
6. Rules of Thumb
- Do not write class-level
operator new. If you need allocation policy, pass it: an allocator parameter, a pool object, an arena. - If you do write one, write both
operator deleteoverloads in the same class, in the same edit, with the same storage source. - Make
operator newnoexceptand call::std::abort()whenmallocreturns null. Never returnnullptrfrom an allocation function, and never use::std::nothrow. - Never mix: memory from
mallocgoes back tofree, memory fromnewgoes back todelete, and a class-leveloperator newmust pair with a class-leveloperator delete. - Prefer
malloc-and-placement-newinside your own factory function. It is the same amount of code, it is visible at the call site, and it cannot be inherited by accident.