Since C++17, associative containers support the extraction of a node and its re-insertion (possibly into another container of the same type). The object returned by extrac
I'm just following up on Kerrek SB's answer in the hope to explain the issue in more detail (and hence a bit more convincingly). The adopted revision 3 of the paper in question (P0083R3) mentions the std::pair
vs std::pair
conundrum, and that "The conversion between these can be effected safely using a technique similar to that used by std::launder
on extraction and reinsertion."
IOW, extraction and reinsertion are safe from optimizations related to type punning by invoking "implementation 'magic'" (this is the explicit wording in the paper) to resolve any possible aliasing problems within the container code itself, and insofar as user code abides by the restrictions you mentioned.
This raises the question of why this "magic" cannot be extended to cover also cases where user code accesses the Mapped
element of a dissociated node through a pointer that was obtained while the node still belonged to a container. The reason for this is that the scope of implementing such "magic" is considerably greater than the scope of implementing the limited magic that applies only to node extraction and insertion.
Consider e.g., trivially, this function:
int f(std::pair &a, const std::pair &b)
{
a.second = 5;
return b.second;
}
As per the restrictions on type aliasing, the implementation is allowed to assume that a
and b
cannot reside at the same memory location. Therefore, the implementation is also allowed to assume that a.second
and b.second
do not reside at the same memory location, even though they have the same type. Therefore, an implementation is entitled to some very basic code generation freedoms such as performing the load of b.second
before the store to a.second
, without needing to actually compare the addresses of a
and b
first.
Now assume that the restrictions on map splicing were not as you have mentioned. Then, it would be possible to do the following:
int g()
{
std::map m{{1, 1}};
auto &r = m[1];
auto node = m.extract(1);
return f(r, node.value());
}
Because of the type punning restrictions, clearly this is UB. Now, hold on, I know you want to protest because:
node_type
for a std::map
does not have a value()
method.node_type
is supposed to std::launder
(or something roughly equivalent) the value.However, these points do not provide an actual remedy. As far as the first point is concerned, consider this minor variation:
int f(std::pair &a, const int &b)
{
a.second = 5;
return b;
}
int g()
{
std::map m{{1, 1}};
auto &r = m[1];
auto node = m.extract(1);
return f(r, node.mapped());
}
Now, peephole optimization into f
does not give the compiler sufficient information to rule out aliasing. However, let's assume that the compiler is able to inline both node.mapped()
(and therefore, the compiler can establish that it returns a reference to the second
of a std::pair
), and f
. Suddenly, the compiler may once more feel entitled to a dangerous optimization.
But what about the laundering? First of all, this does not apply here, because the information regarding extract
and the laundering done within it may be in a different translation unit altogether than node_type::mapped()
. This needs emphasizing: With the tight restrictions that were standardized, the laundering can be done during extraction, it does not have to be done on every call to value()
, which is also clearly the intent expressed in the quote I provided at the beginning. The main issue, however, is that laundering cannot prevent UB here, even if it were done inside node_type::mapped()
. In fact, the following code has undefined behavior (example assumes that sizeof(int) <= sizeof(float)
):
float g()
{
float value = 0.0f; // deliberate separate initialization, see below
value = 3.14f;
int *intp = std::launder(reinterpret_cast(&value));
*intp = 1;
return value + *intp;
}
This is because using std::launder
does not give the user permission to type punning at all. Instead, std::launder
only allows reusing the memory of value
by establishing a lifetime dependency between the float
that lives at &value
initially and the int
that lives there after the std::launder
. In fact, as far as the standard is concerned, value
and *intp
cannot possibly be alive at the same time precisely because they have pointer-incompatible types and the same memory location.
(What std::launder
does achieve, here, is e.g. to prevent reordering of value = 3.14f;
to after *intp = 1
. Simply put, the compiler is not allowed to reorder writes to after the std::launder
, nor reads from a laundered pointer to before the std::launder
, unless it can prove that the memory locations do not in fact overlap, and this is true even if pointer-incompatible types are involved. I used a separate assignment so that I could make this point more clearly.)
What this finally boils down to, is that, to safely support the usage you envision, implementations would have to add additional magic on top of that mentioned in the paper (and the latter is largely implemented already because it is at least very similar to the effects of std::launder
). Not only would this have caused additional effort, but it could also have had the side effect of preventing certain optimizations in cases where the user voluntarily abides by the restrictions as standardized. Judgment calls like this are made all the time in standardizing C++, or pretty much anything, where the experts try to weigh the projected costs against certain or at least probable benefits. If you still need to know more, you'd likely have to approach some of the CWG members directly, because this is where the request to apply these restrictions was made, and as mentioned in the paper linked above.
Hope this helps to clear things up a bit, even if you still disagree with the resolution taken.
As a final note, I highly recommend you to watch some of the great talks on C++ UB if you haven't already, for instance Undefined Behavior is Awesome by Piotr Padlewski, or Garbage In, Garbage Out… by Chandler Carruth.