Something you should know about structured bindings
Today's post is partially about C++ Insights and a lesson learned. Last week Dawid Pilarski opened issue #381 for C++ Insights.
There he explained very well that he noticed that C++ Insights doesn't show the transformation of structured bindings correctly. He provided the following example:
1 2 3 4 5 6 7 |
|
At the time, C++ Insights showed the following transformation:
1 2 3 4 5 6 7 8 9 |
|
Dawid noticed that according to the standard ([dcl.dcl] p4), the internally created variable __tup6
should be moved in this example. Making the result look like this:
1 2 3 4 |
|
The example above is also from Dawid. While I totally agreed with what he wrote so far, I immediately reacted with "hell no" to the suggested transformation. I thought that couldn't be true, __tup6
is after A a moved-from object, and it should not be touched until it was brought back in a known state. This is what I teach all the time, and it is one of the toughest rules when it comes to move semantics. Finding an operation without a precondition to set a moved-from object back into a known state requires careful reading of the objects API. Seeing code like that above automatically turns on all my alarm bells.
Nonetheless, Dawid was absolutely right. __tup6
is casted to an rvalue reference at this point, or more precisely to an xvalue. I will not go into the details of the different categories here. If you like to know more about the value categories, I recommend reading Dawid's post Value categories – [l, gl, x, r, pr]values. Back to what the compiler does and where C++ Insights was wrong or was it?
The compiler does cast __tup6
to an xvalue in A and B above, and C++ Insights did show it if you did turn on the extra option "show all implicit casts". This option is off by default because, in my experience, it adds too much noise. The compiler does an incredible amount of casts for us to make even trivial code compile. However, even with all implicit casts on, the transformation C++ Insights showed was incorrect. The compiler knows that the implicit cast is a cast to an xvalue. Hence there is no need to add the &&
to the type. For us, without the &&
the cast is not an xvalue cast. I modified C++ Insights to add the required &&
to the type when the cast is an implicit cast. This corrects more code than just the structured bindings. The second this that C++ Insights does now is to show the implicit xvalue cast in case of structured bindings regardless of the "show all implicit casts" option. In the default mode, "show all implicit casts off", the transformation now produces the following result:
1 2 3 4 |
|
Now, we can see the xvalue cast in A and B. Perfect so far, and thank you for Dawid for spotting and reporting this issue.
But why should you care?
Because the above becomes important when you implement your own structured binding decomposition. Have a look at the following code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
|
In A, we create a struct S
with two public data members and apply in-class member initializers. The third one is private
and should not be decomposed. This is the reason why we have to write our own get
function, which we see in B, and provided the required tuple-API in C. This tells the compiler that S
has to data members with type int
and std::vector<int>
. All that looks good.
Then in main
, we create an S
object (D) and decompose it into two variables, a
and b
(E). With all that I told you above and looking at the provided code, what do you think about F? This assertion is satisfied, correct? Back at the top in A, we initialized b
with three elements. We are good, right? This is how the main
part looks in the fixed C++ Insights version:
1 2 3 4 |
|
Back to the "are we good" question. No, we are not good. The assert in F fires! It does so because of the static_cast
in G and H. This is the std::move
Dawid made me aware of. Have a look at B of the original version of the code. There, get
takes the parameter as an lvalue. But in G, the compiler applies a std::move
to __obj43
, which leads to a move-construction of S
. A std::vector
is a move-aware container, and it does its job. When the compiler passes __obj43
in G, the first time to get
a new object is created, and __obj43
is moved into it with the contents of b
! We now do have a moved-from object __obj43
. Hence in the second call to get
in H, __obj43
has an empty std::vector
.
There are two ways around this, either make get
take a const S&
or S&&
. In both cases, the std::move
-equivalent call from the compiler does not create a new object, so b
remains intact.
The lesson
The lesson from this never make get
take an lvalue, use T&&
as default, and const T&
as an alternative as long as you do not have a very good reason to fallback to the lvalue.
Support the project
You can support the project by becoming a GitHub Sponsor or, of course, with code contributions.
Andreas