Lambda Expressions in C++: Anonymous Functions

Learn C++ lambda expressions from beginner to advanced — syntax, capture lists, mutable lambdas, generic lambdas, and real-world uses with STL algorithms.

Lambda Expressions in C++: Anonymous Functions

A lambda expression in C++ is an anonymous, inline function that can be defined exactly where it is needed — directly inside another expression or statement. Introduced in C++11, lambdas provide a concise way to create small function objects without the boilerplate of writing a named function or functor class. They can capture variables from the surrounding scope, accept parameters, specify return types, and be stored in variables or passed directly to algorithms.

Introduction

Before C++11, whenever you needed to pass behavior to an algorithm — for example, a custom comparator to std::sort or a predicate to std::find_if — you had two options: write a standalone named function, or write a functor class (a class with operator() overloaded). Both approaches work, but both require you to jump away from the logic you are writing, name something, and define it elsewhere. For small, one-off operations, this friction breaks the flow of reading and writing code.

C++11 introduced lambda expressions to solve this problem elegantly. A lambda lets you write an anonymous function inline, right where you need it. It can capture variables from the surrounding scope, be assigned to a variable, passed to a function, stored in a container, and even called immediately. Lambdas have become one of the most widely used features in modern C++ — they appear in virtually every contemporary codebase.

This article teaches you everything you need to know about lambda expressions in C++. You will start with the basic syntax and work progressively through capture modes, mutable lambdas, return type deduction, generic lambdas (C++14), immediately-invoked lambdas, lambdas with std::function, and advanced patterns. Every concept is illustrated with practical, runnable examples and thorough explanations.

Lambda Syntax: Anatomy of a Lambda

Every lambda expression has the following structure:

C++
[capture list](parameter list) specifiers -> return type { body }

Let’s break down each part:

  • [capture list] — Specifies which variables from the surrounding scope the lambda can access, and how (by value or by reference). This is the part that makes lambdas special compared to regular functions.
  • (parameter list) — The parameters the lambda accepts when called. Optional if there are no parameters.
  • specifiers — Optional keywords like mutable, constexpr, noexcept.
  • -> return type — Optional explicit return type. If omitted, the compiler deduces it.
  • { body } — The function body.

Here is the simplest possible lambda:

C++
#include <iostream>
using namespace std;

int main() {
    // A lambda that takes no parameters and returns nothing
    auto greet = []() {
        cout << "Hello from a lambda!" << endl;
    };

    greet();  // Call it like a regular function

    // A lambda with parameters
    auto add = [](int a, int b) {
        return a + b;
    };

    cout << "3 + 4 = " << add(3, 4) << endl;

    // A lambda called immediately (no need to store it)
    int result = [](int x) { return x * x; }(7);
    cout << "7 squared = " << result << endl;

    return 0;
}

Output:

Plaintext
Hello from a lambda!
3 + 4 = 7
7 squared = 49

Step-by-step explanation:

  1. auto greet = []() { ... } defines a lambda and stores it in greet. The auto type is necessary because a lambda’s type is a unique, unnamed type generated by the compiler — you cannot write its type explicitly. greet() calls it like any normal function.
  2. auto add = [](int a, int b) { return a + b; } defines a lambda that accepts two integers and returns their sum. The return type is deduced automatically as int from the return statement.
  3. [](int x) { return x * x; }(7) is an immediately-invoked lambda expression (IILE). The lambda is defined and called in one expression. The (7) at the end passes 7 as the argument and the expression evaluates to 49.

Before Lambdas vs. After: The Motivation

To appreciate lambdas, it helps to see what code looked like before them. Consider sorting a vector of strings by length:

C++
#include <iostream>
#include <vector>
#include <algorithm>
#include <string>
using namespace std;

// Pre-C++11: standalone function
bool shorterFirst(const string& a, const string& b) {
    return a.size() < b.size();
}

// Pre-C++11: functor class
struct ShorterFirst {
    bool operator()(const string& a, const string& b) const {
        return a.size() < b.size();
    }
};

int main() {
    vector<string> words = {"banana", "fig", "apple", "date", "kiwi"};

    // Method 1: standalone function
    sort(words.begin(), words.end(), shorterFirst);
    for (const auto& w : words) cout << w << " ";
    cout << endl;

    // Method 2: functor
    sort(words.begin(), words.end(), ShorterFirst{});
    for (const auto& w : words) cout << w << " ";
    cout << endl;

    // Method 3: lambda (C++11 and later) — inline, no extra definitions needed
    sort(words.begin(), words.end(),
         [](const string& a, const string& b) {
             return a.size() < b.size();
         });
    for (const auto& w : words) cout << w << " ";
    cout << endl;

    return 0;
}

Output:

C++
fig fig fig fig fig
fig fig fig fig fig
fig kiwi date apple banana

