Skip to content
academia.sh

Lesson 09 / 11

Lambdas and Function Objects

The measurement of how a lambda's non-mutable capture-by-value leaves the source unchanged, and how the captured copy stays independent across calls.

Published Updated

In this lesson

  • Measures that a lambda capturing by value does not change the outer variable itself
  • Shows how the mutable keyword makes the captured copy modifiable
  • Distinguishes capture by reference from capture by value
  • Recognizes that a lambda is an unnamed function object the compiler generates
Contents

The previous lesson searched with std::remove by passing it a value; but some algorithms ask for a predicate instead of a value — something callable that evaluates a condition. This lesson moves to the most common form of that callable thing, lambda expressions. When a lambda captures an outer variable — by writing [value] inside the square brackets — what happens to that outer variable?

Capture by Value: Copying, Not Binding

A lambda that writes [value] embeds a copy of the value variable inside the lambda object. This is not holding a reference to the outer value; a snapshot of the value is taken at the moment the lambda is created.

// capture-by-value.cpp — does the lambda change the outer variable
#include <cstdio>

int main() {
    int value = 10;
    auto show = [value]() { std::printf("captured value %d\n", value); };

    value = 99;
    show();

    std::printf("outer value %d\n", value);
    return 0;
}
captured value 10
outer value 99

SL26. When the lambda was created, value was 10, and the lambda embedded a copy of that 10. Before the lambda was called, the outer value was assigned 99, but when show() was called it still printed 10 — because the copy inside the lambda is completely independent from the outer variable. The outer value itself also stayed unchanged: the act of creating the lambda never modified the variable it captured; the line value = 99 was already a separate assignment operation, not the “capture” operation this lesson measures.

mutable: Making the Captured Copy Modifiable

By default, a copy captured by value is treated as const inside the lambda’s call operator — it cannot be modified. The mutable keyword removes this restriction and allows the captured copy to be modified inside the lambda’s body. This change affects only the copy, not the outer variable.

// mutable-capture.cpp — modifying the copy with mutable
#include <cstdio>

int main() {
    int step = 0;
    auto advance = [step]() mutable {
        step += 1;
        std::printf("step inside lambda %d\n", step);
    };

    advance();

    std::printf("outer step %d\n", step);
    return 0;
}
step inside lambda 1
outer step 0

SL27. Thanks to mutable, the copy of step inside the lambda changed from 0 to 1 during the advance() call — the lambda object was able to make this change on its own copy. But the outer step remained 0 after the call as well: unchanged. This clarifies what mutable changes — not the outer variable, only the copy the lambda embeds inside itself. This is where this course’s central measurement finds its answer for this lesson: capture by value, no matter what mutable is written, never changes the outer source.

Capture by Reference: Direct Access to the Outer Variable

Writing [&value] captures a reference to the outer variable instead of taking a copy. In this case, a change inside the lambda’s body also changes the outer variable — because there is no longer a separate copy.

// capture-by-reference.cpp — capture by reference changes the outside
#include <cstdio>

int main() {
    int step = 0;
    auto advance = [&step]() { step += 1; };

    advance();

    std::printf("outer step %d\n", step);
    return 0;
}
outer step 1

SL28. This time there was no need to write mutable, because what was being modified was not a copy but the outer step itself. After the advance() call, the outer step went from 0 to 1: the kind of this operation is changed. This stands directly opposite the capture-by-value example at the start of the lesson: [value] left the source unchanged while [&step] changes it. The difference is a single character in the capture list (&), but the result is the exact opposite.

Bulk Capture: [=], [&], and Mixed Lists

Instead of writing every outer variable one by one, [=] captures all outer variables by value, and [&] captures all of them by reference. The two can also be mixed: [=, &value] captures everything else by value and only value by reference; [&, value] does the exact opposite. Bulk capture leaves the choice of which variable to capture to the compiler — every outer name used in the lambda’s body is automatically added to the list.

// bulk-capture.cpp — mixed capture list
#include <cstdio>

int main() {
    int threshold = 10;
    int step = 0;

    auto evaluate = [=, &step](int value) {
        if (value > threshold) {
            step += 1;
        }
    };

    evaluate(15);

    std::printf("outer step %d\n", step);
    std::printf("outer threshold %d\n", threshold);
    return 0;
}
outer step 1
outer threshold 10

