Type-aware allocation and deallocation functions

Document #: P2719R3 [Latest] [Status]
Date: 2025-01-08
Project: Programming Language C++
Audience: EWG, CWG
Reply-to: Louis Dionne
<>
Oliver Hunt
<>

1 Introduction

C++ currently provides two ways of customizing the creation of objects in new expressions. First, operator new can be provided as a static member function of a class, like void* T::operator new. If such a declaration is provided, an expression like new T(...) will use that allocation function. Otherwise, the global version of operator new can be replaced by users in a type-agnostic way, by implementing void* operator new(size_t) and its variants. A similar mechanism exists for delete-expressions.

This paper proposes an extension to new-expressions and delete-expressions to provide the concrete type being [de]allocated to the allocation functions. This is achieved via the use of an additional std::type_identity<T> tag argument that allows the provision of the concrete type to operator new and operator delete. In addition to providing valuable information to the allocator, this allows the creation of type-specific operator new and operator delete for types that cannot have intrusive class-scoped operators specified.

At a high level, this allows defining allocation and deallocation functions like:

void* operator new(std::type_identity<mylib::Foo>, std::size_t n) { ... }
void operator delete(std::type_identity<mylib::Foo>, void* ptr) { ... }

However, it also allows providing these functions for a family of types, which is where this feature becomes interesting:

template <class T>
  requires use_special_allocation_scheme<T>
void* operator new(std::type_identity<T>, std::size_t n) { ... }

template <class T>
  requires use_special_allocation_scheme<T>
void operator delete(std::type_identity<T>, void* ptr) { ... }

2 Revision history

3 Motivation

Knowledge of the type being [de]allocated in a new-expression is necessary in order to achieve certain levels of flexibility when defining a custom allocation function. However, even when defining T::operator new in-class, the only information available to the implementation is the type declaring the operator, not the type being allocated. This results in developers various creative (often macro-based) mechanisms to define these allocation functions manually, or circumventing the language-provided allocation mechanisms entirely in order to track the allocated types.

However, in addition to these intrusive mechanisms being cumbersome and error-prone, they do not make it possible to customize how allocation is performed for types controlled by a third-party, or to customize allocation for an open set of types.

Beyond these issues, a common problem in we see in the wild is codebases overriding the global (and untyped) operator new via the usual link-time mechanism and running into problems because they really only intended for their custom operator new to be used within their own code, not by all the code in their process. For example, we’ve seen scenarios where multiple libraries attempt to replace the global operator new and end up with a complex ODR violation bug that depends on how the dynamic linker resolved weak definitions at load time – not very user friendly. By providing the concrete type information to allocators at compile time, it becomes possible for authors to override operator new for a family of types that they control without overriding it for the whole process, which is what they actually want.

3.1 A concrete use case

A few years ago, Apple published a blog post explaining a technique used inside its kernel (XNU) to mitigate various exploits. At its core, the technique roughly consists in allocating objects of each type in a different bucket. By collocating all objects of the same type into the same region of memory, it becomes much harder for an attacker to exploit a type confusion vulnerability. Since its introduction in the kernel, this technique alone has been by far the most effective at mitigating type confusion vulnerabilities.

In a world where security is increasingly important, it may make sense for some code bases to adopt mitigation techniques such as this one. However, these techniques require a large-scale and almost system-wide customization of how allocation is performed while retaining type information, which is not supported by C++ today. While not sufficient in itself to make C++ safer, the change proposed in this paper is a necessary building block for technology such as the above which can greatly improve the security of C++ applications.

4 Current behavior recap

Today, the compiler performs a lookup in the allocated type’s class scope (for T::operator new), and then a lookup in the global scope (for ::operator new) if the previous one failed. Once the name lookup has been done and the compiler has decided whether it was looking for T::operator new or ::operator new, name lookup will not be done again even if the steps that follow were to fail. From here on, let’s denote by NEW the set of candidates found by the name lookup process.

The compiler then performs overload resolution on that set of candidates using the language-specified optional implicit parameters, and if present any developer-provided placement arguments. It does so by assembling an argument list that depends on whether T has a new-extended alignment or not. For the sake of simplicity, assume that T does not have a new-extended alignment. The compiler starts by performing overload resolution as-if the following expression were used:

NEW(sizeof(T), args...)

If that succeeds, the compiler selects the overload that won. If it does not, the compiler performs overload resolution again as-if the following expression were used:

NEW(sizeof(T), std::align_val_t(alignof(T)), args...)

If that succeeds, the compiler selects the overload that won. If it does not, the program is ill-formed. For a type T that has new-extended alignment, the order of the two overload resolutions performed above is simply reversed.

Delete-expressions behave similarly, with lookup being performed in the context of the static type of the expression. The overload resolution process then works by preferring a destroying delete, followed by an aligned delete (if the type has new-extended alignment), followed by the usual operator delete (with or without a size_t parameter depending on whether the considered operator delete is a member function or not).

5 Proposal

This proposal adds a new implicit tag argument of type std::type_identity<T> to operator new and operator delete that is incorporated into the existing overload resolution logic with a higher priority than existing implicit parameters. To avoid conficts with existing code, this parameter is placed as the first argument to the operator, preceding the size or subject pointer. To avoid the complexities of ADL, this proposal does not change any of the name lookup rules associated to new and delete expressions: it only changes the overload resolution that happens once a name has been found.

For the declaration of a type-aware [de]allocation operator to be valid, we explicitly require that the parameter be a (potentially dependent) specialization of std::type_identity, but not a fully dependent type. In other words, the compiler must be able to tell that the first parameter is of the form std::type_identity<T> at the time of parsing the declaration, but before the declaration has been instantiated in the case of a template. This is analogous to the current behavior where we require specific concrete types in the parameter list even in dependent contexts.

Once a set of candidate declarations has been found we perform the same prioritized overload resolution steps, only with the addition of std::type_identity<T>, with a higher priority than the existing size and alignment parameters. For illustration, here is how overload resolution changes (NEW is the set of candidates found by name lookup for operator new, and DELETE is the equivalent for operator delete).

If the user writes new T(...), the compiler checks (in order):

Before
After
// T not overaligned
NEW(sizeof(T))
NEW(sizeof(T), align_val_t{alignof(T)})
// T not overaligned
NEW(type_identity<T>{}, sizeof(T))
NEW(type_identity<T>{}, sizeof(T), align_val_t{alignof(T)})
NEW(sizeof(T))
NEW(sizeof(T), align_val_t{alignof(T)})
// T overaligned
NEW(sizeof(T), align_val_t{alignof(T)})
NEW(sizeof(T))
// T overaligned
NEW(type_identity<T>{}, sizeof(T), align_val_t{alignof(T)})
NEW(type_identity<T>{}, sizeof(T))
NEW(sizeof(T), align_val_t{alignof(T)})
NEW(sizeof(T))

If the user writes delete ptr, the compiler checks (in order):

Before
After
// T not overaligned
DELETE(T-or-Base*, destroying_delete_t{}, ...)
DELETE((void*)ptr, sizeof(*ptr))
DELETE((void*)ptr)
// T not overaligned
DELETE(T-or-Base*, destroying_delete_t{}, ...)
DELETE(type_identity<T>{}, (void*)ptr, sizeof(T))
DELETE(type_identity<T>{}, (void*)ptr)
DELETE((void*)ptr, sizeof(T))
DELETE((void*)ptr)
// T overaligned
DELETE(T-or-Base*, destroying_delete_t{}, ...)
DELETE((void*)ptr, sizeof(T), align_val_t{alignof(T)})
DELETE((void*)ptr, align_val_t{alignof(T)})
DELETE((void*)ptr)
// T overaligned
DELETE(T-or-Base*, destroying_delete_t{}, ...)
DELETE(type_identity<T>{}, (void*)ptr, sizeof(T), align_val_t{alignof(T)})
DELETE(type_identity<T>{}, (void*)ptr, align_val_t{alignof(T)})
DELETE(type_identity<T>{}, (void*)ptr)
DELETE((void*)ptr, sizeof(T), align_val_t{alignof(T)})
DELETE((void*)ptr, align_val_t{alignof(T)})
DELETE((void*)ptr)

If multiple candidates match a given set of parameters, candidate prioritisation and selection is performed according to usual rules for overload resolution.

When a constructor throws an exception, a call to operator delete is made to clean up. Overload resolution for this call remains essentially the same, the only difference being that the selected operator delete must have the same type-awareness as the preceding operator new or the program is considered ill-formed.

For clarity, in types with virtual destructors, operator delete is resolved using the destructor’s class as the type being deallocated (this matches the existing semantics of being equivalent to performing delete this in the context of the class’s non virtual destructor).