Step-by-step explanation:

  1. The standalone function shorterFirst requires a separate definition outside main(). For simple comparators like this, this scatters related logic across different parts of the file.
  2. The functor ShorterFirst requires an entire class definition for what is essentially a one-liner. The class has no reuse value outside this single call site.
  3. The lambda version defines the comparison logic exactly where it is used. There is no extra function or class. The code reads naturally: “sort these words, using the rule that shorter strings come first.”
  4. All three methods produce identical results. The lambda version is the most readable and the most maintainable for this kind of small, local operation.

The Capture List: Accessing Surrounding Variables

The most distinctive feature of lambdas — the part that makes them more than just anonymous functions — is the capture list. A lambda can capture variables from its enclosing scope and use them inside its body.

Capture by Value

Capturing by value makes a copy of the variable at the time the lambda is created. Changes to the original after lambda creation do not affect the lambda’s copy.

C++
#include <iostream>
using namespace std;

int main() {
    int threshold = 10;

    // Capture threshold by value
    auto isAbove = [threshold](int x) {
        return x > threshold;
    };

    cout << "5 above threshold? " << isAbove(5) << endl;   // 0 (false)
    cout << "15 above threshold? " << isAbove(15) << endl; // 1 (true)

    // Changing threshold after lambda creation has NO effect on the lambda
    threshold = 20;
    cout << "15 above threshold (after change)? " << isAbove(15) << endl; // Still 1
    cout << "threshold in main: " << threshold << endl;  // 20

    return 0;
}

Output:

Plaintext
5 above threshold? 0
15 above threshold? 1
15 above threshold (after change)? 1
threshold in main: 20

Step-by-step explanation:

  1. [threshold] captures threshold by value. At the moment the lambda is created, a copy of threshold (value 10) is stored inside the lambda object.
  2. After threshold = 20, the original variable changes, but the lambda’s internal copy remains 10. This is why isAbove(15) still returns true (15 > 10) even after the change.
  3. Capture by value is useful when you want a snapshot of a variable’s current value that the lambda will use consistently regardless of what happens to the original.

Capture by Reference

Capturing by reference gives the lambda a reference to the original variable. Changes through the lambda affect the original, and changes to the original after capture are visible inside the lambda.

C++
#include <iostream>
using namespace std;

int main() {
    int counter = 0;

    // Capture counter by reference
    auto increment = [&counter]() {
        counter++;  // Directly modifies the original variable
    };

    increment();
    increment();
    increment();
    cout << "counter = " << counter << endl;  // 3

    // Lambda sees changes to the original
    counter = 100;
    auto showCounter = [&counter]() {
        cout << "lambda sees counter = " << counter << endl;
    };
    showCounter();  // Prints 100

    counter = 200;
    showCounter();  // Prints 200 — lambda tracks the original

    return 0;
}

Output:

C++
counter = 3
lambda sees counter = 100
lambda sees counter = 200

Step-by-step explanation:

  1. [&counter] captures counter by reference. The lambda has a direct reference into the original variable’s memory location.
  2. Each call to increment() modifies counter directly. After three calls, counter is 3 in the outer scope.
  3. showCounter captures counter by reference. When counter changes to 100 and then 200, the lambda sees the updated value each time it is called.
  4. Warning: Never capture a local variable by reference in a lambda that outlives the variable. If the lambda is stored and called after the captured variable goes out of scope, the reference becomes dangling — a common source of undefined behavior.

Capture All by Value: [=]

[=] captures all local variables used inside the lambda body by value.

C++
#include <iostream>
using namespace std;

int main() {
    int base = 100;
    int multiplier = 3;

    // Captures both base and multiplier by value
    auto compute = [=](int x) {
        return base + x * multiplier;
    };

    cout << compute(5) << endl;   // 100 + 5*3 = 115
    cout << compute(10) << endl;  // 100 + 10*3 = 130

    base = 999;  // Does not affect lambda
    cout << compute(5) << endl;   // Still 115

    return 0;
}

Capture All by Reference: [&]

[&] captures all local variables used inside the lambda body by reference.

C++
#include <iostream>
#include <vector>
using namespace std;

int main() {
    vector<int> results;
    int sum = 0;
    int count = 0;

    // Captures results, sum, and count all by reference
    auto record = [&](int value) {
        results.push_back(value);
        sum += value;
        count++;
    };

    record(10);
    record(20);
    record(30);

    cout << "Count: " << count << endl;  // 3
    cout << "Sum: " << sum << endl;      // 60
    cout << "Results: ";
    for (int r : results) cout << r << " ";
    cout << endl;

    return 0;
}

Output:

C++
Count: 3
Sum: 60
Results: 10 20 30

Mixed Captures

You can mix capture modes — capturing some variables by value and others by reference:

C++
#include <iostream>
#include <string>
using namespace std;

