#074 – The mechanics of lambda-to-class conversion by compilers

A C++ lambda looks like a small function literal — write some captures in brackets, parameters in parens, and a body in braces, and you have a callable. What the compiler actually produces is something heavier and more interesting: a brand-new unnamed class, synthesised on the spot, with the captures as data members and the body as the class’s operator().

Every useful property of lambdas — why each one has a unique type, why captures by value are const by default, why captureless lambdas can decay to function pointers but capturing ones cannot — falls out of that single transformation. Knowing the shape of the generated class is the difference between using lambdas and understanding them.

Three things to take away:

  • A lambda expression generates an unnamed closure type — a class with one data member per by-copy capture, an operator() that is the lambda body, and standard-library- shaped copy/move semantics.
  • The synthesised operator() is const by default; mutable is what removes the const and lets the body modify by-copy captures.
  • A captureless lambda has an extra implicit conversion to a matching function pointer; a capturing lambda does not, which is why one can be passed to C-style callbacks and the other cannot.

The basic transformation

Take a lambda with one capture:

int multiplier{ 3 };
auto times = [multiplier](int n) { return n * multiplier; };

Conceptually, the compiler dissolves the lambda expression into something like this:

class __Lambda_42 {
public:
    __Lambda_42(int multiplier) : m_multiplier{ multiplier } {}
    int operator()(int n) const{ return n * m_multiplier; }
private:
    int m_multiplier;
};

__Lambda_42 times{ multiplier };   // construct from the captured value

Three pieces of the lambda map to three pieces of the class:

  • The capture list [multiplier] becomes a data member of the same type, initialised from the variable in the enclosing scope.
  • The parameter list (int n) becomes the parameter list of operator().
  • The body { return n * multiplier; } becomes the body of operator().

The class is unnamed and has a unique type for every lambda expression in the program. Even two lambdas with identical bodies and signatures get different synthesised types.

Captures become members

The capture mode determines what kind of member the closure type holds:

int  x{ 10 };
int& y = x;
const std::string s{"hello"};

auto byCopy      = [x] { return x; };        // member: int
auto byReference = [&x] { return x; };       // member: int& (typically)
auto initCapture = [v = x * 2] { return v; }; // member: int, init from x*2

By-copy captures ([x]) hold a copy. The closure type owns its own int, independent of the original variable’s lifetime. By-reference captures ([&x]) hold a reference. Modifying through the lambda modifies the original; the original out-living the lambda is the programmer’s responsibility, since references to a destroyed object dangle.

Init-capture ([v = x * 2], C++14) lets you compute the captured value or rename it in the closure. The right-hand side is evaluated in the enclosing scope; the result becomes a member of deduced type.

The order in which the data members appear in the closure type is unspecified by the standard. In practice you should not rely on it — the abstraction holds only at the lambda level.

const by default, mutable to opt out

The synthesised operator() is const. This is the cause of the beginner-trap “I captured count by value, but I can’t increment it”:

int count{ 0 };
auto bump = [count] { ++count; };   // ERROR: cannot modify const member

Inside the closure’s operator() constcount is a non-mutable int member of a const object — the compiler refuses the assignment. The mutable keyword removes the const:

auto bump = [count]() mutable { ++count; };   // OK

The closure type’s operator() is now non-const, and the capture (still a copy of the original) can be modified. Note that this modifies the closure’s copy, not the original variable in the enclosing scope. If you wanted to modify the original, you should have captured by reference — at which point mutable is unnecessary, because modifying through a reference doesn’t require the reference itself to be non-const.

Captureless lambdas and the function-pointer trick

A lambda with no captures has an additional member the capturing version doesn’t: a conversion to a plain function pointer with the matching signature.

auto noCapture   = [](int n) { return n + 1; };
int (*fp)(int)   = noCapture;          // OK — implicit conversion

int factor{ 2 };
auto withCapture = [factor](int n) { return n * factor; };
int (*fp2)(int)  = withCapture;        // ERROR

The reason follows directly from the de-sugaring. A captureless closure has no data members — calling operator() doesn’t depend on instance state, so the compiler can synthesise a free function with the same body and hand back its address. A capturing closure carries instance data; there is no free-function form because the captured values would have nowhere to live.

This is the mechanism that lets captureless lambdas be passed to C-style callbacks like std::signalqsort, or any third- party C API expecting a void (*)(int). Add a single capture and the conversion goes away — at which point the canonical workaround is to declare the function extern "C" (Nibble #067) and write it as a free function, or to type-erase the lambda through std::function.

Generic lambdas: a templated call operator

C++14 added auto in the parameter list. The de-sugaring produces a closure type with a templated operator():

auto identity = [](auto x) { return x; };

// equivalent to:
class __Lambda_99 {
public:
    template <typename T>
    T operator()(T x) const{ return x; }
};

A generic lambda is therefore a class with a template member function — the closure type itself is not a template. Each call with a different argument type instantiates a different specialisation of operator(), but they all live on the same closure type. C++20 extended this with explicit template parameters ([]<typename T>(T x) { ... }), giving full template syntax inside the lambda introducer.

Special-member behaviour

The synthesised closure type has a particular set of defaults worth knowing:

  • No default constructor when the lambda has captures (the members would have nothing to initialise from). Captureless lambdas became default-constructible in C++20.
  • Defaulted copy and move constructors. The closure can be copied or moved as a value, with member-wise behaviour.
  • Deleted copy assignment when captures are present — re-assigning a closure would mean overwriting captured references and copies in ways the language declines to define.
  • Implicit destructor. Captures are destroyed in the usual member-wise manner.

The practical consequence: you can store a lambda in auto, copy it, move it, return it from a function, and put it in a container — but you can’t reassign one to another after construction. If you need assignability, type-erase through std::function (which has its own copy semantics) or hold the closure inside an std::optional.

Inspecting the generated code

The mental model above is conceptual — actual compiler output uses unique mangled names and may add padding and alignment detail. To see the dissolved form for a specific lambda, C++ Insights is the best tool: paste in the lambda, get back the generated class. Compiler Explorer also shows the assembly, which is informative for understanding how captureless-lambda function-pointer conversions resolve to the same code an ordinary free function would produce.

Takeaway

Every lambda expression is a syntactic shorthand for a class the compiler writes for you. Captures become data members, parameters become operator()‘s parameters, the body becomes its body. const by default, mutable to opt out; captureless lambdas decay to function pointers, capturing ones do not; each lambda has a unique unnamed type, which is why auto is the only practical way to name one.

Once the de-sugaring is in your head, every other lambda fact follows from it. The rule is simple: a lambda is not a function — it is a class with one member, written for you on the spot.