C++Functional Programming

C++ Lambdas: Closures, Captures, Generic Lambdas, std::function vs auto & Functional Patterns (C++23)

Complete guide to C++ lambdas and functional programming. Master lambda capture semantics (by value, by reference, by move, init captures), generic lambdas with auto and template parameters, immediately invoked lambdas (IIFE), recursive lambdas with std::function vs deducing this, mutable lambdas, lambda comparators for STL algorithms, and C++23 explicit object parameters.

TT
Emily Ross
10 min readUpdated Apr 21, 2026
C++ Lambdas: Closures, Captures, Generic Lambdas, std::function vs auto & Functional Patterns (C++23)

C++ Lambdas: Closures, Captures, Generic Lambdas, std::function vs auto & Functional Patterns (C++23)


Table of Contents


Lambda Anatomy: The Complete Syntax

cpp
// Minimal lambda (no captures, no params, void return):
auto hello = []{ std::cout << "Hello!\n"; };
hello();

// Full syntax:
auto multiply = [factor = 10](int x, int y) mutable noexcept -> int {
    factor++;          // mutable: can modify captured copies
    return x * y * factor;
};

// Immediately called (IIFE):
int result = [](int a, int b) { return a + b; }(3, 4); // result = 7

// What the compiler generates (conceptual equivalent):
struct __Lambda_multiply {
    int factor;                           // Captured value
    int operator()(int x, int y) noexcept {
        factor++;
        return x * y * factor;
    }
};
// auto multiply = __Lambda_multiply{10};

Capture Semantics: Value, Reference, and Init Captures

cpp
int x = 10;
int y = 20;
std::string name = "Alice";

// === Default captures ===
auto cap_all_by_val = [=]{ return x + y; };        // Copies x and y
auto cap_all_by_ref = [&]{ x++; y++; };            // References to x, y (careful: lifetime!)
// Note: [=] and [&] only capture what is actually USED in the body

// === Selective captures ===
auto selective = [x, &y]{ return x + y; };          // x by value, y by reference
auto with_this  = [this, x]{ process(x); };         // Capture this pointer + x by value

// === Init captures (C++14): computed or renamed captures ===
int a = 5, b = 3;
auto init_cap = [sum = a + b, product = a * b]() {
    return std::format("{} + {} = {}, {} * {} = {}", a, b, sum, a, b, product);
    // Note: a and b NOT captured - only sum and product are
};

// === Capture this vs capture *this ===
class Widget {
    int value_ = 42;
public:
    auto get_adder() {
        // BAD: captures this pointer - if Widget is destroyed, lambda dangles!
        return [this](int n) { return value_ + n; };
        
        // GOOD (C++17): capture *this - copies the entire Widget into the lambda
        return [*this](int n) { return value_ + n; }; // self-contained copy
    }
};

Capture by Move: unique_ptr in Lambdas

unique_ptr can't be copied, so you can't use [=] to capture it. Use an init capture to move it:

cpp
#include <memory>
#include <functional>

auto resource = std::make_unique<ExpensiveResource>();

// WRONG: Can't copy unique_ptr
// auto task = [resource]{ resource->process(); }; // ERROR: copy required

// CORRECT: Init capture with move
auto task = [res = std::move(resource)]() mutable {
    res->process();
    // resource is now nullptr/empty
};
// resource is now null - ownership transferred to lambda

// Storing in a vector of tasks:
std::vector<std::function<void()>> tasks;

for (int i = 0; i < 5; i++) {
    auto data = std::make_unique<WorkItem>(i);
    tasks.emplace_back([item = std::move(data)]() {
        item->execute();
    });
}
// Each task owns its unique_ptr exclusively

Generic Lambdas: auto and Template Parameters

C++14 generic lambdas use auto parameters - each unique argument type generates a separate template instantiation:

cpp
// C++14: auto in parameter list
auto print = [](const auto& value) {
    std::cout << value << '\n';
};
print(42);           // Generates: operator()(const int& value)
print(3.14);         // Generates: operator()(const double& value)
print("hello");      // Generates: operator()(const char[6]& value)

// C++20: explicit template syntax in lambdas
auto typed_print = []<typename T>(const T& value) {
    std::cout << std::format("Type: {}, Value: {}\n",
                             typeid(T).name(), value);
};

// C++20: constrained generic lambda with concepts
auto add_numbers = []<std::integral T>(T a, T b) { return a + b; };
add_numbers(3, 4);      // OK: ints satisfy integral concept
// add_numbers(1.5, 2.5); // ERROR: doubles don't satisfy integral

// C++20: variadic template lambda
auto sum_all = []<typename... Args>(Args&&... args) {
    return (... + std::forward<Args>(args)); // Fold expression
};
auto total = sum_all(1, 2, 3, 4, 5); // 15
auto fsum  = sum_all(1.5f, 2.5f);    // 4.0f

Mutable Lambdas: Modifying Captured Variables

By default, captures by value create const copies inside the lambda body. mutable removes this restriction:

cpp
int counter = 0;

// Without mutable: capture is const, cannot modify
auto count_ro = [counter]() {
    // counter++; // ERROR: assignment of read-only captured variable
    return counter;
};

// With mutable: can modify the captured COPY (NOT the original)
auto count_mut = [counter]() mutable {
    return ++counter; // Modifies the copy inside the lambda
};

std::cout << count_mut() << '\n'; // 1
std::cout << count_mut() << '\n'; // 2
std::cout << counter << '\n';     // Still 0! Original unchanged

// Stateful generator using mutable:
auto make_counter = [n = 0]() mutable { return n++; };
std::cout << make_counter() << '\n'; // 0
std::cout << make_counter() << '\n'; // 1
std::cout << make_counter() << '\n'; // 2

Lambdas in STL Algorithms

