Logo

Blog


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
#include <tuple>

int main()
{
    std::tuple<int, int> tup{2,5};
    auto [a, b] = tup;
}

At the time, C++ Insights showed the following transformation:

1
2
3
4
5
6
7
8
9
#include <tuple>

int main()
{
  std::tuple<int, int> tup = std::tuple<int, int>{2, 5};
  std::tuple<int, int> __tup6 = std::tuple<int, int>(tup);
  std::tuple_element<0, std::tuple<int, int> >::type & a = std::get<0UL>(__tup6); A 
  std::tuple_element<0, std::tuple<int> >::type & b = std::get<1UL>(__tup6); B 
}

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
std::tuple<int, int> tup = std::tuple<int, int>{2, 5};
std::tuple<int, int> __tup6 = std::tuple<int, int>(tup);
std::tuple_element<0, std::tuple<int, int> >::type & a = std::get<0UL>(std::move(__tup6)); A 
std::tuple_element<0, std::tuple<int> >::type & b = std::get<1UL>(std::move(__tup6)); B 

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
std::tuple<int, int> tup = std::tuple<int, int>{2, 5};
std::tuple<int, int> __tup6 = std::tuple<int, int>(tup);
int a = std::get<0UL>(static_cast<std::tuple<int, int> &&>(__tup6)); A 
int b = std::get<1UL>(static_cast<std::tuple<int, int> &&>(__tup6)); B 

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
A Innocent struct with two data members
struct S {
    int              a{2};
    std::vector<int> b{3, 4, 5};


private:
    int id{42};
};

B Implementation of get
template<std::size_t I>
auto get(S value)
{
    if constexpr(0 == I) {
        return value.a;
    } else {
        return value.b;
    }
}

C Satisfy the structured bindings API
namespace std {
    template<>
    struct tuple_size<S> {
        static constexpr std::size_t value = 2;
    };

    template<>
    struct tuple_element<0, S> {
        using type = int;
    };

    template<>
    struct tuple_element<1, S> {
        using type = std::vector<int>;
    };
}  // namespace std

int main()
{
    S obj{}; D Create a S object
    auto [a, b] = obj; E And let it decompose

    assert(3 == b.size()); F Are there 3 elements in b?
}

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
S obj = S{};
S __obj43 = S(obj);
int a = get<0UL>(S(static_cast<S &&>(__obj43))); G 
std::vector<int> b = get<1UL>(S(static_cast<S &&>(__obj43))); H 

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