int main() {
    string prefix = "Item: ";
    int count = 0;

    // prefix captured by value (snapshot), count by reference (shared)
    auto label = [prefix, &count](const string& name) -> string {
        count++;
        return prefix + name + " #" + to_string(count);
    };

    cout << label("Apple") << endl;   // Item: Apple #1
    cout << label("Banana") << endl;  // Item: Banana #2

    prefix = "CHANGED";  // Does not affect lambda
    cout << label("Cherry") << endl;  // Item: Cherry #3 (prefix unchanged)
    cout << "Total items labeled: " << count << endl;  // 3

    return 0;
}

Output:

C++
Item: Apple #1
Item: Banana #2
Item: Cherry #3
Total items labeled: 3

Step-by-step explanation:

  1. [prefix, &count] captures prefix by value (a copy of "Item: " is stored in the lambda) and count by reference (the lambda has a reference to the original count variable).
  2. Even after prefix = "CHANGED", the lambda’s internal copy of prefix remains "Item: ". The lambda uses its captured snapshot.
  3. count is shared between the lambda and the outer scope. Each call increments the original count, and the change is visible both inside the lambda (for the label number) and outside (the final cout).
  4. The explicit -> string return type annotation is optional here (the compiler could deduce it), but it makes the intent clear and is good practice for non-trivial lambdas.

Mutable Lambdas: Modifying Captured-by-Value Variables

By default, a lambda that captures by value cannot modify its captured copies — the lambda’s operator() is implicitly const. If you want to modify a captured-by-value variable inside the lambda, you must add the mutable specifier.

C++
#include <iostream>
using namespace std;

int main() {
    int callCount = 0;

    // Without mutable: captured-by-value variables are read-only
    // auto counter = [callCount]() { callCount++; }; // COMPILE ERROR

    // With mutable: can modify the copy
    auto counter = [callCount]() mutable {
        callCount++;  // Modifies the lambda's COPY — not the original
        cout << "Lambda's internal count: " << callCount << endl;
    };

    counter();  // 1
    counter();  // 2
    counter();  // 3

    // Original is UNCHANGED — the lambda modified its own copy
    cout << "Original callCount: " << callCount << endl;  // 0

    return 0;
}

Output:

C++
Lambda's internal count: 1
Lambda's internal count: 2
Lambda's internal count: 3
Original callCount: 0

Step-by-step explanation:

  1. Without mutable, the lambda’s copy of callCount is read-only. Attempting to modify it is a compile error: “cannot assign to a variable captured by copy in a non-mutable lambda.”
  2. With mutable, the lambda’s operator() is no longer const, so it can modify the captured copies. But these are the lambda’s own copies — changes do not propagate back to the original variable in the outer scope.
  3. Each call to counter() increments the lambda’s internal callCount. After three calls, the lambda’s copy holds 3. But the original callCount in main is still 0.
  4. A mutable captured-by-value variable essentially gives the lambda its own state that persists across calls — the lambda object itself is stateful. This is a powerful pattern for implementing stateful callbacks or generators.

Return Type Deduction and Explicit Return Types

The compiler deduces the return type of a lambda from the return statement. If there are no return statements, the return type is void. If there are multiple return statements, they must all return the same type (or types that are implicitly convertible).

For lambdas with complex or ambiguous return types, you can specify the return type explicitly with -> ReturnType.

C++
#include <iostream>
#include <string>
using namespace std;

int main() {
    // Return type deduced as int
    auto square = [](int x) { return x * x; };
    cout << square(5) << endl;  // 25

    // Return type deduced as string
    auto greet = [](const string& name) {
        return "Hello, " + name + "!";
    };
    cout << greet("World") << endl;

    // Explicit return type when deduction would be ambiguous
    auto safeDivide = [](double a, double b) -> double {
        if (b == 0.0) return 0.0;  // double
        return a / b;              // double — consistent
    };
    cout << safeDivide(10.0, 3.0) << endl;  // 3.33333
    cout << safeDivide(5.0, 0.0) << endl;   // 0

    // Explicit type needed when returning different numeric types
    auto classify = [](int x) -> string {
        if (x > 0) return "positive";
        if (x < 0) return "negative";
        return "zero";
    };
    cout << classify(42) << endl;
    cout << classify(-7) << endl;
    cout << classify(0) << endl;

    return 0;
}

Output:

Plaintext
25
Hello, World!
3.33333
0
positive
negative
zero

Step-by-step explanation:

  1. For square and greet, the return types (int and string) are unambiguous and deduced automatically.
  2. safeDivide returns double in both branches. Even though the deduction would work here, the explicit -> double makes the intent immediately clear to readers.
  3. classify returns string literals in all branches. The explicit -> string is not strictly necessary (all return expressions are const char* which convert to string), but it makes the return type of the lambda obvious at a glance and avoids subtle type conversion questions.

