Skip to main content
C++ · lesson 15 of 24
C++ intermediate Lesson 15 of 24

Lambdas and Functional Programming in C++

C++ lambdas run for real: closure sizes, capture timing, mutable, a dangling capture caught by ASan, generic lambdas, std::function, recursion, [*this].

By SkillByExample · Updated

A lambda is an unnamed function object written inline. The compiler turns each one into a small class with an operator(), which is why lambdas make STL algorithms and callbacks short without costing anything at runtime.

Lambda Syntax

The form is [captures](parameters) -> return_type { body }; everything except the brackets and body is optional.

#include <iostream>
#include <string>

int main() {
    auto add = [](int a, int b) -> int { return a + b; };   // explicit return type
    auto square = [](double x) { return x * x; };           // deduced: double
    std::cout << add(3, 4) << " " << square(1.5) << "\n";

    int n = 42;
    std::string s = "hello";
    auto none  = [] {};
    auto one   = [n] { return n; };
    auto two   = [n, s] { return s.size() + n; };
    auto byref = [&n, &s] { return s.size() + n; };
    std::cout << "sizeof: none " << sizeof(none) << ", [n] " << sizeof(one)
              << ", [n, s] " << sizeof(two) << ", [&n, &s] " << sizeof(byref) << "\n";

    auto other = [] {};
    std::cout << std::boolalpha << "same type as an identical lambda? "
              << std::is_same_v<decltype(none), decltype(other)> << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o syntax syntax.cpp
$ ./syntax
7 2.25
sizeof: none 1, [n] 4, [n, s] 40, [&n, &s] 16
same type as an identical lambda? false

The return type of square was deduced as double. The sizeof line shows what a lambda is: an object whose data members are its captures. With no captures it’s an empty class (size 1); capturing an int makes it 4 bytes; adding a std::string copy adds 32 more (40 with padding); capturing both by reference stores two pointers (16). And every lambda expression has its own unique type — two textually identical lambdas are different types, which is why you store them in auto.

Capture Modes

The capture list decides which locals the body can use, and whether it gets a copy or a reference.

#include <iostream>
#include <string>

int main() {
    int x = 10;
    std::string name = "Alice";

    auto by_value = [x, name] { std::cout << "by value: " << x << " " << name << "\n"; };
    auto by_ref   = [&x, &name] { std::cout << "by ref:   " << x << " " << name << "\n"; };
    auto mixed    = [=, &x] { x = 99; return name.size(); };   // all by value, except x

    x = 20;                       // change the originals after creating the lambdas
    name = "Bob";
    by_value();                   // copies were taken at creation: 10 Alice
    by_ref();                     // sees the current values: 20 Bob

    auto len = mixed();
    std::cout << "mixed wrote x = " << x << ", its name copy has " << len << " chars\n";

    int counter = 0;
    auto increment = [counter]() mutable { return ++counter; };   // modifies its own copy
    std::cout << increment() << " " << increment() << " " << increment()
              << ", original counter " << counter << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o captures captures.cpp
$ ./captures
by value: 10 Alice
by ref:   20 Bob
mixed wrote x = 99, its name copy has 5 chars
1 2 3, original counter 0

The key detail is when a value capture happens: at the lambda’s creation. by_value copied 10 and “Alice” before the originals changed, so it still prints them; by_ref reads the variables at call time. [=, &x] captures everything by value except x, so mixed wrote 99 into the real x. mutable lets a lambda modify its own copy — increment counts 1, 2, 3 inside the closure while the outer counter stays 0.

Without mutable, a value capture is read-only:

int main() {
    int x = 10;
    auto f = [x] { x = 20; };   // value captures are const unless mutable
    f();
}
$ g++ -std=c++20 -Wall -Wextra -o const_capture const_capture.cpp 2>&1 | grep error
const_capture.cpp:3:22: error: assignment of read-only variable 'x'

Dangling References

A reference capture is only valid while the referenced variable lives. Returning such a lambda from a function breaks that — AddressSanitizer shows it:

#include <functional>
#include <iostream>
std::function<int()> make_counter() {
    int count = 0;
    return [&count]() { return ++count; };   // captures a local by reference
}
int main() {
    auto next = make_counter();              // count is already gone
    std::cout << next() << "\n";
}
$ g++ -std=c++20 -g -fsanitize=address -o dangling dangling.cpp
$ ASAN_OPTIONS=detect_stack_use_after_return=1 ./dangling 2>&1 | grep -E 'ERROR|READ|#0 .* in operator'
==5493==ERROR: AddressSanitizer: stack-use-after-return on address 0x7fda032000b0 at pc 0x55e3280703b5 bp 0x7fff9b114180 sp 0x7fff9b114170
READ of size 4 at 0x7fda032000b0 thread T0
    #0 0x55e3280703b4 in operator() /tmp/demo/dangling.cpp:5

count lived in make_counter’s stack frame, which is gone by the time next() runs, so the lambda reads freed stack memory — stack-use-after-return, in operator() at line 5. Without the sanitizer this build crashed with a segmentation fault on this machine, but undefined behavior can just as easily print a plausible number. Capture by value ([count]() mutable) whenever a lambda may outlive its scope.

Lambdas with STL Algorithms

Lambdas are at their best as the predicate or operation of an algorithm, right at the call site.

#include <algorithm>
#include <iostream>
#include <iterator>
#include <numeric>
#include <vector>

int main() {
    std::vector<int> nums = {5, 2, 8, 1, 9, 3, 7};
    int threshold = 4;

    std::sort(nums.begin(), nums.end(), [](int a, int b) { return a > b; });
    std::vector<int> big;
    std::copy_if(nums.begin(), nums.end(), std::back_inserter(big),
                 [threshold](int n) { return n > threshold; });     // uses a capture
    auto small = std::count_if(nums.begin(), nums.end(),
                 [threshold](int n) { return n <= threshold; });
    int product = std::accumulate(big.begin(), big.end(), 1,
                 [](int acc, int n) { return acc * n; });

    for (int n : nums) std::cout << n << " ";
    std::cout << "\nbigger than " << threshold << ": ";
    for (int n : big) std::cout << n << " ";
    std::cout << "\nsmall count " << small << ", product of big " << product << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o algorithms algorithms.cpp
$ ./algorithms
9 8 7 5 3 2 1 
bigger than 4: 9 8 7 5 
small count 3, product of big 2520

The comparator a > b sorts descending. copy_if and count_if both use the captured threshold, which would have required a hand-written functor class with a constructor before C++11. accumulate’s lambda takes the running value first and the element second: 9 × 8 × 7 × 5 = 2520.

Generic Lambdas

auto parameters (C++14) make operator() a template; C++20 adds explicit template parameters and requires clauses.

#include <iostream>
#include <string>
#include <type_traits>
#include <vector>

int main() {
    auto add = [](auto a, auto b) { return a + b; };     // C++14: a template operator()
    std::cout << add(1, 2) << " " << add(1.5, 2.25) << " "
              << add(std::string("hi"), "!") << "\n";

    auto first = []<typename T>(const std::vector<T>& v) { return v.front(); };  // C++20
    std::cout << first(std::vector<int>{7, 8}) << " "
              << first(std::vector<std::string>{"x", "y"}) << "\n";

    auto positive = [](auto val) requires std::is_arithmetic_v<decltype(val)> {
        return val > 0;
    };
    std::cout << std::boolalpha << positive(3) << " " << positive(-2.5) << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o generic generic.cpp
$ ./generic
3 3.75 hi!
7 x
true false

One add handled int, double, and std::string + const char*. []<typename T>(const std::vector<T>& v) names T, which auto can’t do when you need the element type. The requires clause restricts positive to arithmetic types — positive(std::string("x")) would fail to compile with “constraints not satisfied”.

Immediately Invoked Lambdas

Defining and calling a lambda in one expression lets you initialize a const variable with multi-step logic.

#include <cstdlib>
#include <iostream>

int main() {
    const int level = [] {                   // complex init for a const variable
        const char* env = std::getenv("APP_LEVEL");
        if (!env) return 0;
        int val = std::atoi(env);
        return (val >= 0 && val <= 10) ? val : 0;
    }();                                     // () calls it immediately
    std::cout << "level = " << level << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o iife iife.cpp
$ ./iife
level = 0
$ APP_LEVEL=7 ./iife
level = 7
$ APP_LEVEL=42 ./iife
level = 0

The trailing () runs the lambda once, and level is const from birth — no mutable temporary, no separate helper function. Missing and out-of-range values both fall back to 0.

std::function — Type-Erased Callables

std::function<R(Args...)> holds any callable with a matching signature, so different lambdas and functions can share one container or member type.

#include <functional>
#include <iostream>
#include <string>
#include <vector>

struct Button {
    std::string label;
    std::function<void()> on_click;               // any callable with this signature
    void click() const { if (on_click) on_click(); else std::cout << label << ": no handler\n"; }
};

int triple(int x) { return x * 3; }

int main() {
    std::vector<std::function<int(int)>> steps;
    steps.push_back([](int x) { return x * 2; });
    steps.push_back(triple);                      // a plain function fits too
    int offset = 10;
    steps.push_back([offset](int x) { return x + offset; });
    int val = 3;
    for (const auto& f : steps) val = f(val);
    std::cout << "((3*2)*3)+10 = " << val << "\n";

    Button ok{"OK", [] { std::cout << "OK clicked\n"; }}, cancel{"Cancel", nullptr};
    ok.click();
    cancel.click();
    std::cout << "sizeof(std::function<int(int)>) = " << sizeof(std::function<int(int)>) << "\n";
    try { cancel.on_click(); } catch (const std::bad_function_call& e) { std::cout << e.what() << "\n"; }
}
$ g++ -std=c++20 -Wall -Wextra -o function function.cpp
$ ./function
((3*2)*3)+10 = 28
OK clicked
Cancel: no handler
sizeof(std::function<int(int)>) = 32
bad_function_call

The vector mixes a captureless lambda, a plain function and a capturing lambda: 3 → 6 → 18 → 28. An empty std::function tests false, so click() can check it; calling one anyway throws std::bad_function_call. The flexibility has a cost: every std::function is 32 bytes here regardless of what it holds, larger closures go on the heap, and calls go through an indirection the optimizer usually can’t see through. When the callable’s type can be a template parameter or auto, prefer that.

Recursive Lambdas

A lambda can’t name itself inside its own body. Before C++23 there are two workarounds:

#include <functional>
#include <iostream>

int main() {
    std::function<int(int)> fib = [&fib](int n) {         // captures itself by reference
        return n <= 1 ? n : fib(n - 1) + fib(n - 2);
    };

    auto fact = [](auto& self, int n) -> long {           // pass itself as a parameter
        return n <= 1 ? 1 : n * self(self, n - 1);
    };

    std::cout << fib(10) << " " << fact(fact, 10) << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o recursive recursive.cpp
$ ./recursive
55 3628800

fib captures the std::function that holds it — it works, with std::function’s overhead. fact takes itself as a parameter, which needs no std::function but makes every call site write self(self, …). C++23’s explicit object parameter (“deducing this”) removes the awkwardness:

#include <iostream>

int main() {
    auto fib = [](this auto& self, int n) -> int {       // C++23: deducing this
        return n <= 1 ? n : self(n - 1) + self(n - 2);
    };
    std::cout << fib(10) << "\n";
}
$ clang++ -std=c++23 -Wall -Wextra -o deducing_this deducing_this.cpp
$ ./deducing_this
55

This needs Clang 18 or GCC 14; GCC 13, the other compiler on this machine, rejects this auto& self with “expected identifier before ‘this’”.

Capturing this

Inside a member function, [this] captures the object’s pointer; C++17’s [*this] copies the object.

#include <functional>
#include <iostream>

class Processor {
    int threshold_ = 5;
public:
    std::function<bool(int)> live()     { return [this](int x) { return x > threshold_; }; }
    std::function<bool(int)> snapshot() { return [*this](int x) { return x > threshold_; }; }
    void set_threshold(int t) { threshold_ = t; }
};

int main() {
    Processor p;
    auto by_pointer = p.live();         // holds &p
    auto by_copy = p.snapshot();        // holds a copy of p, taken now
    p.set_threshold(100);
    std::cout << std::boolalpha << "7 > threshold? live: " << by_pointer(7)
              << ", snapshot: " << by_copy(7) << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o capture_this capture_this.cpp
$ ./capture_this
7 > threshold? live: false, snapshot: true

After set_threshold(100), the [this] lambda sees the new threshold and says 7 is not above it; the [*this] lambda carries its own copy of the object from before the change and still uses 5. That copy is also what keeps it safe if p is destroyed before the callback runs — [this] would dangle, like the reference capture above.

Stateless Lambdas and Function Pointers

A lambda with no captures converts to a plain function pointer, so it works with C APIs.

#include <cstdio>
#include <cstdlib>

int main() {
    int data[] = {42, 7, 19, 3};
    // C's qsort takes a plain function pointer: int (*)(const void*, const void*)
    std::qsort(data, 4, sizeof(int), [](const void* a, const void* b) {
        int x = *static_cast<const int*>(a), y = *static_cast<const int*>(b);
        return (x > y) - (x < y);
    });
    for (int x : data) std::printf("%d ", x);
    std::printf("\n");

    int (*fp)(int) = [](int x) { return x * 2; };        // explicit conversion target
    std::printf("fp(21) = %d\n", fp(21));
}
$ g++ -std=c++20 -Wall -Wextra -o fnptr fnptr.cpp
$ ./fnptr
3 7 19 42 
fp(21) = 42

qsort wants an int (*)(const void*, const void*), and the captureless lambda converts to exactly that. (x > y) - (x < y) returns -1, 0 or 1 without the overflow that x - y risks. A capture gives the lambda state that a bare function pointer has nowhere to keep:

int main() {
    int factor = 3;
    int (*fp)(int) = [factor](int x) { return x * factor; };   // has state
    return fp(1);
}
$ g++ -std=c++20 -Wall -Wextra -o fnptr_fail fnptr_fail.cpp 2>&1 | grep error
fnptr_fail.cpp:3:22: error: cannot convert 'main()::<lambda(int)>' to 'int (*)(int)' in initialization

Practice

  1. Rewrite make_counter in dangling.cpp so it’s safe, and call next() three times.

    Solution
    std::function<int()> make_counter() {
        int count = 0;
        return [count]() mutable { return ++count; };   // the closure owns its copy
    }

    Prints 1, 2, 3, and ASan stays silent. The counter now lives inside the closure object, which lives as long as next.

  2. In captures.cpp, change by_value to [=] and predict the output before running.

    Solution

    Still by value: 10 Alice. [=] captures exactly the variables the body uses, by value, at creation time — the same as listing [x, name].

  3. Use a generic lambda with std::sort to sort a std::vector<std::string> by length, shortest first, ties alphabetical.

    Solution
    std::sort(words.begin(), words.end(), [](const auto& a, const auto& b) {
        return a.size() != b.size() ? a.size() < b.size() : a < b;
    });

    {"pear", "fig", "apple", "kiwi"} becomes fig kiwi pear apple.

  4. Store three lambdas with different capture lists in one std::vector without std::function. Why doesn’t it work?

    Solution

    It doesn’t compile: each lambda has its own unique type, and a vector holds one type. std::vector<decltype(first)> can only ever hold copies of that first lambda. Heterogeneous callables need type erasure — std::function (or std::move_only_function in C++23).

  5. Make fnptr_fail.cpp compile while still multiplying by a runtime factor.

    Solution
    int factor = 3;
    std::function<int(int)> f = [factor](int x) { return x * factor; };
    return f(1);

    Or use auto f = .... A raw function pointer can’t carry factor; for a C API, the usual workaround is its void* user_data parameter.

Frequently Asked Questions

What is the overhead of std::function?
std::function erases the callable's type: it is a fixed-size object (32 bytes with GCC's libstdc++) that may heap-allocate larger closures, and each call goes through an indirection the compiler usually can't inline. Store lambdas in auto variables or pass them as template parameters when you don't need that flexibility.
What does [=] capture mean?
It captures every local variable the lambda body uses by value, copying it when the lambda is created. Later changes to the originals are not seen, and the copies are const unless the lambda is marked mutable. [&] captures them by reference instead.
When is capturing by reference dangerous?
When the lambda outlives the variables it refers to — for example a lambda returned from a function, stored as a callback, or run on another thread. Calling it then reads destroyed stack memory, which is undefined behaviour; AddressSanitizer reports it as stack-use-after-return.
What is the difference between [this] and [*this]?
[this] captures the object's pointer, so the lambda sees later changes and dangles if the object is destroyed. [*this] (C++17) copies the whole object into the closure, taking a snapshot that stays valid on its own.
Can a lambda be passed to a C function expecting a function pointer?
Only a captureless lambda. With an empty capture list it converts implicitly to a plain function pointer, so it works with APIs like qsort. A lambda that captures anything carries state and cannot convert.