armhub.dev
Programming · 55 pages
Programmingtutorial

C++ Lesson 10 — Lambdas

Lambda syntax, capture by value vs reference, mutable and generic lambdas, init-capture, the anonymous class lambdas desugar to, and the function-pointer decay rule.

5 min read · 1,101 wordsupdated · May 13, 2026
Lesson 10 — at a glance
What you'll learn
Lambda syntax and captures, mutable lambdas, generic lambdas, init-capture, lambda as function object, function-pointer decay for non-capturing lambdas
Code provided
About 130 lines — comparator, event handler, and the equivalent hand-written class
Time
45-60 minutes
Pre-reqs
Lessons 1-9 (templates, STL, smart pointers, move semantics)

Lambdas are the syntax that finally made C++ pleasant to use with STL algorithms. Before C++11, sorting a std::vector of structs by a member meant writing a named comparison functor — twenty lines of class boilerplate to wrap a one-line comparison. With lambdas you write the comparison inline at the call site and the compiler builds the functor for you. The mechanism is straightforward once you see what the compiler is actually generating: a lambda is an anonymous class with an operator(). That's it.

This lesson covers the syntax in full and shows the desugared equivalent so the magic stops being magic.

Syntax

The full form:

[capture] (parameters) -> return-type { body }

Most of those parts are optional. Minimal lambda:

auto greet = [] { std::println("hello"); };
greet();   // hello

Lambda with parameters and an inferred return type:

auto add = [](int a, int b) { return a + b; };
int s = add(2, 3);   // 5

Lambda with an explicit return type — needed only when the compiler cannot deduce or the deduction differs from what you want:

auto half = [](int x) -> double { return x / 2.0; };