Lambdas replace hand-written functors and function objects for STL algorithms:

cpp
#include <algorithm>
#include <ranges>
#include <vector>

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

// Sort with custom comparator:
std::sort(nums.begin(), nums.end(), [](int a, int b){ return a > b; }); // Descending

// Filter with ranges (C++20):
auto evens = nums | std::views::filter([](int n){ return n % 2 == 0; });

// Transform:
auto squares = nums
    | std::views::filter([](int n){ return n > 3; })
    | std::views::transform([](int n){ return n * n; });

// find_if:
auto it = std::find_if(nums.begin(), nums.end(), [](int n){ return n > 5; });

// count_if:
int odds = std::count_if(nums.begin(), nums.end(), [](int n){ return n % 2 != 0; });

// remove_if + erase:
nums.erase(
    std::remove_if(nums.begin(), nums.end(), [](int n){ return n < 4; }),
    nums.end()
);

// Comparator for priority_queue (min-heap):
auto cmp = [](const Task& a, const Task& b){ return a.priority > b.priority; };
std::priority_queue<Task, std::vector<Task>, decltype(cmp)> task_queue(cmp);

Immediately Invoked Lambda Expressions (IIFE)

An IIFE executes a lambda immediately - useful for complex initialization of const variables:

cpp
// Initialize const from complex logic without a named helper function:
const int processed_count = [&data]() {
    int count = 0;
    for (const auto& item : data) {
        if (item.is_valid() && item.value > 0) count++;
    }
    return count;
}(); // Immediately invoked

// Use in member initializer lists:
class Config {
    const std::vector<std::string> allowed_hosts_;
public:
    Config(const std::string& config_file)
        : allowed_hosts_([&config_file]() {
            std::vector<std::string> hosts;
            parse_hosts(config_file, hosts);
            return hosts;
          }())  // IIFE in initializer list
    {}
};

// Complex constexpr initialization:
constexpr auto LOOKUP_TABLE = [](){
    std::array<uint8_t, 256> table{};
    for (int i = 0; i < 256; i++) {
        table[i] = __builtin_popcount(i); // Population count
    }
    return table;
}();  // Computed at compile time

std::function vs auto: The Performance Tradeoff

cpp
// auto: zero-overhead, lambda type preserved
auto fast_sorter = [](int a, int b){ return a < b; };
std::sort(v.begin(), v.end(), fast_sorter); // Inlined by compiler - fastest

// std::function: type-erased, extra overhead
std::function<bool(int, int)> slow_sorter = [](int a, int b){ return a < b; };
std::sort(v.begin(), v.end(), slow_sorter); // Virtual-call-like dispatch - slower

// std::function overhead sources:
// 1. Type erasure via internal virtual dispatch (~3-5ns per call)
// 2. Small buffer optimization (<=~16 bytes): stack allocation
// 3. Larger closures: heap allocation!

// RULE: Use auto when type doesn't need to cross interface boundaries
// RULE: Use std::function only when you need to store heterogeneous callables

// Storing callbacks in a class (correct use of std::function):
class EventBus {
    std::vector<std::function<void(const Event&)>> handlers_;
public:
    void subscribe(std::function<void(const Event&)> handler) {
        handlers_.push_back(std::move(handler));
    }
    void publish(const Event& e) {
        for (auto& h : handlers_) h(e); // Must handle any callable
    }
};

Recursive Lambdas: Y-Combinator and deducing this (C++23)

C++ lambdas cannot directly call themselves because they have no name inside their own body. Solutions:

cpp
// Pre-C++23: std::function (with overhead)
std::function<int(int)> factorial = [&factorial](int n) -> int {
    return n <= 1 ? 1 : n * factorial(n - 1);
};

// Pre-C++23: Y-combinator (zero overhead, complex)
auto Y = [](auto f) {
    return [f](auto x) { return f(f, x); };
};
auto factorial_y = Y([](auto self, int n) -> int {
    return n <= 1 ? 1 : n * self(self, n - 1);
});

// C++23: "deducing this" - explicit object parameter - direct recursion!
auto factorial_23 = [](this auto self, int n) -> int {
    return n <= 1 ? 1 : n * self(n - 1); // self refers to the lambda itself
};
// Zero overhead: same as a regular recursive function
int f10 = factorial_23(10); // 3628800

Frequently Asked Questions

Is a lambda always faster than std::function? When passed directly to a template function (like std::sort), yes - the compiler knows the exact type and inlines the lambda body. When stored in std::function, lambda calls involve type erasure overhead (~3-5ns per call). For callbacks stored in containers, std::function is unavoidable. For algorithms, always pass lambdas directly.

Can lambdas be constexpr? Yes - from C++17, lambdas are implicitly constexpr if their body is valid as a constant expression. Lambda IIFE is a common pattern for initializing constexpr arrays with complex logic. C++20 allows lambdas in unevaluated contexts, template arguments, and more.

What's the capture overhead? Captured values are stored as members of the compiler-generated closure type. Captured by-value: space for each captured variable. Captured by-reference: one pointer per reference. Capturing [=] only captures variables actually used in the body - no overhead for unused variables. The lambda object itself lives on the stack unless stored in a std::function (may heap-allocate large closures).


Key Takeaway

Lambdas transformed C++ from a language where "passing logic" required separate functor classes to one where inline, composable, closures are first-class. The key discipline: pass to algorithms directly (zero overhead via auto), store in std::function only when necessary (accepts type erasure overhead), and use C++23's "deducing this" for recursive lambdas without overhead. Together with std::ranges, lambdas enable functional pipeline programming that remains fully optimizable by the compiler.

Read next: Multithreading & Atomics: High-Performance Concurrency ->


Part of the C++ Mastery Course - 30 modules from modern C++ basics to expert systems engineering.