5.1 Free function example

struct SingleClass { };
struct UnrelatedClass { };
struct BaseClass { };
struct SubClass1 : BaseClass { };
struct SubClass2 : BaseClass { };
struct SubClass3 : BaseClass { };
void* operator new(std::type_identity<SingleClass>, std::size_t); // (1)
template <typename T> void* operator new(std::type_identity<T>, std::size_t); // (2)

template <std::derived_from<BaseClass> T>
void* operator new(std::type_identity<T>, std::size_t); // (3)
void* operator new(std::type_identity<SubClass2>, std::size_t); // (4)
void* operator new(std::type_identity<SubClass3>, std::size_t) = delete; // (5)

struct SubClass4 : BaseClass {
  void *operator new(size_t); // (6)
};

void f() {
  new SingleClass();     // calls (1)
  new UnrelatedClass();  // calls (2)
  new BaseClass();       // calls (3) with T=BaseClass
  new SubClass1();       // calls (3) with T=SubClass1
  new SubClass2();       // calls (4)
  new SubClass3();       // resolves (5) reports error due to deleted operator
  new SubClass4();       // calls (6) as the class scoped operator wins
  new int();             // calls (2) with T=int
}

[ Note: The above is for illustrative purposes only: it is a bad idea to provide a fully unconstrained type-aware operator new.end note ]

5.2 In-class example

// In-class operator
class SubClass1;
struct BaseClass {
  template <typename T>
  void* operator new(std::type_identity<T>, std::size_t); // (1)
  void* operator new(std::type_identity<SubClass1>, std::size_t); // (2)
};

struct SubClass1 : BaseClass { };
struct SubClass2 : BaseClass { };
struct SubClass3 : BaseClass {
  void *operator new(std::size_t); // (3)
};
struct SubClass4 : BaseClass {
  template <typename T>
  void *operator new(std::type_identity<T>, std::size_t); // (4)
};

void f() {
  new BaseClass;         // calls (1) with T=BaseClass
  new SubClass1();       // calls (2)
  new SubClass2();       // calls (1) with T=SubClass2
  new SubClass3();       // calls (3)
  new SubClass4();       // calls (4) with T=SubClass4
  ::new BaseClass();     // ignores in-class operators and uses appropriate global operator
}

5.3 Operator suppression example

There are many cases where projects may not want types to be allocated and deallocated via new and delete operators. Doing so today requires injecting operators into the relevant types, which often results in extensive use of macros. This proposal allows constraint based selection of target types, and as such can be leveraged to specify deleted operators, and so automatically prevent their use e.g.

template <typename T> concept SelectionConstraint = ...;
template <SelectionConstraint T> void *operator new(std::type_identity<T>, std::size_t) = delete;
...
template <SelectionConstraint T> void operator delete(std::type_identity<T>, void *) = delete;
...

5.4 Template type allocation example

The template arguments to a type aware operator new or delete are not required to be directly applied to std::type_identity, but are simply available for usual template deduction, so a type aware allocation function can be defined to operate over a template type, e.g.

template <typename T, int N>
struct MyArrayType {
  ...
};
template <typename T, int N>
void *operator new(std::type_identity<MyArrayType<T, N>>, size_t, ...) {
 ...
}
...
// calls the above operator new<int, 5>(std::type_identity<MyArrayType<int, 5>>, ...)
auto A = new MyArrayType<int, 5>;

6 Proposed wording

6.1 [cpp.predefined]

Update [tab:cpp.predefined.ft] to include a feature detection macro __cpp_typed_allocation with a release appropriate value.

6.2 [basic.stc.dynamic.allocation]

Updates for [basic.stc.dynamic.allocation]

[ Drafting note: The wording changes here are intended to provide for the existence of a correct operator new or operator new[] in which the first parameter is a std::type_identity<T> tag parameter where previously the first parameter was required to be of type std::size_t. The language is intended to further permit this tag parameter to be a dependent type, as long as it is always a specialization of std::type_identity, e.g template <typename T> void *operator new(T, std::size_t) would always be invalid, even if T was std::type_identity<E> for some E, or similarly template <template<class> class T> void *operator new(T<int>, std::size_t). ]

