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. mutablemakes 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.