TLDR;
Can we make a non-copyable, non-movable class with zero default initialization into an implicit lifetime type without changing the existing constructors?
Directly from the C++23 specification, 11.2
9 A class S is an implicit-lifetime class if
(9.1) — it is an aggregate whose destructor is not user-provided or
(9.2) — it has at least one trivial eligible constructor and a trivial, non-deleted destructor.
But what does "at least one" mean? Sounds like any one of the many possible constructors. However, the spec also says that for a constructor to be trivial, it must be...
Implicitly declared or explicitly defaulted (= default) on its first declaration.
Since only special member functions can be either of those, only special member functions can technically be trivial. So, the answer, in my mind: not a chance.
But I ain't the sharpest tool in the shed and may be missing something in the standard.
Consider this minimized existing class.
template <typename T>
struct Foo
{
T t;
Foo() : t() { }
Foo(Foo const &) = delete;
Foo(Foo &&) = delete;
};
static_assert(not std::is_trivially_default_constructible_v<Foo<int>>);
static_assert(not std::is_trivially_copy_constructible_v<Foo<int>>);
static_assert(not std::is_trivially_move_constructible_v<Foo<int>>);
But, we want to be able to use instances of this type in situations that require it to be an implicit lifetime type (when T could be used in such a way). It is not currently an implicit lifetime type.
So, we add this.
template <typename T>
struct Foo
{
// ...
Foo(tag_t) { } // Kneel, bow, and pray...
};
Effectively, Foo<int>::foo(tag_t)
behaves as a trivial constructor (same for Foo() {}
).
Technically, Foo<int>::Foo(tag_t)
is not a trivial constructor (same for Foo() {}
).
Furthermore, the reference implementation of std::is_implicit_lifetime
in P2674 only checks the default/copy/move constructors for triviality. I get that because anything else would require compiler support. However, the standard itself says any constructor, and the compiler can see that this constructor does nothing, so it could detect that there is an effective trivial constructor... and I'm crossing my fingers and my toes.
I'm guessing it doesn't, but as of today, no official releases of any major vendor provide this type trait.
So, given that the default/copy/move constructors are not trivial - how do you make Foo
into an implicit lifetime type - without changing the current default/copy/move constructor - that would break any current code that uses those types?
Before you tell me not to write classes like that, this isn't one of my classes. It's for, um, yeah, it's for a friend. See, there is already at least one just like that in the standard library. One that is being treated as if it were an implicit lifetime type in lots of existing code. But, technically, it is not, and I'm trying to make it one without having to write a paper.
EDIT
I was trying not to get into issues about specific types here but to keep it to the standard to see if there is a way around this issue. However, kind people who want to help ask the same question in different ways, so here goes (and this is almost as short as I can keep it).
In C++20, std::atomic
was modified to enforce zero-initialization through its default constructor. While this change improved safety for most use cases, it inadvertently made std::atomic
unsuitable for shared memory scenarios by removing its trivial default constructor and implicit lifetime characteristics.
Using std::atomic<int>
in shared memory now results in undefined behavior. While std::atomic_ref
provides a workaround, it sacrifices encapsulation and type safety. The standard currently offers no type designed explicitly for atomic operations in shared memory despite acknowledging this use case in its specification of lock-free operations.
IMO, this was an oversight at the time. The paper, P0883, was trying to make sure that std::atomic<int> i{}
was zero initialized. Yeah, that looks strange - please read the paper if you'd like more details. It went further than that, though, and made default initialization of all std::atomic
types zero initialize instead of default initialized.
So, since the beginning, the copy/move constructors of std::atomic
have been deleted, for good reason. With C++20, the default constructor is no longer trivial, even when T
is int
.
This is the exact scenario as my Foo
example. I didn't think it mattered whether we were talking about my Foo
or std::mutex
, and bringing std::mutex
into it just makes the discussion become more complicated.
TLDR;
Can we make a non-copyable, non-movable class with zero default initialization into an implicit lifetime type without changing the existing constructors?
Directly from the C++23 specification, 11.2
9 A class S is an implicit-lifetime class if
(9.1) — it is an aggregate whose destructor is not user-provided or
(9.2) — it has at least one trivial eligible constructor and a trivial, non-deleted destructor.
But what does "at least one" mean? Sounds like any one of the many possible constructors. However, the spec also says that for a constructor to be trivial, it must be...
Implicitly declared or explicitly defaulted (= default) on its first declaration.
Since only special member functions can be either of those, only special member functions can technically be trivial. So, the answer, in my mind: not a chance.
But I ain't the sharpest tool in the shed and may be missing something in the standard.
Consider this minimized existing class.
template <typename T>
struct Foo
{
T t;
Foo() : t() { }
Foo(Foo const &) = delete;
Foo(Foo &&) = delete;
};
static_assert(not std::is_trivially_default_constructible_v<Foo<int>>);
static_assert(not std::is_trivially_copy_constructible_v<Foo<int>>);
static_assert(not std::is_trivially_move_constructible_v<Foo<int>>);
But, we want to be able to use instances of this type in situations that require it to be an implicit lifetime type (when T could be used in such a way). It is not currently an implicit lifetime type.
So, we add this.
template <typename T>
struct Foo
{
// ...
Foo(tag_t) { } // Kneel, bow, and pray...
};
Effectively, Foo<int>::foo(tag_t)
behaves as a trivial constructor (same for Foo() {}
).
Technically, Foo<int>::Foo(tag_t)
is not a trivial constructor (same for Foo() {}
).
Furthermore, the reference implementation of std::is_implicit_lifetime
in P2674 only checks the default/copy/move constructors for triviality. I get that because anything else would require compiler support. However, the standard itself says any constructor, and the compiler can see that this constructor does nothing, so it could detect that there is an effective trivial constructor... and I'm crossing my fingers and my toes.
I'm guessing it doesn't, but as of today, no official releases of any major vendor provide this type trait.
So, given that the default/copy/move constructors are not trivial - how do you make Foo
into an implicit lifetime type - without changing the current default/copy/move constructor - that would break any current code that uses those types?
Before you tell me not to write classes like that, this isn't one of my classes. It's for, um, yeah, it's for a friend. See, there is already at least one just like that in the standard library. One that is being treated as if it were an implicit lifetime type in lots of existing code. But, technically, it is not, and I'm trying to make it one without having to write a paper.
EDIT
I was trying not to get into issues about specific types here but to keep it to the standard to see if there is a way around this issue. However, kind people who want to help ask the same question in different ways, so here goes (and this is almost as short as I can keep it).
In C++20, std::atomic
was modified to enforce zero-initialization through its default constructor. While this change improved safety for most use cases, it inadvertently made std::atomic
unsuitable for shared memory scenarios by removing its trivial default constructor and implicit lifetime characteristics.
Using std::atomic<int>
in shared memory now results in undefined behavior. While std::atomic_ref
provides a workaround, it sacrifices encapsulation and type safety. The standard currently offers no type designed explicitly for atomic operations in shared memory despite acknowledging this use case in its specification of lock-free operations.
IMO, this was an oversight at the time. The paper, P0883, was trying to make sure that std::atomic<int> i{}
was zero initialized. Yeah, that looks strange - please read the paper if you'd like more details. It went further than that, though, and made default initialization of all std::atomic
types zero initialize instead of default initialized.
So, since the beginning, the copy/move constructors of std::atomic
have been deleted, for good reason. With C++20, the default constructor is no longer trivial, even when T
is int
.
This is the exact scenario as my Foo
example. I didn't think it mattered whether we were talking about my Foo
or std::mutex
, and bringing std::mutex
into it just makes the discussion become more complicated.
Can we make a non-copyable, non-movable class with zero default initialization into an implicit lifetime type without changing the existing constructors?
No. The intent of the standard (and the wording) is fairly clear, it wants a type that can be either constructed uninitialized or by a bitwise copy, and destroying which is a no-op.
Yours can't be constructed this way, so it can't be implicit-lifetime.
But I don't think trying to reach formal correctness in this case is a useful goal.
If std::atomic
worked for your purposes before, it won't suddenly stop working in C++20 (assuming you don't have a template that explicitly checks those type requirements).
The standard wording on lifetimes is more strict than what implementations actually enforce in practice (and is wording is slowly getting more and more relaxed).
The standard doesn’t have any concept of shared memory (beyond observing that some implementations of concurrency primitives can support this case by avoiding an address-based table). As such, there’s no way to say what we really want, which is not that the second process can create new objects that start with the value representations of the objects created by the first process (which would… diverge from the originals once they were modified?) but that the second process simply uses the same objects.
If we did have such a notion, it would remove the need for implicit-lifetime status, since the lifetime can be started in the normal fashion (in the first process) and the type can have whatever desirable invariants. As such, we don’t want to weaken the implicit-lifetime requirements (and thereby damage invariants) for the sake of a use case that would profit from it only incompletely in two different ways.
memcpy
/memmove
. Any function with a body is potentially more than noop; Specifically, a constructor not defined as=default
will initialize its sub-objects to zero. That means any not-defaulted constructor is not trivial. But outsides the special memebers default definition doesn't make sence; So other constructors are automatically overruled from being trivial - due to the fact that they cannot have a default definition. – Red.Wave Commented Feb 1 at 7:50