1 An allocation function or allocation function templatethat is not a class member function shall either belong to the global scope and not have a name with internal linkage or be a class member function. The declared return type shall be the non-dependent type void*. A non-template allocation function shall have at least one parameter, and an allocation function template shall have at least two parameters. If the first parameter is of type std::type_identity<T> for some type T there shall be at least two parameters; the first parameter is called the type-identity parameter, and the second parameter is called the size parameter. Otherwise, the first parameter is called the size parameter. The first size parameter shall have the non-dependent type std::size_t ([support.types]). The first parameter shall not Neither the type-identity parameter, if present, nor the size parameter shall have an associated default argument ([dcl.fct.default]). The type-identity parameter represents the type being allocated, such that when allocating an object or array of type T, the type-identity parameter shall be of type std::type_identity<U> where U is the type T with qualifiers removed. The value of the firstsize parameter is interpreted as the requested size of the allocation. An allocation function can be a function template. Such a template shall declare its return type and first parameter as specified above (that is, template parameter types shall not be used in the return type and first parameter type). Allocation function templates shall have two or more parameters.

6.3 [basic.stc.dynamic.deallocation]

Updates for [basic.stc.dynamic.deallocation]

[ Drafting note: As with the changes to [basic.stc.dynamic.allocation] the intent of this wording is to permit the first parameter to be a std::type_identity specialization as long as we can identify this as a specialization of std::type_identity prior to any template instantiation. ]

3Each deallocation function shall return void. A deallocation function shall have at least one parameter. If the first parameter is of type std::type_identity<T> for some type T, there shall be at least two parameters; the first parameter is called the type-identity parameter, the second parameter is called the address parameter. Otherwise, the first parameter is called the address parameter. If the function has a type-identity parameter, it is not a destroying delete and the third parameter, if present, shall not be of type std::destroying_delete_t. If the function is a destroying operator delete declared in class type C, the type of its firstaddress parameter shall be C*; otherwise, the type of its firstaddress parameter shall be void*. A deallocation function may have more than one parameteradditional parameters beyond the type-identity and address parameters. A usual deallocation function is a deallocation function whose parameters after the firstaddress parameter are

  • (3.1)optionally, a parameter of type std::destroying_delete_t, then
  • (3.2)optionally, a parameter of type std::size_t, then
  • (3.3)optionally, a parameter of type std::align_val_t.

A destroying operator delete shall be a usual deallocation function. A deallocation function may be an instance of a function templatetemplated. Neither the firstaddress parameter nor the return type shall depend on a template parameter. A deallocation function template shall have two or more function parameters can be a function template. Such a function template shall have at least two parameters, and declare its address parameter as specified above. A template instance is never a usual deallocation function, regardless of its signature.is only a usual deallocation function if it has a type-identity parameter, and the type-identity parameter is a dependent type (i.e. the type-identity parameter may be of type std::type_identity<T> where T shall be a dependent type), and the type-identity parameter is the only dependent parameter.

[ Drafting note: Is this sufficient to prevent a case like this from being a usual deallocation function? It seems like it might not cover Args being an empty pack?

template <typename T, typename... Args>
void operator delete(type_identity<T>, void*, Args...);
]

6.4 [expr.new]

Updates to [expr.new]

[ Drafting note: We are adding a new implicit tag parameter to the head of the argument list, we’re calling this tag parameter the type-identity. It is a std::type_identity<> over the qualifier-free allocated type T for an allocation of the form new T or new T[<expr>]. This is the method by which the allocated type is exposed to developers.

We are extending the overload resolution steps to include this new parameter. The intent is for the type aware overloads to get priority over any other “matching” option. e.g a type aware operator new without align_val_t will still get priority over a viable align_val_t candidate (on the principle that the allocator can query the allocated type alignment directly). This requires indenting and adding paragraph numbers to steps in paragraph 20. The below removal is the section of text that is being inset and separated into steps below. ]

20 Overload resolution is performed on a function call created by assembling an argument list. The first argument is the amount of space requested, and has type std::size_t. If the type of the allocated object has new-extended alignment, the next argument is the type’s alignment, and has type std::align_val_t. If the new-placement syntax is used, the initializer-clauses in its expression-list are the succeeding arguments. If no matching function is found then

  • (20.1) if the allocated object type has new-extended alignment, the alignment argument is removed from the argument list;
  • (20.2) otherwise, an argument that is the type’s alignment and has type std::align_val_t is added into the argument list immediately after the first argument;

and then overload resolution is performed again.

20 The type-identity argument is a value of type std::type_identity<U>. When the allocated type is “array of N T”, or the type T otherwise, U is the type T with qualifiers removed.

