Skip to content
jieqi's archive
Go back

C++: Lambda Basics

Contents
[x, &y] (int a) mutable noexcept -> long { return a; }
^^^^^^^ ^^^^^^^ ^^^^^^^ ^^^^^^^^ ^^^^^^^ ^^^^^^^^^^^^^
capture params  mutable noexcept trailing  body
                                 return

Motivation

Passing behaviour to algorithms. The original motivation:

std::sort(v.begin(), v.end(), [](const Order& a, const Order& b) {
    return a.price > b.price;
});

Before lambdas, that comparator had to be a named type, written out somewhere else in the file:

struct ByPriceDesc {
    bool operator()(const Order& a, const Order& b) const { return a.price > b.price; }
};

std::sort(v.begin(), v.end(), ByPriceDesc{});

Both still compile, and both do the same thing. The lambda just puts the comparator where it is used.

A Lambda Is a Function Object

A lambda is a function object: a class with an operator(). The compiler writes that class for you, and the captures become its data members.

int x = 10;
auto f = [x](int y) { return x + y; };

desugars to roughly:

struct __lambda1 {
    int x;                                        // the capture, a member
    int operator()(int y) const { return x + y; } // note: const
};

__lambda1 f{10};

That’s it. Once you see it as a struct, the rest stops being a list of separate facts:

The layout claim is literal — sizeof a lambda is sizeof the equivalent struct, padding and all:

[]        ->  1    (empty struct minimum)
[x]       ->  4    == sizeof(int)
[x, d]    -> 16    == sizeof(struct { int; double; })

And “unique type” means unique per expression, not per signature. Two lambdas with byte-identical source are still different types:

auto a = [](int y){ return y; };
auto b = [](int y){ return y; };
static_assert(!std::is_same_v<decltype(a), decltype(b)>);   // passes

By-Value vs By-Reference Capture

int x = 10;

auto byval = [x] { return x; };      struct { int  x; };
auto byref = [&x] { return x; };     struct { int& x; };

Everything else follows from that.

Timing

By-value copies at the point the lambda is created, not when it’s called.

int x = 10;
auto f = [x] { return x; };
x = 99;
f();              // 10, the copy was taken already

auto g = [&x] { return x; };
x = 50;
g();              // 50, reads through the reference now

mutable

By-value needs it to modify, because operator() is const and the member is int. By-reference doesn’t, because a const member function can still write through an int& — const applies to the reference itself, which is meaningless since references can’t rebind.

auto a = [x]  () mutable { x++; };   // needs mutable, modifies its own copy
auto b = [&x] ()         { x++; };   // no mutable, modifies the real x

Lifetime, the one that actually hurts

auto make_bad() {
    int x = 10;
    return [&x] { return x; };    // x dies here. Dangling reference.
}

auto make_ok() {
    int x = 10;
    return [x] { return x; };     // copy lives in the closure. Fine.
}

Rule of thumb: [&] is safe when the lambda is consumed before the enclosing scope ends. That covers std::sort, for_each, count_if, anything synchronous. It is dangerous the moment the lambda outlives the scope, which means anything stored, queued, returned, or handed to another thread.

Written exactly as above, clang does catch it — warning: address of stack memory associated with local variable 'x' returned. Wrap the same closure in a std::function and the warning disappears, because type erasure hides the closure type from that analysis. Which is why this survives in real code: the shape that gets diagnosed is the shape nobody ships.

The [=] and this trap

struct Engine {
    int limit;
    auto get() {
        return [=] { return limit; };   // looks like a copy of limit
    }                                   // it is NOT. It captured `this`.
};

[=] captures this by pointer, then limit means this->limit. If the Engine dies, the lambda dangles even though you wrote [=].

Two things give it away. Mutate the object after building the closure and the closure sees the change; and the closure is pointer-sized, not int-sized:

e.limit = 99;  ->  closure returns 99      (a copy would still say 10)
sizeof(closure)    8, the size of Engine*  (an int copy would be 4)

C++17 added [*this] to copy the object — that closure returns 10 and has sizeof 4. C++20 deprecated the implicit capture, so the original now warns:

warning: implicit capture of 'this' with a capture default of '=' is deprecated

This is the single most common lambda bug in production code, and a reliable interview question.

Check

Given the desugaring above, why does this fail to compile?

int x = 10;
auto f = [x]() { x++; };
Answer

operator() is const by default, so x++ is trying to modify a member of a const object:

error: cannot assign to a variable captured by copy in a non-mutable lambda

mutable drops the const and it compiles:

auto f = [x]() mutable { x++; };

Note what that does not do. x is still a copy living inside the closure — mutable only lets you modify the member. The outer x is untouched, and the modified value persists across calls, because it’s the same object each time. To affect the original you need [&x], which stores a reference instead.

Previous Post
C++: Atomics and Memory Ordering
Next Post
C++: Class Templates - Full and Partial Specialisation