Lambdas with STL Algorithms

Lambdas shine brightest when combined with the Standard Template Library’s algorithms. They make algorithm-based code readable and concise.

C++
#include <iostream>
#include <vector>
#include <algorithm>
#include <numeric>
#include <string>
using namespace std;

int main() {
    vector<int> numbers = {15, 3, 42, 8, 27, 5, 19, 1, 33, 11};

    // --- std::sort with custom comparator ---
    sort(numbers.begin(), numbers.end(),
         [](int a, int b) { return a > b; });  // Descending order
    cout << "Sorted descending: ";
    for (int n : numbers) cout << n << " ";
    cout << endl;

    // --- std::find_if: find first number > 20 ---
    auto it = find_if(numbers.begin(), numbers.end(),
                      [](int n) { return n > 20; });
    if (it != numbers.end())
        cout << "First > 20: " << *it << endl;

    // --- std::count_if: count even numbers ---
    int evenCount = count_if(numbers.begin(), numbers.end(),
                             [](int n) { return n % 2 == 0; });
    cout << "Even numbers: " << evenCount << endl;

    // --- std::for_each: print with formatting ---
    cout << "All numbers: ";
    for_each(numbers.begin(), numbers.end(),
             [](int n) { cout << "[" << n << "] "; });
    cout << endl;

    // --- std::transform: square every element ---
    vector<int> squares(numbers.size());
    transform(numbers.begin(), numbers.end(), squares.begin(),
              [](int n) { return n * n; });
    cout << "Squared: ";
    for (int s : squares) cout << s << " ";
    cout << endl;

    // --- std::accumulate with lambda: sum of squares ---
    int sumOfSquares = accumulate(numbers.begin(), numbers.end(), 0,
                                  [](int acc, int n) { return acc + n * n; });
    cout << "Sum of squares: " << sumOfSquares << endl;

    // --- std::remove_if: remove numbers less than 10 ---
    numbers.erase(
        remove_if(numbers.begin(), numbers.end(),
                  [](int n) { return n < 10; }),
        numbers.end());
    cout << "After removing < 10: ";
    for (int n : numbers) cout << n << " ";
    cout << endl;

    return 0;
}

Output:

Plaintext
Sorted descending: 42 33 27 19 15 11 8 5 3 1
First > 20: 42
Even numbers: 2
All numbers: [42] [33] [27] [19] [15] [11] [8] [5] [3] [1]
Squared: 1764 1089 729 361 225 121 64 25 9 1
Sum of squares: 4388
After removing < 10: 42 33 27 19 15 11

Step-by-step explanation:

  1. sort with [](int a, int b) { return a > b; } sorts in descending order. The lambda acts as the comparator, returning true when a should come before b.
  2. find_if with [](int n) { return n > 20; } locates the first element satisfying the predicate. The result it is an iterator to that element.
  3. count_if counts elements where the predicate is true. The lambda [](int n) { return n % 2 == 0; } is the predicate.
  4. transform applies the lambda to each element and stores results in squares. Each element is replaced by its square.
  5. accumulate with a binary lambda builds a running total. The binary accumulator lambda takes the current accumulated value and the next element, returning the updated accumulation.
  6. The erase-remove idiom with remove_if removes elements satisfying the predicate. remove_if moves matching elements to the end and returns an iterator to where the removed region starts; erase then physically removes them.

Capturing this in Member Functions

When a lambda is defined inside a member function of a class, you can capture this to access the class’s member variables and methods.

C++
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

class DataProcessor {
private:
    vector<int> data_;
    int minValue_;
    int maxValue_;

public:
    DataProcessor(vector<int> data, int minVal, int maxVal)
        : data_(move(data)), minValue_(minVal), maxValue_(maxVal) {}

    // Lambda captures 'this' to access member variables
    vector<int> filter() const {
        vector<int> result;
        for_each(data_.begin(), data_.end(),
                 [this, &result](int val) {         // Capture this
                     if (val >= minValue_ && val <= maxValue_) {
                         result.push_back(val);
                     }
                 });
        return result;
    }

    // Lambda captures 'this' and modifies member data
    void normalize() {
        if (data_.empty()) return;

        int maxVal = *max_element(data_.begin(), data_.end());

        // [this] captures access to data_ member
        transform(data_.begin(), data_.end(), data_.begin(),
                  [this, maxVal](int val) -> int {
                      return (maxVal > 0) ? (val * 100 / maxVal) : 0;
                  });
    }

    void print() const {
        cout << "Data: ";
        for (int v : data_) cout << v << " ";
        cout << endl;
    }
};