21 Overload resolution is performed on a function call by assembling an argument list.

[ Drafting note: some of these points exist, but are being inset and put into a sub-list ]

  • (21.1) The first argument is the type-identity argument, the secondThe first argument is the amount of space requested, and has type std::size_t.

  • (21.2) If the type of the allocated object has new-extended alignment, the next argument is the type’s alignment, and has type std::align_val_t. If the new-placement syntax is used, the initializer-clauses in its expression-list are the succeeding arguments. If no matching function is found then

    • (21.2.1) if the allocated object type has new-extended alignment, the alignment argument is removed from the argument list;

    • (21.2.2) otherwise, an argument that is the type’s alignment and has type std::align_val_t is added into the argument list immediately after the size argument;

    • (21.2.3) and then overload resolution is performed again.

  • (21.3) If no matching function was found and the type-identity argument was present in the argument list, the above overload resolution steps are repeated without the type-identity argument.

29 A declaration of a placement deallocation function matches the declaration of a placement allocation function if it has the same number of parameters and, after parameter transformations ([dcl.fct]), all parameter types except the firstthe size and object are identical.

30 If a new-expression calls a deallocation function it passes the type-identity argument as the first argument if it was passed to the allocation function, and the value returned from the allocation function call as the first argumentargument for the address parameter of type void *.

6.5 [expr.delete]

9 For an delete operator applied to an operand of type T*, type-identity argument is a value of type std::type_identity<U> where U is the result of removing qualifiers from the type T.

[ Drafting note: p10 below is trying to update the existing overload resolution semantics to say

  • Perform template argument deduction and synthesis - this previously was not performed as it was definitionally not possible.

  • Specify selection of the best viable function given that it is now possible for multiple allocation functions of the same type to exist.

]

10 For each function template found, function template argument deduction ([temp.deduct]) is performed using the type of the type-identity as the first parameter, and the type of each subsequent parameter in the candidate function template. If argument deduction and checking succeeds, and the deduced type of the first parameter is the same type as the type identity tag, the deduced template-arguments are used to synthesize the declaration of a single function template specialization; Otherwise the declaration is eliminated from further consideration.

11The deallocation function to be called is selected from the candidate set as follows:

  • (11.1) If any of the deallocation functions is a destroying operator delete, all deallocation functions that are not destroying operator deletes are eliminated from further consideration.
  • (11.2) If any of the deallocation functions have a first parameter of the same type as the type-identity, all deallocation functions that do not have a first parameter of that type are eliminated from further consideration.
  • (11.3) If the type has new-extended alignment, a function with a parameter of type std::align_val_t is preferred; otherwise a function without such a parameter is preferred. If any preferred functions are found, all non-preferred functions are eliminated from further consideration.
  • (11.4) If multiple functions of the same type are found, only the most viable functions [over.match.best] are retained, all others are eliminated from further consideration.

12 For a single-object delete expression, the deleted object is the object A pointed to by the operand if the static type of A does not have a virtual destructor, and the most-derived object of A otherwise. [ Note: If the deallocation function is not a destroying operator delete and the deleted object is not the most derived object in the former case, the behavior is undefined, as stated above.end note ] If the selected function is type aware, then it will be called with the type-identity argument as the first argument to the call. For an array delete expression, the deleted object is the array object. When a delete-expression is executed, the selected deallocation function shall be called with the address of the deleted object in a single-object delete expression, or the address of the deleted object suitably adjusted for the array allocation overhead ([expr.new]) in an array delete expression, as theits first argument following the type identity argument if present.

6.6 [expr.const]

(10.18.1) the selected allocation function is a replaceable global allocation function ([new.delete.single], [new.delete.array]) or a global type aware allocation function where, if the the type identity tag is ignored, matches a replaceable global allocation function and the allocated storage is deallocated within the evaluation of E, or

7 Design choices and notes

7.1 Design choice: Allowing type aware allocation functions in constant expressions

New since Wroclaw

In the current specification it is not possible for an author to specify a constexpr operator new or operator delete. Instead, using a new-expression or a delete-expression in a constexpr context requires that the resolved operator be ::operator new / ::operator delete, and the compiler elides the call entirely. In particular, if an in-class T::operator new is defined and the new-expression resolves to it, the new-expression is not constexpr-friendly. That seems to be an arbitrary limitation.

