Is allocators' allocate and construct well-defined through [basic.life]p8?
Asked Answered
H

1

6

cppreference's example of std::allocator contains this code (shortened for simplicity):

// default allocator for ints
std::allocator<int> alloc1;

using traits_t1 = std::allocator_traits<decltype(alloc1)>; // The matching trait
p1 = traits_t1::allocate(alloc1, 1);
traits_t1::construct(alloc1, p1, 7);  // construct the int
std::cout << *p1 << '\n';

Rather straight-forward as far as allocators are concerned. However, what wording in the standard guarantess that p1 actually points to the new object?

According to cppreference documentation on std::allocate and [allocator.members], the default allocator's allocate() function

creates an array of type T[n] in the storage and starts its lifetime, but does not start lifetime of any of its elements.

and returns

[a pointer] to the first element of an array of n objects of type T whose elements have not been constructed yet.

Afaik, the array creation wording was added to the standard so that pointer-arithmetic on the pointer is valid. In any case, this means that the returned pointer points the first element of the T[] and the lifetime of this first element was not started.

construct() then creates an object at this location, however, it does not return a pointer to this object. The only pointer we have is still the one allocate returned.

Usually when an object is placed in the location of an expired object, it can "transparently replace" the old one under the conditions laid out in [basic.life]p8: (emphasis mine)

If, after the lifetime of an object has ended and before the storage which the object occupied is reused or released, a new object is created at the storage location which the original object occupied, a pointer that pointed to the original object [...] will automatically refer to the new object and, once the lifetime of the new object has started [...]

The lifetime of this array element was never started so it cannot have ended, so this should not apply. How else can it then be guaranteed that accessing the newly constructed object is well-defined? Is std::launder supposed to be used after every construct call?

Please keep in mind that this is a [language-lawyer]-tagged question. It's not about whether this code practically works but about the "laws" of the standard.

Horsey answered 1/9, 2023 at 13:33 Comment(22)
Looks like a missing launder to me.Kame
Isn't this really just asking if a placement new is valid? Because it seems that's exactly what allocator::construct uses.Endocrinotherapy
@Kame That was my first thought, too. However, I believe this usage is very common and not just limited to cppreference's examples, which is why I thought there might be an interpretation that makes launder unnecessary.Horsey
@AdrianMole placement new returns a pointer to the new object, which can then be used. construct throws that pointer away and doesn't return anything. So with plain placement-new, the problem can be side-stepped by using the returned pointer. aditionally, in the situations where placement-new is used, basic.life/8 usually applies because the overwritten object was once within its lifetime. only allocator seems to be an exception in that it can create an array without starting the lifetime of this array's members.Horsey
@Kame I don't think that is intended. It would be weird to have to do that the first time you construct the object, but not the second time. I remember there were similar other issues with basic.life/8 like this one.Hardeman
At a stretch, this could arguably be a defect in the Standard but, IMHO, the part, and before the storage which the object occupied is reused or released seems to cover the case you describe. OK, there wasn't an object there before, but that can also be true when using placement new. The storage is there and has not been reused or released.Endocrinotherapy
In the case where there was an object there before, and it's lifetime has ended, then it's as if there was no longer an object there. Is not?Endocrinotherapy
@AdrianMole "and before the storage which the object occupied is reused or released" is a condition in addition to "after the lifetime of an object has ended". "OK, there wasn't an object there before": The object does exist, but its lifetime hasn't started yet. Placement-new or construct will however create a _new object in its place and start the lifetime of that new object, not the old one. "OK, there wasn't an object there before": The new object is reusing the storage of the old one that never had its lifetime started.Hardeman
@AdrianMole "Then it's as if there was no longer an object there. Is not?": Not according to the quoted passage of the standard (and also others). In particular if there never was an object, then the pointer from allocate would have to be invalid and could never automatically refer to another object).Hardeman
I'm still having trouble differentiating between storage that was once occupied by an object whose lifetime has ended and storage for an object whose lifetime has not yet started.Endocrinotherapy
@AdrianMole The storage doesn't matter. A pointer is either a null pointer, pointing to an object (possibly out-of-lifetime), one-past an object or is invalid. There are no pointers to storage without an object. (Except that the behavior of invalid pointers is implementation-defined and so could work like that.)Hardeman
But I guess that's why I only have a bronze badge in the language-lawyer tag?Endocrinotherapy
The allocation created an array of objects and the returned value is a pointer to the first element ... whose lifetime has not yet started. So, once an object is constructed there, the pointer will remain valid, just as in the case where an object was there before.Endocrinotherapy
@AdrianMole I am pretty sure that's the intent, but this question is specifically about whether the normative wording actually states this equivalence, which it seems to not do.Hardeman
@Horsey There is another case in addition to std::allocator::allocate. Any implicit object creation can create and start the lifetime of an array object with any non-implicit lifetime element type, but won't start the lifetime of its elements. You can make the same argument there.Hardeman
@Hardeman speaking of "similar other issues with basic.life" this question (which I also created today) is related: #77024429Horsey
After traits_t1::construct(alloc1, p1, 7);, the object (int) created satisfies [intro.object]/9. As a consequence, it may have the same address as p1. "Two objects with overlapping lifetimes [here: the p1 array object and the created int] may have the same address if one is nested within the other"Mccue
MMh... This doesn't help.Mccue
Note: the new construct_at from C++20 does return the pointer to the newly created object.Mccue
@Mccue allocator_traits::construct calls this construct_at, afaik, since in C++20 the default std::allocator does not implement a member construct function anymore, so the allocator_traits::construct falls back to construct_at, however, it does not in turn pass that pointer on to the caller.Horsey
Indeed... Disapointing. I've tend to agree with you: allocator_traits::construct is quite problematic.Mccue
The lifetime of this array element was never started so it cannot have ended Not sure if lifetime start is a precondition to lifetime end. But anyway, this (non-?)issue is not new and there is a 3-4 y.o. question about unions: union U { int i; double d; } u {}; u.d = 0; will d name the new object, since original u.d. has never been alive? (In the union case, itsa bit unclear if d is a «name»)Millpond
C
1

