4

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.

6
  • 2
    Why do you want to allow evading the invariant of the class that its members are initialized? What standard-library type are we talking about? Commented Feb 1 at 2:28
  • Informally "Trivial" means equivalent of noop/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. Commented Feb 1 at 7:50
  • Do you realize you are making a contradictory requirement statement here? It is not only about Foo but also about T. T must be trivially copyable/constructible/destructible and Foo should be constrained to that (concepts). Tell your friend that fallout you get after that adding that requirement/constraint to Foo is code that needs fixing. Commented Feb 1 at 7:58
  • I think all of you are asking the same question, just in a different way. I tried to avoid getting into too many issues, but I edited the question to hopefully address your questions - and keep others from being confused and asking similar questions. Commented Feb 1 at 9:34
  • 1
    @PatrickRoberts—I Agree. The paper acknowledges that compilers didn't implement it that way, and I believe the standard didn't dictate that anyway (the wording was possibly ambiguous). I think this was an overreach and should have been left trivial for trivial types. However, once it was made that way, it can't be changed back without breaking code that now assumes default initialization will zero-initialize. The only option to prevent UB is to use a native type and then use atomic_ref or C/native atomic functions. With the sync functions, atomic is dangerous across processes anyway. Commented Feb 3 at 19:18

2 Answers 2

1

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).

Sign up to request clarification or add additional context in comments.

5 Comments

Yeah - but the standard and compilers are moving to define this kind of behavior. And I don't want to just depend on the compiler doing what I want when it is clearly UB. And it just gets me that std::atomic already fit this definition until we decided that std::atomic<int> x; should zero-initialize. In the end, I was hoping against hope that there was a way to put the jeanie back in the bottle to fix the implicit lifetime requirement without breaking the now-set-in-stone zero initialization.
@JodyHagins My point is that no, compilers are not moving in this regard, they are being very very conservative. People widely rely on this stuff working.
@JodyHagins That said, you could use std::atomic_ref.
Another thing to consider is that compilers don't seem to optimize atomics, which means you're even less likely to have issues with them.
Yeah, but the purpose of all this implicit lifetime stuff is to provide well defined usage - so that the abstract machine can clearly manage these lifetimes. I don't like doing things that depend on the compiler to neve change its mind.
0

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.

6 Comments

Quote from the standard: [Note 1: This restriction enables communication by memory that is mapped into a process more than once and by memory that is shared between two processes. —end note]. Before P0883, std::atomic would be fine to use - but after, it can no longer be considered an implicit lifetime type. I agree we don't want to weaken them - I'm just trying to find a way to make std::atomic an implicit lifetime type again - but I really don't think that can be done and a new type (mine is called ipc_atomic) is needed.
@JodyHagins: I disagree that it “would be fine to use”: you’re trying to justify a reinterpret_cast, and you can’t with wording that implicitly creates objects, nor really with std::start_lifetime_as (if you even had that in the prior world). I’m not saying it won’t work in practice; I’m saying this point about (the change to) std::atomic is not the singular impediment your question seems to say it is.
Sorry - I don't follow. Without P0883, std::atomic<int> would be an implicit lifetime type because it would meet all other requirements, including at least one trivial constructor (trivial default constructor). However, because the default ctor is no longer trivial, it is not an implicit lifetime type, so no implicit lifetime rules apply, and even calling std::start_lifetime_as would not work. It would be eligible for implicit lifetimes if it still had its trivial default constructor. I'm obviously missing something here, but I don't see it.
@JodyHagins: My claim, and the basis of my answer, is that it being implicit-lifetime doesn’t actually help you: you don’t want your second process to create an object at all but to use the one created by the first process. If you’re willing to claim that the object does exist in the second process (which is not unreasonable in this space beyond the standard), you just cast with abandon and don’t need the implicit lifetime property at all.
I never try to create it again - just once when the shared memory is created - and the default ctor is never even called. The subsequent processes use mmap. However, to not have UB, the objects must be implicit lifetime types, whether utilizing the auto-blessing from mmap or start_lifetime_as. But, since the default ctor is not trivial, std::atomic<int> has ZERO trivial constructors - and at least one must exist to be an implicit lifetime type. Thus, the abstract machine can never see its lifetime because it only allows implicit lifetimes for implicit lifetime types.
|

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.