The type of a lambda is a unique, unnamed class type generated by the compiler. You typically store it in auto or pass it to a function template that deduces the type. decltype(half) is some compiler-internal name like main()::{lambda(int)#1}. You cannot spell it directly.

Captures: by value, by reference, by default

The capture clause [...] lists what enclosing variables the lambda can see and how. The four common forms:

int x = 10, y = 20;
 
auto by_value     = [x, y]   { return x + y; };  // x and y copied at lambda creation
auto by_reference = [&x, &y] { return x + y; };  // x and y referred to live
auto value_all    = [=]      { return x + y; };  // every used variable captured by value
auto ref_all      = [&]      { return x + y; };  // every used variable captured by reference
 
x = 999;
std::println("{} {} {} {}", by_value(), by_reference(), value_all(), ref_all());
// 30 1019 30 1019

[=] and [&] capture only the variables actually used in the body. They do not, despite the syntax, "capture everything" — unused outer variables are not included.

You can mix: [=, &counter] captures everything by value except counter, which is captured by reference. [&, total] is the inverse.

Capture of this

Member-function lambdas often need to access member variables. The captures [this] and [*this] cover this:

class Widget {
public:
    void start() {
        on_event([this] { ++count_; });            // captures this pointer
        on_event([*this] mutable { ++count_; });   // captures a copy of *this
    }
private:
    int count_ = 0;
};

[this] captures the pointer — the lambda then has the same lifetime concerns as the object. [*this] (C++17) copies the whole object into the lambda, so the lambda can outlive the original — at the cost of the copy.

Mutable lambdas

By default, a lambda's operator() is const. Captured-by-value members are also const inside the body, so you cannot mutate them:

auto bad = [n = 0] { ++n; };   // ERROR: n is const inside operator()

mutable removes the const:

auto counter = [n = 0]() mutable { return ++n; };
counter();   // 1
counter();   // 2
counter();   // 3

This is a stateful lambda — each call advances n. Useful for closures over generators, IDs, sequence numbers.

Init-capture (generalised capture)

C++14 introduced init-capture, also known as generalised capture. Instead of just capturing an existing variable, you can introduce a new lambda-local variable with an initialiser:

auto ptr = std::make_unique<Widget>();
auto consume = [w = std::move(ptr)] { w->run(); };
// ptr is now nullptr; the lambda owns the Widget.

This is the only way to move a move-only type (std::unique_ptr, std::thread) into a lambda. Capture-by-value would try to copy and fail to compile.

Init-capture also lets you precompute something at lambda-creation time:

auto check = [denom = compute_denominator()](int x) { return x / denom; };

The expression compute_denominator() runs once when the lambda is constructed, not on every call.

Generic lambdas

C++14 made lambdas templates. Use auto (or, since C++20, a template parameter list []<typename T>(T x)) for parameters:

auto add = [](auto a, auto b) { return a + b; };
add(1, 2);            // int + int
add(1.5, 2.5);        // double + double
add(std::string("a"), std::string("b"));   // string concat

The compiler generates a templated operator() — one instantiation per type combination, just like a function template. The C++20 syntax lets you spell the template parameter for type-relating constraints:

auto same_type = []<typename T>(T a, T b) { return a == b; };
same_type(1, 2);      // OK
same_type(1, 2.0);    // ERROR: deduction conflict — T cannot be both int and double

Lambdas in STL algorithms — the standard use case

This is what lambdas were designed for:

#include <algorithm>
#include <vector>
#include <print>
 
struct Person { std::string name; int age; };
 
int main() {
    std::vector<Person> people = {
        {"Alice", 30}, {"Bob", 25}, {"Cara", 40}, {"Dan", 28},
    };
 
    // Sort by age, ascending.
    std::ranges::sort(people, [](const Person &a, const Person &b) {
        return a.age < b.age;
    });
 
    // Find first person over 30.
    auto it = std::ranges::find_if(people, [](const Person &p) {
        return p.age > 30;
    });
    if (it != people.end()) std::println("first over-30: {}", it->name);
 
    // Sum of ages.
    int total = 0;
    std::ranges::for_each(people, [&total](const Person &p) { total += p.age; });
    std::println("total: {}", total);
}

Each call site reads as one expression. The comparator is right next to the sort. The accumulator captures total by reference. No named functors, no global functions, no header file growth.

What a lambda actually is

The trick is that there is no magic. A lambda is sugar for an anonymous class with an operator(). The compiler rewrites:

int threshold = 10;
auto over_threshold = [threshold](int x) { return x > threshold; };
bool ok = over_threshold(15);

into something like:

class __lambda_xyz {
public:
    explicit __lambda_xyz(int t) : threshold_(t) {}
    bool operator()(int x) const { return x > threshold_; }
private:
    int threshold_;
};
 
int threshold = 10;
__lambda_xyz over_threshold(threshold);
bool ok = over_threshold(15);

Captures become member variables. The body becomes operator(). By-value captures are members of the type captured; by-reference captures are reference members. mutable removes the const from operator(). Init-capture ([v = std::move(x)]) becomes a member initialised from the expression. Generic lambdas ([](auto x){...}) become a templated operator().

Once you internalise this, the rest of the language follows: a lambda is "just" a class instance, so it has a size (sum of captures, often zero), a type, copy/move semantics inherited from the captures, and a deterministic constructor. Empty-capture lambdas have size 1 (no addressable state, but distinct objects still need distinct addresses).

Lambdas as std::function vs template parameter

Two ways to take a lambda as a function argument:

#include <functional>
 
// Type-erased: any callable matching the signature.
void on_event_fn(std::function<void(int)> handler) {
    handler(42);
}
 
// Templated: the lambda's exact type is deduced.
template <typename F>
void on_event_tmpl(F handler) {
    handler(42);
}

The std::function version is simpler at the call site and lets you store handlers in containers (std::vector<std::function<void(int)>>), but it has overhead — a small-buffer optimisation in most implementations, falling back to a heap allocation for large captures, plus an indirect call. The templated version inlines the lambda body into the caller, has zero runtime overhead, but generates a new instantiation per lambda type and cannot be stored in a homogeneous container.

Rule of thumb: for hot-path code, use the templated form. For heterogeneous handler tables, signal/slot systems, or stored callbacks that must outlive the calling scope, use std::function. On embedded targets std::function may heap-allocate — see the C++ on Cortex-M reference for fixed-size alternatives.

A complete worked example

Sort and event handler — the two canonical uses — in one file. Build with g++ -std=c++23 -O2 -Wall lambdas.cpp:

#include <algorithm>
#include <functional>
#include <print>
#include <string>
#include <vector>
 
struct Event { std::string topic; int value; };
 
class EventBus {
public:
    using Handler = std::function<void(const Event &)>;
 
    void subscribe(Handler h) { handlers_.push_back(std::move(h)); }
 
    void publish(const Event &e) const {
        for (const auto &h : handlers_) h(e);
    }
 
private:
    std::vector<Handler> handlers_;
};
 
int main() {
    // --- 1. Lambda as std::sort comparator.
    std::vector<int> nums = {5, 2, 9, 1, 7, 3};
    std::ranges::sort(nums, [](int a, int b) { return a > b; });    // descending
    for (int n : nums) std::print("{} ", n);
    std::println("");
 
    // --- 2. Lambda as event handler callback.
    EventBus bus;
 
    int hit_count = 0;
    bus.subscribe([&hit_count](const Event &e) {
        if (e.topic == "alarm") ++hit_count;
    });
 
    bus.subscribe([prefix = std::string("LOG: ")](const Event &e) {
        std::println("{}{}={}", prefix, e.topic, e.value);
    });
 
    bus.publish({"alarm", 1});
    bus.publish({"info", 42});
    bus.publish({"alarm", 5});
 
    std::println("alarms: {}", hit_count);
 
    // --- 3. Mutable lambda as a stateful generator.
    auto next_id = [n = 0]() mutable { return ++n; };
    std::println("ids: {} {} {}", next_id(), next_id(), next_id());
 
    // --- 4. Non-capturing lambda decays to function pointer.
    void (*cb)(int) = [](int x) { std::println("cb called with {}", x); };
    cb(99);
}

Notice the four idioms: comparator, captured-state handler, init-capture handler with a prefix string, mutable stateful generator, and function-pointer decay. Every line is one of those four patterns.

What you learned

  • Lambda syntax is [capture](params) -> ret { body }; most parts are optional.
  • Captures: [x] by value, [&x] by reference, [=] / [&] defaults; [this] captures the object pointer.
  • mutable makes captured-by-value members modifiable inside the body.
  • Init-capture ([v = std::move(x)]) is the only way to move move-only types in.
  • Generic lambdas ([](auto x){...}) generate a templated operator().
  • A lambda is just an anonymous class with an operator(). Captures become members.
  • Non-capturing lambdas decay to plain function pointers; capturing ones do not.

Exercises

Exercise 10.1. Write a lambda that takes a std::vector<int> by reference and returns the count of even numbers using std::count_if.

Exercise 10.2. Why does this fail to compile, and what's the fix?

auto make_logger() {
    std::string prefix = "[INFO] ";
    return [&prefix](const std::string &msg) {
        std::println("{}{}", prefix, msg);
    };
}

Exercise 10.3. Convert this hand-written functor to an equivalent lambda:

struct GreaterThan {
    int threshold;
    explicit GreaterThan(int t) : threshold(t) {}
    bool operator()(int x) const { return x > threshold; }
};

Next

PreviousNext
C++ Lesson 9 — Move semanticsC++ Lesson 11 — Error handling

See also: C++ on Cortex-M, C++ tutorial index, Bare-metal C tutorial.