int main() {
    DataProcessor proc({5, 12, 3, 18, 7, 25, 9, 14}, 5, 15);

    cout << "Original: ";
    proc.print();

    auto filtered = proc.filter();
    cout << "Filtered (5-15): ";
    for (int v : filtered) cout << v << " ";
    cout << endl;

    proc.normalize();
    cout << "Normalized: ";
    proc.print();

    return 0;
}

Output:

Plaintext
Original: Data: 5 12 3 18 7 25 9 14
Filtered (5-15): 5 12 7 9 14
Normalized: Data: 20 48 12 72 28 100 36 56

Step-by-step explanation:

  1. [this, &result] captures the current object pointer (this) and result by reference. Inside the lambda, minValue_ and maxValue_ are accessed as this->minValue_ and this->maxValue_ — but you can omit this-> since the capture makes all member variables available.
  2. [this, maxVal] in normalize captures this (for access to data_) and maxVal by value (a snapshot of the maximum value at the time of capture).
  3. In C++17, you can use [*this] to capture the entire object by value (making a copy), rather than by pointer. This is useful for asynchronous code where the object might be destroyed before the lambda runs.
  4. Capturing this in asynchronous or long-lived lambdas requires care: if the DataProcessor object is destroyed before the lambda runs, this becomes a dangling pointer.

Generic Lambdas (C++14): Auto Parameters

C++14 introduced generic lambdas, where parameters can be declared auto, making the lambda behave like a template function. The compiler generates a separate instantiation for each argument type.

C++
#include <iostream>
#include <vector>
#include <string>
#include <algorithm>
using namespace std;

int main() {
    // Generic lambda: works with any printable type
    auto print = [](auto value) {
        cout << value << endl;
    };

    print(42);
    print(3.14);
    print("hello");
    print(string("world"));

    // Generic comparator: works with any type supporting <
    auto lessThan = [](auto a, auto b) {
        return a < b;
    };

    cout << "3 < 5: " << lessThan(3, 5) << endl;
    cout << "'apple' < 'banana': " << lessThan(string("apple"), string("banana")) << endl;

    // Generic lambda for a max finder
    auto findMax = [](const auto& container) -> decltype(auto) {
        return *max_element(container.begin(), container.end());
    };

    vector<int> ints = {3, 1, 4, 1, 5, 9, 2, 6};
    vector<string> words = {"banana", "apple", "cherry", "date"};

    cout << "Max int: " << findMax(ints) << endl;
    cout << "Max word (alphabetically): " << findMax(words) << endl;

    // Generic transform
    auto doubleValue = [](auto x) { return x * 2; };

    vector<int> doubled(ints.size());
    transform(ints.begin(), ints.end(), doubled.begin(), doubleValue);
    cout << "Doubled ints: ";
    for (int v : doubled) cout << v << " ";
    cout << endl;

    vector<double> dvals = {1.5, 2.5, 3.5};
    vector<double> dDoubled(dvals.size());
    transform(dvals.begin(), dvals.end(), dDoubled.begin(), doubleValue);
    cout << "Doubled doubles: ";
    for (double v : dDoubled) cout << v << " ";
    cout << endl;

    return 0;
}

Output:

Plaintext
42
3.14
hello
world
3 < 5: 1
'apple' < 'banana': 1
Max int: 9
Max word (alphabetically): date
Doubled ints: 6 2 8 2 10 18 4 12
Doubled doubles: 3 5 7

Step-by-step explanation:

  1. auto print = [](auto value) { ... } is a generic lambda. When called with 42 (int), the compiler instantiates a version with int. When called with "hello" (const char*), it instantiates another. One lambda definition, multiple generated specializations.
  2. findMax uses const auto& to avoid copying the container and decltype(auto) to preserve the exact return type (a reference to the maximum element). This works for vector<int> and vector<string> with the same lambda.
  3. doubleValue works for both vector<int> and vector<double>. The x * 2 expression is instantiated as int * 2 and double * 2 respectively.
  4. Generic lambdas are essentially syntactic sugar for a template operator(). They are the predecessor to C++20’s abbreviated function templates and are extremely powerful for writing flexible, reusable inline operations.

Storing Lambdas: std::function

Lambda expressions have unique, unnamed types that the compiler generates. While you can use auto to store a lambda locally, if you need to store a lambda in a struct member, pass it through non-template APIs, or store different lambdas in a collection, you use std::function<ReturnType(ArgTypes...)>.

C++
#include <iostream>
#include <functional>
#include <vector>
#include <string>
using namespace std;

// Function that takes a callback
void processItems(const vector<int>& items,
                  function<void(int)> callback) {
    for (int item : items) {
        callback(item);
    }
}

// Event handler system using std::function
class Button {
public:
    string label;
    function<void()> onClick;  // Stores any callable with no args, no return

    Button(string lbl) : label(lbl) {}

    void click() {
        cout << "Button '" << label << "' clicked" << endl;
        if (onClick) onClick();  // Check if handler is set before calling
    }
};