We clearly don’t want or need such a limitation for typed operator new. Our solution to that is to treat a global typed operator new (without placement parameters) just like an untyped ::operator new, i.e. as a replaceable global allocation function. While that does mean the compiler will assume the behaviour of typed operator new, that seems like a reasonable assumption inside constexpr. We apply the same rationale to operator new[], operator delete, and operator delete[] in order to fully support type aware allocators where existing global allocators are supported.

7.2 Design choice: Stripping qualifiers from the type

New since Wroclaw

There is a question as to whether the type-identity should include qualifiers. In previous drafts this was not addressed, and retaining the qualifiers was implied. After consideration we have realised that this would be surprising, is inconsistent with how the type being allocated is presented to the developer throughout the process of allocation and deallocation, and results in the type-identity trivially diverging between such allocation and deallocation e.g.

const T *t = new T;
...
delete t;

7.3 Design choice: Safety restriction on mismatched declaration scopes

New since Wroclaw

There is currently no language mechanism to enforce that operator new and operator delete are defined as a pair. That is a potential source of confusion and bugs. However, since we are introducing a new form of operator new and operator delete, we are free to change these rules, and it would be simple to do so. In [expr.new] we suggest adding the follow paragraph to force the selected operator delete to be in the same scope as its matching operator new. We believe that the vast majority of use cases will already assume that’s the case, and this would only catch a potentially common misuse.

31 If a single matching deallocation is found and either the allocation function or the deallocation function has a type-identity parameter, then both functions shall belong to the same scope ([basic.scope.scope]), otherwise the program is ill-formed.

7.4 Design choice: std::type_identity<T> vs “raw” template argument

In an earlier draft, this paper was proposing the following (seemingly simpler) mechanism. Instead of using std::type_identity<T> as a tag, the compiler would search as per the following expression:

operator new<T>(sizeof(T), args...)

The only difference here is that we’re passing the type being allocated directly as a template argument instead of using a std::type_identity<T> tag parameter. Unfortunately, this has a number of problems, the most significant being that it’s not possible to distinguish the newly-introduced type-aware operator from existing template operator new and operator delete declarations.

For example, a valid operator new declaration today would be:

template <class ...Args>
void* operator new(std::size_t, Args...);

Hence, an expression like new (42) int(3) which would result in a call like operator new<int>(sizeof(int), 42) could result in this operator being called with a meaning that isn’t clear – is it a type-aware (placement) operator or a type-unaware placement operator? This also means that existing and legal operators could start being called in code bases that don’t expect it, which is problematic.

Beyond that being confusing for users, this also creates a legitimate problem for the compiler since the resolution of new and delete expressions is based on checking various forms of the operators using different priorities. In order for this to make sense, the compiler has to be able to know exactly what “category” of operator a declaration falls in, so it can perform overload resolution at each priority on the right candidates.

Finally, when a constructor in a new-expression throws an exception, an operator delete that must be a usual deallocation function gets called to clean up. If there is no matching usual deallocation function, no cleanup is performed. Using a template parameter instead of a tag argument could lead to code where no cleanup happened to now find a valid usual deallocation function and perform a cleanup.

Taken together we believe these issues warrant the use of an explicit tag parameter.

7.5 Design choice: std::type_identity<T> vs T*

This proposal uses std::type_identity<T> as a tag argument rather than passing a first argument of type T*. At first sight, passing T* as a first tag argument seems to simplify the proposal and decouple the compiler from the standard library.

However, this approach hides an array of subtle problems that are avoided through the use of std::type_identity.

7.5.1 Problems with the value being passed

The first problem is the value being passed as the tag parameter. Given operator signatures of the form

template <class T> void *operator new(T*, size_t);
template <class T> void operator delete(T*, void*);

Under the hood, the compiler could perform calls like

// T* ptr = new T(...)
operator new<T>((T*)nullptr, sizeof(T));

// delete ptr
operator delete<T>((T*)nullptr, ptr);

A developer could reasonably be assumed to know that the tag parameter to operator new can’t be anything but a null pointer. However, for operator delete we can be assured that people will be confused about receiving two pointer parameters, where the explicitly typed parameter is nullptr. Also note that we cannot pass the object pointer through that parameter as operator delete is called after the object has been destroyed. Passing the memory to be deallocated through a typed pointer is an incitation to use that memory as a T object, which would be undefined behavior.

7.5.2 Silent conversions to base types

A scenario we have discussed is developers wishing to provide custom [de]allocation operators for a whole class hierarchy. When using a typed pointer as the tag, this would be written as:

struct Base { };
void* operator new(Base*, std::size_t);
void operator delete(Base*, void*);

This operator would then also match any derived types of Base, which may or may not be intended. If not intended, the conversion from Derived* to Base* would be entirely silent and may not be noticed. Furthermore, this would basically defeat the purpose of providing type knowledge to the allocator, since only the type of the base class would be known. We believe that the correct way of implementing an operator for a hierarchy is this:

struct Base {
  template <class T>
  void* operator new(std::type_identity<T>, std::size_t); // T is the actual type being allocated

  template <class T>
  void operator delete(std::type_identity<T>, void*);
};

Or alternatively, in the global namespace:

template <std::derived_from<Base> T>
void* operator new(std::type_identity<T>, std::size_t);

template <std::derived_from<Base> T>
void operator delete(std::type_identity<T>, void*);

For these reasons, we believe that a tag type like std::type_identity is the right design choice.

7.6 Design choice: Location of std::type_identity<T>

When writing this paper, we went back and forth of the order of arguments. This version of the paper proposes:

operator new(std::type_identity<T>, std::size_t, placement-args...)
operator new(std::type_identity<T>, std::size_t, std::align_val_t, placement-args...)

operator delete(std::type_identity<T>, void*)
operator delete(std::type_identity<T>, void*, std::size_t)
operator delete(std::type_identity<T>, void*, std::size_t, std::align_val_t)

Another approach would be:

operator new(std::size_t, std::type_identity<T>, placement-args...)
operator new(std::size_t, std::align_val_t, std::type_identity<T>, placement-args...)

operator delete(void*, std::type_identity<T>)
operator delete(void*, std::size_t, std::type_identity<T>)
operator delete(void*, std::size_t, std::align_val_t, std::type_identity<T>)

The existing specification allows for the existence of template (including variadic template) declarations of operator new and delete, and this functionality is used in existing code bases. This leads to problems compiling real world code where overload resolution will allow selection of a non-SFINAE-safe declaration and subsequently break during compilation.

Placing the tag argument first ensures that no existing operator definition can match, and so we are guaranteed to be free from conflicts.

7.7 Design choice: Templated type-aware operator delete is a usual deallocation function

Allowing type-aware operator delete does require changes to the definition of usual deallocation functions, but the changes are conceptually simple and the cost of not supporting this case is extremely high.

In the current specification, we place very tight requirements on what an operator delete declaration can look like in order to be considered a usual deallocation function. The reason this definition previously disallowed function templates is that all of the implicit parameters are monomorphic types. That restriction made sense previously.

However, this proposal introduces a new form of the operators for which it is correct (even expected) to be a function template. To that end, we allow a templated operator delete to be considered a usual deallocation function, as long as the only dependently-typed parameter is the first std::type_identity<T> parameter. To our minds, these semantics match the “intent” of the restrictions already in place for the other implicit parameters like std::align_val_t.

The cost of not allowing a templated type-aware operator delete as a usual deallocation function is very high, as it functionally prohibits the use of type-aware allocation operators in any environment that requires the ability to clean up after a constructor has thrown an exception.

7.8 Design choice: No support for type-aware destroying delete

We have decided not to support type-aware destroying delete as we believe it creates a user hazard. At a technical level there is no additional complexity in supporting type-aware destroying delete, but the resulting semantics seem likely to cause a lot of confusion. For example, given this hypothetical declaration:

struct Foo {
  ...
  template <class T>
  void operator delete(std::type_identity<T>, Foo*, std::destroying_delete_t);
};

struct Bar : Foo { };

void f(Foo* foo) {
  delete foo; // calls Foo::operator delete<Foo>
}

void g(Bar *bar) {
  delete bar; // calls Foo::operator delete<Bar>
}

To a user this appears to be doing what they expect. However, consider the following:

struct Oops : Bar { };

void h(Oops *oops) {
  g(oops); // calls Foo::operator delete<Bar> from within g()
}

By design, destroying delete does not perform any polymorphic dispatch, and as a result the type being passed to the operator is not be the dynamic type of the object being destroyed, but rather its static type. As a result, basic functionality will appear to work correctly from the user’s point of view when in reality the rules are much subtler than they seem.

Given that the design intent of destroying delete is for users to manage destruction and dispatching manually, we believe that adding type-awareness to destroying delete will add little value while creating the potential for confusion, so we decided not to do it.