I think we mostly agree that this code ought to work. std::launder is typically only needed when a const complete object is replaced by another object of the same type (ignoring top-level cv-qualifiers)1; [basic.life]/8.3. The rationale here is that if the compiler can see the code that creates the const complete object, it's allowed to assume that a pointer to that object or a reference to that object is a pointer or reference to an unchanging value, which provides useful optimization opportunities. You have to use std::launder to disable this optimization. This suggests that if there was no value in the first place (because this is the first time you are starting the lifetime of an object in a particular region of storage), then std::launder should not be needed (even if the object is const).

I think the OP is right that there's a wording gap in the standard. It might, unfortunately, be one that is not easily fixable at the present time. The problem is that we don't have a formal characterization of the identity of an object that is not within its lifetime. Perhaps the allocate call creates an uninitialized object with a particular identity, o1, and construct starts the lifetime of o1. If that's the case, then there is no problem: any pointer to o1 continues to be a pointer to o1 unless the pointer has its value changed by assigning to it. But that leads to an obvious follow-up question. If o1 is destroyed and a new object o2 has its lifetime begun in the same storage, we know that o1 and o2 are not the same object, but what can we say about the storage after o1's lifetime ended and before o2's lifetime began? We have a partial characterization of what can be done using a pointer or reference to that storage; [basic.life]/6–7. We do not have any explanation of when exactly the storage transitions from being occupied by "o1, whose lifetime has ended" to "o2, whose lifetime has not yet begun". Someone needs to do the work of figuring out an answer and working through all the implications of it. (And I suspect that there is no answer that has all the desired implications.)

Still, given that the object in OP's example is not even const, there is practically no chance that the eventual resolution to this ambiguity would make OP's code have undefined behaviour.

1 Note that such a const object must have dynamic storage duration; [basic.life]/10.

Cantoris answered 3/9, 2023 at 19:39 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.