Document #: | P2719R1 |
Date: | 2024-10-16 |
Project: | Programming Language C++ |
Audience: |
Evolution |
Reply-to: |
Louis Dionne <ldionne@apple.com> Oliver Hunt <oliver@apple.com> |
std::type_identity<T>
vs
“raw” template argumentstd::type_identity<T>
vs
T*
std::type_identity<T>
operator delete
is a
usual deallocation functionstd::allocator<T>
operator new
/
operator delete
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) { ... }
std::type_identity
parameter for in-class
T::operator new
for
consistencystd::type_identity
as
the first parameterKnowledge 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.
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.
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:
(sizeof(T), args...) NEW
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:
(sizeof(T), std::align_val_t(alignof(T)), args...) NEW
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).
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
|
---|---|
|
|
|
|
If the user writes
delete ptr
, the compiler checks
(in order):
Before
|
After
|
---|---|
|
|
|
|
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).
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
.
// 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
}
std::type_identity<T>
vs
“raw” template argumentIn 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.
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
.
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.
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*);
There is a fundamental difference between
T*
and
std::type_identity<T>
in
that T*
is a type that has an
actual value and size, whereas
std::type_identity
is a zero
sized record. This difference means that the
T*
model results in an
additional parameter being required in the generated code, whereas the
zero sized type_identity
parameter does not exist in the majority of calling conventions. In
principle this difference should be minor for templated operators as
they are typically inlined and so the calling convention is not
relevant, but for non-template definitions the implementation can be out
of line, and so the difference may matter.
For all of these reasons, we believe that a tag type like
std::type_identity
is the right
design choice.
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.
operator delete
is a
usual deallocation functionAllowing 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.
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) {
(oops); // calls Foo::operator delete<Bar> from within g()
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.
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.
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).
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.
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.