C++ Insights new lambda transformation

Last week I pushed an updated lambda transformation for C++ Insights. Here is a little background on the change.

Since I first published C++ Insights when you transformed a lambda like the following:

1
2
3
4
5
6
int main()
{
   int val{4};

   auto lamb = [&] { return val; };
}

The result looked like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
int main()
{
  int val = {4};

  class __lambda_5_16 {
  public:
    inline /*constexpr */ int operator()() const { return val; }

    A 
    __lambda_5_16(int& _val)
    : val{_val}
    {}

  private:
    int& val;
  };

  __lambda_5_16 lamb = __lambda_5_16{val};
  return 0;
}

Now, what's wrong with this transformation? Well, there is no constructor in a lambda A. Never. Except for a default constructor in case of a captureless lambda.

But, I needed the constructor to initialize the data member val because the standard also requires that all captures become private data members. More importantly, the standard also requires that all copy-captured variables are direct-initialized: [expr.prim.lambda.capture] p15

When the lambda-expression is evaluated, the entities that are captured by copy are used to direct-initialize each corresponding non-static data member

Now, this direct-initialization was something that the earlier transformation didn't show. Instead, you saw the constructor, which aimed to initialize things as good as possible. I could argue that for many cases there was no difference.

However, there was a difference. Consider this example, which is derived from Issue2.cpp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
struct Movable {
  Movable() {}
  Movable(Movable&& other) {}
  Movable& operator=(Movable&& other) { return *this; }

  Movable(const Movable&)            = delete;
  Movable& operator=(const Movable&) = delete;
};

int main()
{
  Movable m{};
  auto    fun = [x = std::move(m)] {};
}

We have a move-only object that is copy-captured into a lambda. The old transformation produced 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
#include <utility>

struct Movable {
  inline Movable() {}
  inline Movable(Movable&& other) {}
  inline Movable& operator=(Movable&& other) { return *this; }

  // inline Movable(const Movable &) = delete;
  // inline Movable & operator=(const Movable &) = delete;
};

int main()
{
  Movable m = Movable();

  class __lambda_20_16 {
  public:
    inline /*constexpr */ void operator()() const {}

  private:
    Movable x;

  public:
    // inline __lambda_20_16(const __lambda_20_16 &) /* noexcept */ =
    // delete; inline __lambda_20_16 & operator=(const __lambda_20_16 &) /*
    // noexcept */ = delete;
    __lambda_20_16(Movable&& _x)
    A 
    : x{std::move(_x)}
    {}
  };

  B 
  __lambda_20_16 fun = __lambda_20_16{Movable(std::move(m))};
}

I had to move the object during the constructor call B and a second time in the constructor initializer list A. This isn't as good as the code your compiler generates for you! This is when direct initialization makes a difference.

Imagine the object Movable would contain a data member like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
struct Movable {
  A 
  std::string text{/* longish text */};

  Movable() {}
  Movable(Movable&& other) {}
  Movable& operator=(Movable&& other) { return *this; }

  Movable(const Movable&)            = delete;
  Movable& operator=(const Movable&) = delete;
};

The move-constructor of Moveable would of course move the std::string data member text A. But it would be moved twice as well. Now in your std::string object there is more data than just the pointer to the string data. A typical std::string store the currently allocated capacity and the length of the string. Let's say to size_t. They can't be moved. They are always copies. This is where the former transformation presented you with bad code.

Aside from that, I had to insert the header utility for std::move.

What do I change? Well, for a long time I was checking how compilers solve this. One way, of course, would be to rename the data-members inside the lambda. This would be fine under the umbrella of the standard. It specifically calls this out in [expr.prim.lambda.capture] p10

For each entity captured by copy, an unnamed non-static data member is declared in the closure type.

The drawback with this approach is that I have to track all the captures and rename all their uses. Which would obfuscate the name as well.

The trick that at least GCC seems to do is to start a lambda as a struct with the captures as public data members. Then it uses aggregate initialization to directly initialize all the captures. After this step, the compiler internally flips the struct into a class. But that it fulfills the requirements of the standard but can also use standard code generation for the direct-init case.

Now, I can't do the flip. So I decided to violate the standard in a way that I make the data members public. I only leave the public as a comment. While still a violation, it seems the better one than before.

With the new transformation, the generated code now looks like this:

 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
struct Movable {
  inline Movable() {}

  inline Movable(Movable&& other) {}

  inline Movable& operator=(Movable&& other) { return *this; }

  // inline Movable(const Movable &) = delete;
  // inline Movable & operator=(const Movable &) = delete;
};

int main()
{
  Movable m = Movable{};

  class __lambda_15_17 {
  public:
    inline /*constexpr */ void operator()() const {}

    A 
    // private:
    Movable x;

  public:
    // inline __lambda_15_17(const __lambda_15_17 &) /* noexcept */ =
    // delete; inline __lambda_15_17 & operator=(const __lambda_15_17 &) /*
    // noexcept */ = delete;
  };

  __lambda_15_17 fun = __lambda_15_17{std::move(m)};
  return 0;
}

You can see the commented private in A with the constructor gone. Only the std::move we as users must write is there.

The motivation for this change came after another chat with Jason Turner at CppCon 2024. We had that discussion a couple of times. That day I sat down later that night and implemented the change. After something that nearly tuned into an all-nighter, I was compelled to think that this was the better way to go.

The change dropped over 500 lines of code and reduced. I also could remove a couple of special treatments, and the test output became shorter as well.

Overall, I think this change is a great improvement to C++ Insights. I hope this is an improvement for you to.

Please don't hesitate to reach out and let me know your thoughts!

Support the project

You can support the project by becoming a GitHub Sponsor, or Patreon or, of course, with code contributions.

As a company you can sponsor C++ Insights and get your company logo shown in the top banner. Please reach out, if you want to discuss this option.

Andreas

Recent posts