Compilers are allowed to elide replaceable global new allocations, or combine several such new allocations into one even if they have observable side-effects.
Explanation Link to heading
Generally, compilers are allowed to perform any kind of optimization as long as the program’s observable behavior stays the same (known as “as-if rule”). Yet, there is a well-known exception you’ve probably heard of: copy elision — a language rule under which certain copy or move operations are omitted (in some cases mandated by the standard, in others merely permitted), even when doing so changes otherwise observable constructor or destructor calls.
However, there is another optimization of a similar kind: allocation elision. Since C++14, implementations are allowed to omit calls to replaceable global allocation functions made by new-expressions. They are also allowed to extend one allocation so that it provides storage for another new-expression.
The term “replaceable global allocation functions” refers to the standard ::operator new and ::operator new[] overloads declared in the global namespace. The C++ standard explicitly permits user programs to redefine — ::operator new(size_t), ::operator new[](size_t), and some others. These are the entry points that ordinary new T{...} expressions call by default to allocate memory. The qualifier “replaceable” is doing real work here: placement new is also global but it cannot be replaced, and class-specific operator new is not global at all — neither of these is covered by allocation elision.
A quote from cppreference:
new expressions are allowed to elide or combine allocations made through replaceable allocation functions. In case of elision, the storage may be provided by the compiler without making the call to an allocation function (this also permits optimizing out unused new expression).
If the compiler decides to apply allocation elision and the allocated object isn’t needed at all — then the allocation can be dropped entirely. If the object is still needed — then the compiler takes the storage not from the allocation function, but from somewhere else, for example the stack. In both scenarios, if the compiler applies allocation elision, the call to the allocation function disappears.
malloc/free pairs are already elided under the as-if rule. Unlike them, the user-supplied replacement of operator new/operator delete may contain logic beyond pure allocation (e.g., logging, counting). The as-if rule alone would forbid removing such observable side effects, so [expr.new]/14 states an explicit exception — allowing the compiler to omit those calls anyway, even at the cost of changing the program’s observable behavior.
A first demonstration Link to heading
Here is a program whose replacement operator new cannot possibly succeed:
#include <cstdio>
#include <new>
void* operator new(std::size_t) {
throw std::bad_alloc{};
}
int main() {
try {
int* const p = new int;
delete p;
std::puts("allocatable");
} catch (const std::bad_alloc&) {
std::puts("not allocatable");
}
}
Compiled with clang at -O3, this prints allocatable — even though operator new cannot return (Godbolt). Since the replacement throws unconditionally, allocatable can only mean the call was skipped: the storage is “provided by the implementation,” and the excecution doesn’t go inside the catch block. g++ at -O3 still reports failure on this particular example.
The as-if rule alone could not produce allocatable output, because no valid abstract machine execution of the program exists in which it would. Only [expr.new]/14, which explicitly permits omitting the call along with any side effects in its body, can produce clang’s outcome.
When it applies Link to heading
Let’s investigate other cases when allocation elision takes place.
#include <cstdio>
#include <cstdlib>
#include <new>
int allocs = 0;
void* operator new(const std::size_t n) {
++allocs;
void* const p = std::malloc(n);
if (p == nullptr) {
throw std::bad_alloc{};
}
return p;
}
void operator delete(void* const p) noexcept {
std::free(p);
}
int main() {
for (int i = 0; i < 1000; ++i) {
int* p = new int{i};
delete p;
}
std::printf("operator new was called %d times\n", allocs);
}
With optimizations disabled, GCC, Clang, and MSVC print that operator new was called 1000 times. With -O3-style optimizations enabled, GCC and Clang optimize the allocation calls away and print that operator new was called 0 times. MSVC at /O2 still prints that operator new was called 1000 times.
Note the asymmetry: if the program prints 0, that is definitive proof that elision took place — only allocation elision lets the implementation skip the call to operator new entirely, including the ++allocs side effect (the as-if rule alone could not drop ++allocs, since the counter is observable through printf). Printing 1000, however, does not prove the opposite. The as-if rule already permits a sufficiently aggressive compiler to inline the replacement, remove the malloc/free pair under the assumption that allocation always succeeds, and leave ++allocs to run — that would yield 1000 with no allocator call actually happening. Empirically GCC and Clang at -O3 don’t do this on this particular test (they choose full elision when it applies), but the standard does not forbid it, so 1000 is consistent both with “every call happened” and with “no allocator call happened, but the counter ran anyway”. To distinguish, inspect the generated assembly.
MSVC does not appear to implement this optimization — the standard allows but does not require it. The most direct public statement I found is this MSVC-related comment: “we don’t implement this particular temporary allocation optimization at the moment.”
This optimization is pretty brittle: small, unrelated details in the surrounding code or in the replacement allocator can flip whether elision is observed. A few variations on the example above (counter reading at -O3):
| Variation | GCC | Clang | MSVC |
|---|---|---|---|
Base version: read and write through the pointer, operator new throws on nullptr (Godbolt) | 0 | 0 | 1000 |
Pointer passed to a function, operator new throws on nullptr (Godbolt) | 0 | 1000 | 1000 |
Null check in operator new commented out (Godbolt) | 1000 | 0 | 1000 |
std::make_unique, pointer passed, throw in operator new (Godbolt) | 0 | 1000 | 1000 |
std::make_unique, no pointer escape, no throw (Godbolt) | 1000 | 1000 | 1000 |
operator new returns the malloc result, no nullptr check (Godbolt) | 1000 | 0 | 1000 |
nothrow operator new (Godbolt) | 1000 | 1000 | 1000 |
Two rough patterns emerge: GCC’s elision tends to require that operator new be too complex to inline — a throw plus a null check are usually enough, and -fno-inline brings elision back for inlinable replacements. Note that the standard ([dcl.fct.def.replace]/1.1) explicitly forbids declaring a replacement function inline, but the prohibition applies to the keyword; the optimizer remains free to auto-inline a small definition on its own — which is exactly what defeats GCC’s elision here. Clang’s elision tends to require that the resulting pointer not escape the local scope.
Beyond these quirks, a general pattern emerges: both compilers apply allocation elision in the simple cases — when the compiler sees the allocation’s entire lifetime and that lifetime is essentially automatic, i.e. confined to a block or a function. The allocation can then be safely replaced with stack storage, or not performed at all.
The standard also permits allocation combining: one allocation may be extended to provide storage for another new-expression. However, I was not able to write a code that will trigger such behavior. Possibly, neither GCC, Clang or MSVC implement such optimization.