Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

"Empty base optimization" for lambda captures - forbidden by the Standard? Why?

Tags:

c++

lambda

c++17

I recently came across a situation where I ended up with a large number of nested lambdas to build asynchronous computation chains.

template <typename F>
struct node : F
{
    node(F&& f) : F{std::move(f)}
    {
    }

    template <typename FThen>
    auto then(FThen&& f_then)
    {
        return ::node{[p = std::move(*this), t = std::move(f_then)]()
        {   
        }};
    }
};

int main()
{
    auto f = node{[]{ }}.then([]{ }).then([]{ });
    return sizeof(f);
}   

All the objects I capture in my the lambdas are empty, yet the size of the final object is greater than one: example on gcc.godbolt.org.

If I change the lambda inside node</* ... */>::then to a function object with explicit EBO, the size of the final object becomes one.

template <typename P, typename T>
struct node_lambda : P, T
{
    node_lambda(P&& p, T&& t) : P{std::move(p)}, T{std::move(t)}
    {
    }

    void operator()()
    {
    }
};

template <typename FThen>
auto node</* ... */>::then(FThen&& f_then)
{
    return ::node{node_lambda{std::move(*this), std::move(f_then)}};
}

Live example on gcc.godbolt.org


I find this really annoying because I'm forced to either:

  • Write a lot of boilerplate code that is roughly equivalent to the lambda.

  • Pay an additional memory cost due to the fact that something like EBO doesn't apply to lambda captures.

Is there anything in the Standard that explicitly forces empty lambda captures to take additional space? If so, why?

like image 403
Vittorio Romeo Avatar asked Jul 12 '17 11:07

Vittorio Romeo


2 Answers

From expr.prim.lambda.capture:

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

While the lambdas here have no capture:

auto f = node{[]{ }}.then([]{ }).then([]{ });

and hence have no unnamed non-static data members, and hence are empty, that's not what then() actually uses. It uses this:

return ::node{[p = std::move(*this), t = std::move(f_then)](){}};

that lambda captures t and p by copy, and hence has two unnamed non-static data members. Each .then() adds another member variable, even if each one is empty, hence the size of the node keeps going up.

Or in other words, the empty base optimization only applies to bases, and capture for lambdas doesn't create bases, it creates non-static data members.

like image 112
Barry Avatar answered Sep 17 '22 11:09

Barry


Given the as-if rule and [expr.prim.lambda.closure]/2:

An implementation may define the closure type differently from what is described below provided this does not alter the observable behavior of the program other than by changing:

  • the size and/or alignment of the closure type,
  • whether the closure type is trivially copyable (Clause [class]),
  • whether the closure type is a standard-layout class (Clause [class]), or
  • whether the closure type is a POD class (Clause [class]).

I don't see anything preventing an implementation from using some kind of magic to optimize away the storage for the captured empty variable.

That said, doing so would be an ABI break, so don't hold your breath.


Allowing - or requiring - an implementation to make the type of a captured empty variable a base of the closure type, on the other hand, would be a horrendously bad idea. Consider:

struct X { };
struct Y { };
void meow(X x);                     // #1
void meow(Y y);                     // #2
void meow(std::function<void()> f); // #3

template<class T, class U>
void purr(T t, U u) {
    meow([t = std::move(t), u = std::move(u)] { /* ... */ });
}

It would be insane for purr to do anything other than call #3, yet if captures can become bases then it can call #1, or #2, or be ambiguous.

like image 43
T.C. Avatar answered Sep 16 '22 11:09

T.C.