CodeRigor

FREE LESSON

Lambdas and callables

Write lambdas with deliberate captures, and pick the right callable type for an API: a template, std::function_ref, std::function or std::move_only_function.

A lambda is shorthand for a small class with an operator(). Once you see the class the compiler writes for you, captures stop being magic, and the two bugs that matter — a closure outliving what it refers to, and paying for type erasure you didn't need — become easy to spot.

1. What a lambda really is

C++
#include <print>

int main() {
    int threshold = 10;
    auto above = [threshold](int x) { return x > threshold; };
    std::println("{} {}", above(5), above(12));

    // Roughly what the compiler generates:
    struct Above {
        int threshold;                                   // one member per capture
        bool operator()(int x) const { return x > threshold; }
    };
    Above same{threshold};
    std::println("{} {}", same(5), same(12));
}
A closure is a struct with one member per capture
Sizes from GCC on x86-64. A by-reference capture is stored as a pointer (8 bytes); the closure type's member order is unspecified, but padding rules still apply.
A closure is a struct with one member per capture
Sizes from GCC on x86-64. A by-reference capture is stored as a pointer (8 bytes); the closure type's member order is unspecified, but padding rules still apply.

Each lambda expression has its own unique, unnamed closure type. That's why you store lambdas with auto (or pass them to templates): two lambdas with identical text still have different types.

Notice the const on operator(): by default a lambda can't modify its by-value captures. mutable removes that const.

2. Captures

Capture Member stored in the closure Lifetime risk
[x] a copy of x, made when the lambda is created none
[&x] a reference to x dangles if the closure outlives x
[y = expr] init-capture: a new member initialised from expr (can move: [p = std::move(ptr)]) none for by-value
[this] the this pointer dangles if the closure outlives the object
[*this] a copy of the whole object none
[=] / [&] everything used, by value / by reference as above, per variable

Prefer naming what you capture. [=] in a member function captures the this pointer, not a copy of the object (C++20 deprecates that implicit capture), which surprises people.

C++
#include <memory>
#include <print>

int main() {
    int x = 1;
    auto by_value = [x] { return x; };
    auto by_ref = [&x] { return x; };
    x = 2;
    std::println("by value: {}, by reference: {}", by_value(), by_ref());

    auto counter = [n = 0]() mutable { return ++n; };   // init-capture + mutable
    counter();
    std::println("counter: {}", counter());

    auto p = std::make_unique<int>(42);
    auto owner = [p = std::move(p)] { return *p; };      // the closure now owns the int
    std::println("owner: {}, p is {}", owner(), p ? "set" : "empty");
}
By value copies at creation; by reference reads the variable later
After x = 2: by_value still holds its own 1, while by_ref follows the reference to the variable and sees 2.
By value copies at creation; by reference reads the variable later
After x = 2: by_value still holds its own 1, while by_ref follows the reference to the variable and sees 2.

The dangling closure

A closure that captures by reference is only valid while the referenced objects live. Returning such a closure from a function is the classic bug:

C++
auto make_counter(int start) {
    int next = start;
    return [&next] { return next++; };   // next dies when make_counter returns
}
The closure returned by make_counter points into a dead frame
make_counter's frame is popped when it returns, but the closure kept a reference to next inside it. The next call to the closure reads and writes memory that may already belong to another function.
The closure returned by make_counter points into a dead frame
make_counter's frame is popped when it returns, but the closure kept a reference to next inside it. The next call to the closure reads and writes memory that may already belong to another function.

AddressSanitizer reports it as a stack-use-after-scope/return. The fix is to capture by value and make the lambda mutable so it can update its own copy: [next = start]() mutable { return next++; }.

Rule of thumb: by-reference captures are fine for lambdas that are used immediately (passed to an algorithm, called right away). Anything stored, returned, or handed to another thread should capture by value.

3. Copying a closure copies its state

A closure is an object; copying it copies its members. Two copies of a mutable counter count independently:

C++
#include <print>

int main() {
    auto c = [n = 0]() mutable { return ++n; };
    c();
    auto d = c;          // d starts with n == 1, like c
    std::println("{} {}", c(), d());   // 2 2
}

If copies should share state, share it explicitly — e.g. capture a std::shared_ptr to the state.

4. Generic lambdas, template lambdas, and recursion

C++
#include <print>
#include <string>
#include <vector>

int main() {
    auto twice = [](auto x) { return x + x; };                 // generic: a template operator()
    std::println("{} {}", twice(21), twice(std::string("ab")));

    auto same_type_sum = []<class T>(T a, T b) { return a + b; };   // template lambda: names T
    std::println("{}", same_type_sum(1.5, 2.25));

    auto first_or = []<class T>(const std::vector<T>& v, T fallback) {
        return v.empty() ? fallback : v.front();
    };
    std::println("{}", first_or(std::vector<int>{}, -1));

    // C++23 deducing this: the lambda receives itself as a parameter, so it can recurse.
    auto fib = [](this auto self, int n) -> long long {
        return n < 2 ? n : self(n - 1) + self(n - 2);
    };
    std::println("fib(30) = {}", fib(30));
}

Before deducing this, recursive lambdas needed std::function (slow, allocates) or a "pass yourself" parameter. A lambda can't capture itself by name in its own initialiser.

Immediately invoked lambdas

A lambda called on the spot is the neat way to initialise a const variable with logic:

C++
#include <print>
#include <string_view>

int main() {
    std::string_view mode = "fast";
    const int iterations = [&] {
        if (mode == "fast") return 10;
        if (mode == "thorough") return 1000;
        return 100;
    }();
    std::println("{}", iterations);
}

5. Storing and passing callables

There are four ways to accept "something callable" in an API. They trade flexibility against cost:

Parameter type Owns the callable? Allocates? Copyable? Use when
template <class F> void f(F fn) / std::invocable auto fn yes (a copy) no as F header code, hot paths; every callable type gets its own instantiation
std::function_ref<R(Args...)> (C++26) no, just refers never yes (cheap) the callee calls it during the call and doesn't keep it
std::function<R(Args...)> yes may (large captures) requires a copyable callable stored callbacks that must be copyable
std::move_only_function<R(Args...)> (C++23) yes may no (move-only) stored callbacks; accepts move-only lambdas (e.g. capturing unique_ptr)

std::function and std::move_only_function use type erasure: they store any callable behind one type, allocating on the heap when the callable is too big for their small internal buffer. std::function_ref stores just a pointer to the callable plus a pointer to a call thunk — it never allocates, but it must not outlive the callable it refers to:

C++
#include <functional>
#include <memory>
#include <print>

// The callee only calls `attempt` while running: function_ref is the right type.
bool retry(std::function_ref<bool()> attempt, int times) {
    for (int i = 0; i < times; ++i)
        if (attempt()) return true;
    return false;
}

int main() {
    int tries = 0;
    bool ok = retry([&] { return ++tries == 3; }, 5);   // fine: the lambda lives until retry returns
    std::println("ok={} after {} tries", ok, tries);

    std::move_only_function<int()> stored = [p = std::make_unique<int>(7)] { return *p; };
    std::println("stored() = {}", stored());
    // std::function<int()> copyable = [p = std::make_unique<int>(7)] { return *p; };  // error: not copyable
}
Referring vs owning a callable
function_ref is two pointers into the caller's world (16 bytes, never allocates). std::function (32 bytes in libstdc++) and move_only_function (40) own a copy of the callable — inline if it fits their small buffer, otherwise on the heap.
Referring vs owning a callable
function_ref is two pointers into the caller's world (16 bytes, never allocates). std::function (32 bytes in libstdc++) and move_only_function (40) own a copy of the callable — inline if it fits their small buffer, otherwise on the heap.
C++
std::function_ref<int()> r = [] { return 1; };  // the temporary lambda dies at the ';'
r();                                            // dangling: undefined behaviour

6. std::invoke and member pointers

Algorithms and wrappers call things with std::invoke, which also accepts pointers to members — so &Point::x works as a projection or a callable:

C++
#include <algorithm>
#include <functional>
#include <print>
#include <vector>

struct Point {
    int x, y;
    int sum() const { return x + y; }
};

int main() {
    Point p{3, 4};
    std::println("{} {}", std::invoke(&Point::x, p), std::invoke(&Point::sum, p));

    std::vector<Point> pts{{5, 1}, {2, 9}, {4, 4}};
    std::ranges::sort(pts, {}, &Point::x);                // projection = member pointer
    std::println("{} {} {}", pts[0].x, pts[1].x, pts[2].x);
}

If you write your own higher-order functions, call user callables with std::invoke(f, args...) rather than f(args...) so member pointers work too.

Worked example: composing callables

compose(f, g)(args…) = f(g(args…))
The result owns moved-in copies of f and g, so it can outlive the arguments it was built from.
compose(f, g)(args…) = f(g(args…))
The result owns moved-in copies of f and g, so it can outlive the arguments it was built from.

compose(f, g) returns a callable computing f(g(args...)). It must own f and g (the result may outlive the arguments), so it captures them by move:

C++
#include <functional>
#include <print>
#include <string>
#include <utility>

template <class F, class G>
auto compose(F f, G g) {
    return [f = std::move(f), g = std::move(g)]<class... A>(A&&... args) mutable {
        return std::invoke(f, std::invoke(g, std::forward<A>(args)...));
    };
}

int main() {
    auto add = [](int a, int b) { return a + b; };
    auto show = [](int v) { return "<" + std::to_string(v) + ">"; };
    auto add_then_show = compose(show, add);
    std::println("{}", add_then_show(2, 3));        // <5>
}
  • Captures by move so the result owns its pieces and can be returned.
  • mutable so stateful callables (like a counter) can update.
  • Template parameter pack + std::forward passes g's arguments through unchanged; std::invoke lets f or g be member pointers.

Faded example — your turn

Write make_accumulator(start): each call acc(x) adds x to a running total and returns the new total. What do you capture, how, and does the lambda need mutable? What happens to two copies of the accumulator?

C++
auto make_accumulator(int start) {
    return /* ??? */;
}
Answer

return [total = start](int x) mutable { return total += x; }; — capture the running total by value (an init-capture), since the closure outlives make_accumulator, and mark it mutable so operator() may change it. Copies have independent totals; to share one total, capture a std::shared_ptr<int>.

Summary

  • A lambda is an object of a unique class; captures are its members.
  • Capture by value for closures that are stored or returned; by reference only for immediate use. mutable lets the closure change its own copies.
  • Template lambdas ([]<class T>) name their types; deducing this makes recursion easy.
  • APIs: a template or function_ref for "call during this call"; move_only_function (or function if copies are needed) for "store and call later". function_ref never allocates but must not outlive its target.
  • Call user callables with std::invoke so member pointers work.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

What a lambda compiles to: a closure class, captures as members — exactly the mental model this lesson uses.

Capture modes, mutable and generic lambdas, and lifetime traps with captures by reference.

Type erasure — how std::function (and function_ref) hold any callable.

Lambda expression syntax and semantics, with many small examples.

C++23 std::move_only_function and why std::function can't hold move-only callables.

Unity/C#, but the idea is language-agnostic: what a closure captures and the lifetime surprises that follow — compare with C++ capture-by-reference.

Lambdas vs std::function vs function pointers: call cost, inlining and when type erasure is worth it.

Builds a minimal std::function to show how type erasure stores and calls any callable.