SL29. In the capture list [=, &step], threshold was captured by value (as a copy), and step was captured by reference. The call evaluate(15) read the copy of threshold but did not change it — the outer threshold stayed unchanged. The same call changed step through the reference: the outer step went from 0 to 1. A mixed capture list makes it possible for some variables in the same lambda to stay unchanged while others change at the same time — the kind decision is made separately for each name in the capture list; there is no single kind for the list as a whole.

A Lambda Is Actually a Function Object

When the compiler sees a lambda expression, it generates an unnamed class behind the scenes: this class has an operator() member function, and the captured variables are that class’s data members. A [value] capture adds a member of value’s type to the generated class and copies that member in the constructor. This explains why lambdas can be used in the same places as hand-written function objects (ordinary classes that define an operator()).

// function-object-equivalent.cpp — a lambda and a hand-written class do the same job
#include <cstdio>

struct ValueDisplayer {
    int value;
    void operator()() const { std::printf("value inside class %d\n", value); }
};

int main() {
    int value = 42;

    ValueDisplayer class_displayer{value};
    auto lambda_displayer = [value]() { std::printf("value inside lambda %d\n", value); };

    class_displayer();
    lambda_displayer();
    return 0;
}
value inside class 42
value inside lambda 42

SL30. The ValueDisplayer class and the lambda_displayer lambda produced the same result, because both apply the same pattern: carrying a value as a member and reading it inside operator(). Neither of these calls changed value; both leave it unchanged, because both took their own copy in the constructor and only read it. This equivalence shows that lambdas are not a “special” language feature, but an ordinary class the compiler writes automatically.

What the Capture List Leaves in the Source

The “Systems Programming with C” course (c-systems-programming) asked whether a change reaches the other side: the writer’s own copy, parties sharing the same object, or no one at all. The capture list immediately brings that question to mind — a lambda capturing with [&value] “sees” the outer change, one capturing with [value] does not. This lesson does not ask that question. What is measured here is what is left in the source itself after the lambda is called: the source is unchanged with capture by value, changed with capture by reference, and unspecified when it was moved through an rvalue capture. Seeing names a relationship; what is left behind names a state. This course’s measure is the state.

std::function: Keeping Different Callables in the Same Type

Every lambda has its own unique, unnamed type generated by the compiler — two different lambda expressions are different types even if they carry the same signature. std::function<Result(Arguments...)> is a wrapper that lets these different types be kept in a single common container. The act of wrapping does not change the callable being wrapped; it only carries a copy of it.

// std-function-wrapping.cpp — does std::function change the lambda it wraps
#include <cstdio>
#include <functional>

int main() {
    int multiplier = 3;
    auto lambda = [multiplier](int value) { return value * multiplier; };

    std::function<int(int)> wrapped = lambda;
    multiplier = 100;

    std::printf("wrapped(5) result %d\n", wrapped(5));
    std::printf("outer multiplier %d\n", multiplier);
    return 0;
}
wrapped(5) result 15
outer multiplier 100

SL31. The line std::function<int(int)> wrapped = lambda; took a copy of lambda’s state at that moment into wrapped — this is a copying operation, just like the [multiplier] capture itself. multiplier was later assigned 100, but wrapped(5) still gave 15 (5 * 3), because the copy inside wrapped carried multiplier’s value from before the assignment. lambda itself came out of this wrapping operation unchanged: assignment to std::function never wrote to the lambda object, it only copied it. This is the same pattern as the capture-by-value example at the start of the lesson — the difference is that this time what is copied is not an int, but a lambda object itself.

This copying carries a cost: to be able to hold callables of different types in a single common type, std::function applies type erasure — it hides the real type of the callable it wraps behind a base class. This hiding usually requires a dynamic memory allocation and adds an indirect jump (a mechanism similar to a virtual call) on every call. A lambda assigned directly to an auto variable keeps its own concrete type; the compiler can inline the call directly. std::function is therefore not a tool that stands in for auto everywhere — it is preferred when callables of different types need to be carried in the same container (for example, keeping a list of callbacks in a container), not just to hold a single lambda.

Generic Lambdas: auto as the Parameter Type

Starting with C++14, a lambda’s parameter list can write auto instead of a concrete type. This turns the lambda’s call operator into a template: the same lambda, when called with different types, generates a separate operator() instance for each type. This has nothing to do with the capture list — the kind (unchanged/changed) of the captured variables is still determined by the same rules; only the call parameter is generic.

// generic-lambda.cpp — an auto-parameter lambda works with different types
#include <cstdio>