int main() {
    vector<int> data = {1, 2, 3, 4, 5};

    // Pass lambda as std::function parameter
    processItems(data, [](int x) {
        cout << x * x << " ";
    });
    cout << endl;

    // Store different lambdas in same std::function variable
    function<int(int, int)> op;

    op = [](int a, int b) { return a + b; };
    cout << "Add: " << op(3, 4) << endl;

    op = [](int a, int b) { return a * b; };
    cout << "Multiply: " << op(3, 4) << endl;

    // std::function in a struct member — event system
    Button btn("Submit");
    int clickCount = 0;

    btn.onClick = [&clickCount, &btn]() {
        clickCount++;
        cout << "Handling click #" << clickCount
             << " for '" << btn.label << "'" << endl;
    };

    btn.click();
    btn.click();
    btn.click();

    // Collection of different operations
    vector<function<double(double)>> transformers = {
        [](double x) { return x * 2; },
        [](double x) { return x * x; },
        [](double x) { return x + 100; },
    };

    double value = 5.0;
    for (auto& transform : transformers) {
        cout << "Transformed " << value << " -> " << transform(value) << endl;
    }

    return 0;
}

Output:

Plaintext
1 4 9 16 25
Add: 7
Multiply: 12
Button 'Submit' clicked
Handling click #1 for 'Submit'
Button 'Submit' clicked
Handling click #2 for 'Submit'
Button 'Submit' clicked
Handling click #3 for 'Submit'
Transformed 5 -> 10
Transformed 5 -> 25
Transformed 5 -> 105

Step-by-step explanation:

  1. function<void(int)> is a type-erased wrapper that can store any callable that takes int and returns void. This includes lambdas, regular functions, functors, and std::bind expressions.
  2. function<int(int, int)> op demonstrates storing different lambdas in the same function variable by reassignment. The variable can hold any callable with the matching signature.
  3. Button::onClick is a function<void()> member. Any lambda, free function, or method with void() signature can be assigned to it. This is the classic observer/callback pattern.
  4. vector<function<double(double)>> transformers stores a heterogeneous collection of callables that all share the same signature. This enables runtime dispatch — choosing which operation to apply from a list.
  5. Performance note: std::function uses type erasure and may involve heap allocation for large lambda objects. For performance-critical inner loops, prefer auto or template parameters over std::function.

Lambda Comparison Table: Capture Modes

Capture SyntaxWhat It CapturesHowUse Case
[]NothingNo external variables needed
[x]Variable xBy value (copy)Read a snapshot; source may change
[&x]Variable xBy referenceModify original; need up-to-date value
[=]All used localsBy value (all)Convenience snapshot; multiple variables
[&]All used localsBy reference (all)Convenience; modify multiple originals
[=, &x]All by value; x by refMixedMost by snapshot, one needs write-back
[&, x]All by ref; x by valueMixedMost need write-back, one is read-only
[this]Current object pointerBy pointerAccess class members in member functions
[*this]Current objectBy value (copy)Safe for async; object may be destroyed

Advanced Patterns: Recursive Lambdas

A lambda cannot capture itself by name (since it doesn’t have one when the capture list is evaluated), but you can enable recursion using std::function or a Y-combinator pattern.

C++
#include <iostream>
#include <functional>
using namespace std;

int main() {
    // Method 1: Use std::function to enable self-reference
    function<int(int)> factorial = [&factorial](int n) -> int {
        return n <= 1 ? 1 : n * factorial(n - 1);
    };

    cout << "5! = " << factorial(5) << endl;
    cout << "10! = " << factorial(10) << endl;

    // Method 2: Pass the lambda to itself (C++14 generic lambda)
    auto fibonacci = [](auto self, int n) -> int {
        if (n <= 1) return n;
        return self(self, n - 1) + self(self, n - 2);
    };

    cout << "fib(10) = " << fibonacci(fibonacci, 10) << endl;

    return 0;
}

Output:

Plaintext
5! = 120
10! = 3628800
fib(10) = 55

Step-by-step explanation:

  1. Method 1: function<int(int)> factorial = [&factorial](int n) -> int { ... } stores the lambda in a named std::function variable and captures that variable by reference. Inside the lambda, factorial(n-1) is a valid recursive call because factorial is a named, in-scope std::function.
  2. Method 2: The generic lambda takes itself as the first argument (auto self). Calling fibonacci(fibonacci, 10) passes the lambda to itself, enabling recursion. This avoids std::function overhead but requires passing the lambda explicitly.
  3. Note that std::function carries overhead from type erasure. For high-performance recursive lambdas, Method 2 or a traditional named function may be preferable.

Immediately-Invoked Lambda Expressions (IILE)

One underappreciated lambda use case is the immediately-invoked lambda for complex initialization logic. C++ requires variables to be initialized in their declaration, but sometimes initialization logic is complex. A lambda called immediately can produce the value:

C++
#include <iostream>
#include <string>
#include <vector>
using namespace std;

int main() {
    // Complex initialization with an IILE
    const int config = []() {
        int val = 10;
        val *= 2;
        val += 5;
        return val;
    }();
    cout << "config = " << config << endl;  // 25

    // Initialize a const vector with computed values
    const vector<int> primes = []() {
        vector<int> result;
        for (int n = 2; n <= 30; n++) {
            bool isPrime = true;
            for (int i = 2; i * i <= n; i++) {
                if (n % i == 0) { isPrime = false; break; }
            }
            if (isPrime) result.push_back(n);
        }
        return result;
    }();

    cout << "Primes up to 30: ";
    for (int p : primes) cout << p << " ";
    cout << endl;

    // Conditional initialization without ternary nesting
    int x = 42;
    const string category = [x]() -> string {
        if (x < 0)   return "negative";
        if (x == 0)  return "zero";
        if (x < 10)  return "small";
        if (x < 100) return "medium";
        return "large";
    }();

    cout << x << " is " << category << endl;

    return 0;
}

Output:

Plaintext
config = 25
Primes up to 30: 2 3 5 7 11 13 17 19 23 29
42 is medium

Step-by-step explanation:

  1. const int config = []() { ... }() defines a lambda and calls it immediately. The const modifier means config cannot be changed later. The lambda provides the multi-step initialization logic cleanly.
  2. The primes vector is const, meaning it cannot be modified after initialization. Without the lambda, you would need a mutable intermediate vector and a const reassignment, or a helper function. The IILE initializes it in one clean expression.
  3. The category classification uses an IILE to select from multiple conditions without nesting ternary operators. The result is a const string initialized with exactly the right value.
  4. IILEs are particularly valuable for initializing const or constexpr variables with complex logic that cannot be expressed as a single expression.

Practical Example: A Flexible Event Pipeline

Let’s build a real-world example combining multiple lambda features into a practical event processing pipeline.

C++
#include <iostream>
#include <vector>
#include <functional>
#include <string>
#include <algorithm>
using namespace std;

struct Event {
    string type;
    int    value;
    string source;
};

class EventPipeline {
public:
    using Filter    = function<bool(const Event&)>;
    using Transform = function<Event(Event)>;
    using Handler   = function<void(const Event&)>;

    EventPipeline& addFilter(Filter f) {
        filters_.push_back(move(f));
        return *this;
    }

    EventPipeline& addTransform(Transform t) {
        transforms_.push_back(move(t));
        return *this;
    }

    EventPipeline& addHandler(Handler h) {
        handlers_.push_back(move(h));
        return *this;
    }

    void process(Event event) const {
        // Apply all filters
        for (const auto& filter : filters_) {
            if (!filter(event)) return;  // Event rejected
        }
        // Apply all transforms
        for (const auto& transform : transforms_) {
            event = transform(event);
        }
        // Deliver to all handlers
        for (const auto& handler : handlers_) {
            handler(event);
        }
    }

    void processAll(const vector<Event>& events) const {
        for (const auto& e : events) process(e);
    }

private:
    vector<Filter>    filters_;
    vector<Transform> transforms_;
    vector<Handler>   handlers_;
};

int main() {
    int processedCount = 0;
    int highValueCount = 0;

    EventPipeline pipeline;

    pipeline
        // Filter: only process "click" and "submit" events
        .addFilter([](const Event& e) {
            return e.type == "click" || e.type == "submit";
        })
        // Filter: ignore events with value < 0
        .addFilter([](const Event& e) {
            return e.value >= 0;
        })
        // Transform: normalize source to uppercase
        .addTransform([](Event e) {
            for (char& c : e.source) c = toupper(c);
            return e;
        })
        // Transform: amplify value for "submit" events
        .addTransform([](Event e) {
            if (e.type == "submit") e.value *= 10;
            return e;
        })
        // Handler: log every processed event
        .addHandler([&processedCount](const Event& e) {
            processedCount++;
            cout << "[LOG] " << e.type << " from " << e.source
                 << " value=" << e.value << endl;
        })
        // Handler: count high-value events
        .addHandler([&highValueCount](const Event& e) {
            if (e.value > 50) highValueCount++;
        });

    vector<Event> events = {
        {"click",  5,  "button_a"},
        {"hover",  1,  "menu"},        // Filtered out (not click/submit)
        {"submit", 8,  "form_b"},
        {"click",  -3, "button_c"},    // Filtered out (negative value)
        {"submit", 3,  "form_a"},
        {"click",  15, "button_b"},
    };

    pipeline.processAll(events);

    cout << "\nTotal processed: " << processedCount << endl;
    cout << "High-value events: " << highValueCount << endl;

    return 0;
}

Output:

Plaintext
[LOG] click from BUTTON_A value=5
[LOG] submit from FORM_B value=80
[LOG] submit from FORM_A value=30
[LOG] click from BUTTON_B value=15

Total processed: 4
High-value events: 1

Step-by-step explanation:

  1. The pipeline stores std::function collections for filters, transforms, and handlers. Each stage is added via a fluent interface (addFilter(...).addTransform(...)...), returning *this for chaining.
  2. Filters are lambdas that return bool. Two filters are applied in sequence: type check and value check. If either returns false, the event is dropped.
  3. Transforms are lambdas that take and return an Event. The source is uppercased in the first transform; submit values are amplified in the second. Events are modified as they flow through.
  4. Handlers are lambdas that receive the final Event. The log handler captures processedCount by reference and increments it. The count handler captures highValueCount by reference.
  5. The “hover” event is filtered out by the type filter. The negative-value click is filtered out by the value filter. Four events pass all filters and are processed.
  6. This pattern demonstrates real-world lambda usage: lambdas stored as std::function, lambda state via captures, lambda composition through multiple stages, and fluent API design with lambdas as building blocks.

Common Mistakes with Lambdas

Mistake 1: Dangling Reference Capture

C++
function<int()> makeCounter() {
    int count = 0;
    return [&count]() { return ++count; };  // BAD: count is destroyed on return
}

// SAFE: capture by value
function<int()> makeCounter() {
    int count = 0;
    return [count]() mutable { return ++count; };  // OK: copy lives in lambda
}

Never capture local variables by reference in a lambda that outlives the enclosing scope.

Mistake 2: Capturing this in Async Lambdas

C++
class Widget {
    void startAsync() {
        // BAD: Widget may be destroyed before async work completes
        async_do_work([this]() { use(member_); });

        // SAFER: capture a shared_ptr to keep Widget alive
        auto self = shared_from_this();
        async_do_work([self]() { self->use(self->member_); });
    }
};

Mistake 3: Accidentally Copying Large Objects

C++
vector<int> bigData(1'000'000, 0);

// BAD: copies the entire vector into the lambda
auto process = [bigData]() { ... };

// GOOD: capture by reference (if lambda doesn't outlive bigData)
auto process = [&bigData]() { ... };

// GOOD: move into lambda (transfers ownership)
auto process = [data = move(bigData)]() { ... };

Mistake 4: Overusing std::function for Performance-Critical Paths

std::function adds overhead from type erasure and potential heap allocation. In tight loops, prefer auto with template parameters:

C++
// Slow (std::function overhead in every call)
void process(vector<int>& v, function<int(int)> f) { ... }

// Fast (zero overhead — inlined by compiler)
template<typename Func>
void process(vector<int>& v, Func f) { ... }

Conclusion

Lambda expressions are one of the most transformative features of modern C++. They allow you to express small, focused pieces of behavior exactly where they are needed — without the ceremony of separate function definitions or functor classes. The result is code that is more readable, more maintainable, and often more efficient.

The capture list is what elevates lambdas beyond simple anonymous functions. By selectively capturing variables by value or by reference, a lambda becomes a powerful closure that can carry state, observe external variables, and modify its environment — all while maintaining clear ownership semantics.

Used with STL algorithms, lambdas transform verbose loop-based code into concise, expressive pipelines. Used with std::function, they enable flexible callback and event systems. Used as immediately-invoked expressions, they initialize complex const variables cleanly. Used as generic lambdas in C++14, they provide template-level flexibility with minimal syntax.

Mastering lambdas — their syntax, capture modes, mutable behavior, storage options, and best practices — is an essential step in writing fluent, idiomatic modern C++. Once they become part of your natural programming vocabulary, you will wonder how you ever wrote C++ without them.

Share:
Subscribe
Notify of
0 Comments
Inline Feedbacks
View all comments

Discover More

Understanding System Updates: Why They Matter and How They Work

Learn why operating system updates are crucial for security, performance, and features. Discover how updates…

Arduino Boards: Uno, Mega, Nano, and More

Learn about different Arduino boards, including Uno, Mega, Nano, and more. Discover their features, use…

Introduction to Robotics: A Beginner’s Guide

Learn the basics of robotics, its applications across industries, and how to get started with…

What Is a System Call and How Do Programs Talk to the Operating System?

Learn what system calls are and how programs interact with the operating system. Understand the…

Getting Started with Robotics Programming: An Introduction

Learn the basics of robotics programming, from selecting languages to integrating AI and autonomous systems…

Intel Debuts Revolutionary Core Ultra Series 3 Processors at CES 2026 with 18A Manufacturing Breakthrough

Intel launches Core Ultra Series 3 processors at CES 2026 with groundbreaking 18A technology, delivering…

Click For More
0
Would love your thoughts, please comment.x
()
x