7.9 Design choice: Dropped support for ADL

The initial proposal allowed the specification of type-aware operators in namespaces that would then be resolved via ADL. Upon further consideration, this introduces a number of challenges that are difficult to resolve robustly. As a result, we have dropped support for namespace-scope operator declarations and removed the use of ADL from the proposal.

The first problem is that ADL would be based on the type of all arguments passed to operator new, including placement arguments. While this is not a problem for operator new itself, operator delete does not get the same placement arguments, which would potentially change the set of associated namespaces used to resolve new and delete.

One of our original motivations for allowing namespace-scoped operators was to simplify the task of providing operators for a whole library. However, since ADL is so viral, the set of associated namespaces can easily grow unintentionally (e.g. new lib1::Foo<lib2::Bar>(...)), which means that developers would have to appropriately constrain their type-aware operators anyway. In other words, we believe that a declaration like this would never have been a good idea in the first place:

namespace lib {
  // intent: override for all types in this namespace
  template <class T>
  void* operator new(std::type_identity<T>, std::size_t);
}

There are too many ways in which an unconstrained declaration like this can break, including an unexpected set of associated namespaces or even a mere using namespace lib;.

Given the need to constrain a type-aware operator anyway, we believe that allowing namespace-scoped operators is merely a nice-to-have but not something that we need fundamentally. Furthermore, adding this capability to the language could always be pursued as a separate proposal since that concern can be tackled orthogonally. For example, a special ADL lookup could be done based solely on the dynamic type being [de]allocated.

Since this adds complexity to the proposal and implementation and doesn’t provide great value, we are not pursuing it as part of this proposal.

7.10 Interactions with std::allocator<T>

Today, std::allocator<T>::allocate is specified to call ::operator new(std::size_t) explicitly. Even if T::operator new exists, std::allocator<T> will not attempt to call it. We view this as a defect in the current standard since std::allocator<T> could instead select the same operator that would be called in an expression like new T(...) (without the constructor call, obviously).

This doesn’t have an interaction with our proposal, except for making std::allocator<T>’s behavior a bit more unfortunate than it already is today. Indeed, users may rightly expect that std::allocator<T> will call their type-aware operator new when in reality that won’t be the case.

Since this deception already exists for T::operator new, we do not attempt to change std::allocator<T>’s behavior in this proposal. However, the authors are willing to investigate fixing this issue as a separate proposal, which will certainly present its own set of challenges (e.g. constant evaluation).

7.11 ODR implications and mismatched operator new / operator delete

A concern that was raised in St-Louis was that this proposal would increase the likelihood of ODR violation caused by different declarations of operator new/operator delete being used in different TUs. For example, one TU would get lib1::operator new and another TU would use lib2::operator delete due to e.g. a different set of headers being included. Note that the exact same issue also applies to every other operator that is commonly used via ADL (like operator+), except that many such ODR violations may end up being more benign than a mismatched new/delete.

First, we believe that the only way to avoid this issue (in general) is to properly constrain templated declarations, and nothing can prevent users from doing that incorrectly. However, since this proposal has dropped the ADL lookup, declarations of type-aware operators must now be in-class or global. This greatly simplifies the selection of an operator, which should make it harder for users to unexpectedly define an insufficiently constrained operator without immediately getting a compilation error.

Furthermore, without ADL lookup, the ODR implications of this proposal are exactly the same as the existing ODR implications of user-defined placement new operators, which can be templates.

7.12 Impact on the library

This proposal does not have any impact on the library, since this only tweaks the search process performed by the compiler when it evaluates a new-expression and a delete-expression. In particular, we do not propose adding new type-aware free function operator new variants in the standard library at this time, althought this could be investigated in the future.

7.13 Interactions with coroutines

Coroutines currently allow using a custom operator new and operator delete for allocating the coroutine frame. That is done by looking up in the coroutine’s promise type for Promise::operator new. However, there is no mechanism to communicate the type being allocated, since that type is only something known by the compiler (and fairly late during translation). This paper does not propose changing how allocation for coroutine frames is customized. In the future, the coroutine specification could be updated to make the type being allocated more visible and to use a typed allocation function, but there is significant enough design space to avoid doing that here.

8 Acknowledgments

We’d like to acknowledge the time spent by Jens Maurer, Brian Bi, Corentin Jabot, Richard Smith, Hana Dusikova, and others to help us develop the wording in this proposal.