int main() {
    auto show = [](auto value) {
        std::printf("show called with a value\n");
        (void)value;
    };

    show(7);
    show(3.14);

    return 0;
}
show called with a value
show called with a value

SL32. The show lambda was called with both an int and a double; the compiler generated a separate operator() instance for each call, but there is a single lambda expression in the source code. This is the same mechanism as the general behavior of template functions — an auto parameter turns the lambda’s call operator into a template member function. This has no direct bearing on this lesson’s central axis (the kind of the captured variable); a generic lambda only makes the type of the call parameter flexible, it does not change the capture list’s behavior.

If mutable Is Not Written

Inside the body of a lambda without mutable, a line that tries to assign directly to a variable captured by value does not compile — because operator() is generated as const unless mutable is written, and a const member function cannot assign to data members.

// const-lambda-body.cpp — why assignment fails to compile without mutable
#include <cstdio>

int main() {
    int value = 0;
    auto read = [value]() { std::printf("value %d\n", value); };

    // The line below does not compile, because operator() is const
    // and value cannot be modified:
    // auto broken = [value]() { value += 1; };

    read();
    return 0;
}
value 0

This restriction is not a shortcoming, it is a guarantee: unless mutable is written, it is enforced at compile time that a lambda will read the same captured value the same way on every call, without silently accumulating state across calls. If a lambda is meant to hold state across calls, that intent must be stated explicitly by writing mutable.

This guarantee also determines which question a reader must ask when examining a lambda: is there an & in the capture list or not, and if so, is mutable written. The answer to these two questions alone determines, for every variable the lambda captures, whether the source will remain unchanged or become changed — no runtime observation is needed either.

The Bounding Measurement

All three lambdas carry the same body — increment the captured name once and print it — but the moment only the capture form changes, the kind of the outer source splits into three:

#include <cstdio>
#include <string>
#include <utility>

int main() {
    int step = 0;
    auto by_value = [step]() mutable {
        ++step;
        std::printf("inside body: %d\n", step);
    };
    by_value();

    int position = 0;
    auto by_reference = [&position]() mutable {
        ++position;
        std::printf("inside body: %d\n", position);
    };
    by_reference();

    std::string s = "data";
    auto by_move = [t = std::move(s)]() {
        std::printf("inside body: %s\n", t.c_str());
    };
    by_move();
    s = "new-value";

    std::printf("outer step: %d\n", step);
    std::printf("outer position: %d\n", position);
    std::printf("outer s: %s\n", s.c_str());
    return 0;
}
inside body: 1
inside body: 1
inside body: data
outer step: 0
outer position: 1
outer s: new-value

All three captures work as expected inside the body, but the trace they leave on the outer source falls into three different kinds. [step] captures by value; even though mutable allows the copy to be incremented, this increment affects only the lambda’s own copy, and the outer step stays unchanged at 0. [&position] captures by reference; ++position inside the body directly changes the outer variable, and the outer position changed to 1. [t = std::move(s)] is an rvalue capture: the resource s held is moved into t, and after the move, s is left in a valid but unspecified state — this is why this lesson never reads or prints s’s content or length after the move. What makes s usable again is assigning it a new value: after the line s = "new-value", s has defined content again, and it is safe to read it — the last line of output shows this. The same body, with three different capture forms, leaves the source in three different kinds: unchanged, changed, unspecified.

Summary

  • Capture by value with [value] takes a copy of the outer variable; the outer variable comes out of the lambda-creation operation unchanged.
  • mutable makes the captured copy modifiable inside the lambda’s body, but this change affects only the copy, not the outer source.
  • Capture by reference with [&value] accesses the outer variable itself; in this case a change inside the lambda’s body also changes the outside.
  • A lambda is an instance of an unnamed class the compiler generates; the captured variables are that class’s data members, and the lambda’s body is its operator().
  • The kind of this lesson’s central measurement is unchanged: with or without mutable, capture by value never changes the outer source.
  • The bounding measurement: the same body produces three different kinds with three different captures — capture by value is unchanged, capture by reference is changed, and rvalue (move) capture leaves the source unspecified, and this lesson never reads that source before reassigning it.

Next Step

This lesson showed how a lambda carries the value it captures inside itself, when mutable makes that copy modifiable, and why capture by reference directly affects the outer source. The next lesson takes up a way to view a container’s elements without transforming them and without building a new container: how does a range view, as a window looking into the underlying container, leave the source unchanged?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close