In-Depth Understanding of C++ Lambda Expressions: Reference Capture Principles, Usage, and Potential Pitfalls

In-Depth Understanding of C++ Lambda Expressions: Reference Capture Principles, Usage, and Potential PitfallsIn-Depth Understanding of C++ Lambda Expressions: Reference Capture Principles, Usage, and Potential PitfallsClick the blue text to follow the author

1. Introduction

C++ Lambda expressions allow for the inline definition of anonymous functions within code, resulting in a more compact and readable code structure. Lambda expressions are not just syntactic sugar; they also introduce the concept of capture lists, enabling Lambdas to access variables from their defining scope, thus achieving the “closure” characteristic of the external environment.

The capture list is a core component of lambda expressions, defining how a lambda accesses variables from its external scope. The capture list provides various capture methods, including value capture, reference capture, and mixed capture. Among these, <span>[&]</span> is the most important form, allowing the lambda expression to access all variables used in its closure by reference.

This article reveals that <span>[&]</span> does not literally mean “capture all” variables, but rather how the compiler generates closure types and accesses member variables by reference to achieve access to external variables. It analyzes the characteristics of <span>[&]</span> capture and compares it with <span>[=]</span> capture.

2. Principles of Reference Capture

When first encountering C++ Lambda expressions, one might think that <span>[&]</span> captures “all” variables within the lambda’s defining scope. The reality is that it does not do so.

<span>[&]</span>‘s true explanation is “capture by reference all variables used in the lambda body“. The compiler does not naively capture all variables in the scope into the Lambda. Instead, it automatically identifies which variables are actually used in the lambda function body and captures only those.

This “by reference” characteristic is the core of <span>[&]</span> capture:

  • Direct access to original variables: The lambda accesses captured variables directly, operating on the original variables in the scope rather than their copies.
  • Since it is by reference, it avoids the overhead of data copying.
  • Changes to external variables are immediately reflected inside the lambda, and vice versa. Modifications to captured variables inside the lambda directly affect the original external variables.

How does the compiler implement <span>[&]</span> capture?

To understand the internal mechanism of <span>[&]</span> capture, one must know what the compiler does behind the scenes for lambda expressions.

When the compiler encounters a lambda expression:

When the compiler sees [&]:

  1. The compiler generates a unique, anonymous class (called a “closure type” or “lambda closure”) for each lambda expression. This anonymous class has an overloaded <span>operator()</span> function, whose body is the function body of the lambda expression.
  2. For the external local variables actually used in the lambda body, captured by <span>[&]</span>, the compiler adds corresponding reference-type member variables to this anonymous class.
  3. Access to these captured variables inside the lambda is fundamentally achieved by accessing the corresponding reference member variables of this anonymous class. Thus, when the lambda expression is created, these reference member variables are initialized to point to the original variables in the external scope.
In-Depth Understanding of C++ Lambda Expressions: Reference Capture Principles, Usage, and Potential Pitfalls
Insert image description here

For example:

#include <iostream>
#include <functional>

int main() 
{
    int x = 10;
    int y = 20;
    int z = 30;

    // 1. Define a lambda using [&] capture
    auto myLambda = [&]() {
        std::cout << "Inside lambda: x = " << x << std::endl;
        x += y;
    };

    // 2. Call the lambda
    myLambda();

    // 3. Observe the change in x
    std::cout << "Outside lambda: x = " << x << std::endl;
    std::cout << "Outside lambda: y = " << y << std::endl;
    std::cout << "Outside lambda: z = " << z << std::endl;

    return 0;
}

Here, I recommend a fantastic tool that allows you to view C++ source code “through the eyes of the compiler”.

cppinsights (https://cppinsights.io/): A very intuitive display of how C++ language “syntactic sugar” is expanded by the compiler, which is very useful for understanding the underlying mechanisms.

In-Depth Understanding of C++ Lambda Expressions: Reference Capture Principles, Usage, and Potential Pitfalls
Insert image description here

Using the cppinsights tool to display the code expanded by the compiler:

#include <iostream>
#include <functional>

int main()
{
    int x = 10;
    int y = 20;
    int z = 30;
        
    class __lambda_11_21
    {
        public: 
        inline/*constexpr */void operator()() const
        {
        std::operator<<(std::cout, "Inside lambda: x = ").operator<<(x).operator<<(std::endl);
        x = x + y;
        }
        
        private: 
        int & x;
        int & y;
        
        public:
        __lambda_11_21(int & _x, int & _y)
        : x{_x}
        , y{_y}
        {}
        
    };
    
    __lambda_11_21 myLambda = __lambda_11_21{x, y};
    myLambda.operator()();

    std::operator<<(std::cout, "Outside lambda: x = ").operator<<(x).operator<<(std::endl);
    std::operator<<(std::cout, "Outside lambda: y = ").operator<<(y).operator<<(std::endl);
    std::operator<<(std::cout, "Outside lambda: z = ").operator<<(z).operator<<(std::endl);

    return 0;
}

It is very clear that the compiler only captured the variables <span>x, y</span>, while the unused variable <span>z</span> was not captured at all.

Not only is the <span>[&]</span> capture like this, but the <span>[=]</span> capture is similar.

3. Differences Between <span>[&]</span> and <span>[=]</span>

The <span>[&]</span> and <span>[=]</span> captures are the two most commonly used capture methods.

Unlike the reference capture of <span>[&]</span>, the meaning of <span>[=]</span> capture is “capture by value all variables used in the lambda body“.

  • When the lambda expression is defined, for the external local variables actually used in the lambda body, the compiler creates an independent copy of them and stores them in the generated anonymous closure type.
  • Access and modification of these captured variables inside the lambda only affect their own copies and do not impact the original external variables.
  • Unless the lambda expression is declared as <span>mutable</span>, the variables captured by <span>[=]</span> are constant inside the lambda (i.e., cannot be modified). This is because the lambda’s <span>operator()</span> is by default a <span>const</span> member function, and <span>const</span> member functions cannot modify non-<span>mutable</span> member variables.

Example:

#include <iostream>

int main() {
    int a = 10;
    int b = 20; // b is not used in the lambda

    // 1. Define a lambda using [=] capture
    auto myLambda = [=]() {
        // Only a is used in the lambda body
        std::cout << "Inside lambda: a = " << a << std::endl;
        // a++; // Compilation error: by default, captured variables by value are constant
    };

    // 2. Define a lambda using [=] capture with mutable keyword
    auto mutableLambda = [=]() mutable {
        std::cout << "Inside mutable lambda (before modification): a = " << a << std::endl;
        a++; // Can modify the captured copy
        std::cout << "Inside mutable lambda (after modification): a = " << a << std::endl;
    };

    // 3. Call the lambda
    myLambda();
    mutableLambda();
    mutableLambda(); // Call again

    // 4. Observe changes in a and b
    std::cout << "Outside lambda: a = " << a << std::endl; // Original a remains unchanged
    std::cout << "Outside lambda: b = " << b << std::endl; // b remains unchanged

    return 0;
}

The corresponding code generated by the compiler is as follows:

#include <iostream>

int main()
{
int a = 10;
int b = 20;
    
class __lambda_8_21
  {
    public: 
    inline/*constexpr */void operator()() const
    {
      std::operator<<(std::cout, "Inside lambda: a = ").operator<<(a).operator<<(std::endl);
    }
    
    private: 
    int a;
    
    public:
    __lambda_8_21(int & _a)
    : a{_a}
    {}
    
  };
  
  __lambda_8_21 myLambda = __lambda_8_21{a};
    
class __lambda_15_26
  {
    public: 
    inline/*constexpr */void operator()()
    {
      std::operator<<(std::cout, "Inside mutable lambda (before modification): a = ").operator<<(a).operator<<(std::endl);
      a++;
      std::operator<<(std::cout, "Inside mutable lambda (after modification): a = ").operator<<(a).operator<<(std::endl);
    }
    
    private: 
    int a;
    
    public:
    __lambda_15_26(int & _a)
    : a{_a}
    {}
    
  };

  __lambda_15_26 mutableLambda = __lambda_15_26{a};
  myLambda.operator()();
  mutableLambda.operator()();
  mutableLambda.operator()();
std::operator<<(std::cout, "Outside lambda: a = ").operator<<(a).operator<<(std::endl);
std::operator<<(std::cout, "Outside lambda: b = ").operator<<(b).operator<<(std::endl);
return 0;
}

It is clear that the compiler only captured the variable <span>a</span>, while the unused variable <span>b</span> was not captured at all.

The core differences between <span>[&]</span> and <span>[=]</span> captures are as follows:

Feature <span>[&]</span> (Default Reference Capture) <span>[=]</span> (Default Value Capture)
Capture Method Reference capture (<span>int& _var;</span>) Value capture (<span>int _var;</span>)
Impact on Original Variable Modifications inside the lambda directly affect the original external variable. Modifications inside the lambda only affect the captured copy, not the original external variable.
Performance Consideration No copying overhead, higher performance, especially suitable for large objects. There is copying overhead, which may lead to performance degradation for large objects.
Lifetime Risk of dangling reference: If the lambda’s lifetime exceeds that of the captured variable, it may lead to runtime errors. Relatively safe: Captures a copy, independent of the original variable’s lifetime. However, if capturing pointers or references, one must still be cautious of the lifetime of the pointed or referenced objects.
Default Constness Captured references can modify the original variable. By default, captured copies are constant and cannot be modified (unless using <span>mutable</span> keyword).
Applicable Scenarios When modification of external variables is needed; pursuing extreme performance; ensuring the external variable’s lifetime is longer than the lambda. When the lambda needs to be independent of the external variable’s state; when the lambda may execute after the external variable is destroyed; when passing the lambda as a parameter to asynchronous operations or threads.

Choosing <span>[&]</span> Scenarios:

  • When the lambda needs to directly modify variables within its defining scope, <span>[&]</span> is the only choice.
  • For large objects or complex data structures, avoiding copying overhead can enhance performance.
  • Ensure the external variable’s lifetime is longer than the lambda: This is a prerequisite for using <span>[&]</span>.

Choosing <span>[=]</span> Scenarios:

  • When the lambda needs to be independent of the external variable’s state.
  • When the lambda’s lifetime may exceed that of the captured variable: This is the most common safety consideration.
  • When the captured variables are small objects or basic types.

Mixed Capture and Explicit Capture:

  • <span>[=, &x]</span>: Default value capture, but <code><span>x</span> is captured by reference.
  • <span>[&, y]</span>: Default reference capture, but <code><span>y</span> is captured by value.
  • <span>[a, &b]</span>: <code><span>a</span> is captured by value, <span>b</span> is captured by reference.

4. Potential Pitfalls of <span>[&]</span> Capture

<span>[&]</span> capture allows direct access to external variables, but it also comes with a serious potential risk: dangling references.

A dangling reference refers to a situation where a reference (or pointer) points to a memory area that has already been released or is no longer valid, yet the reference (or pointer) itself still exists and attempts to access this invalid memory. Accessing data through a dangling reference can lead to undefined behavior, manifesting as program crashes, data corruption, unpredictable results, or even appearing normal but having serious security vulnerabilities.

<span>[&]</span> capture is essentially “capture by reference”, so the lambda does not store copies of the variables but rather references to the original variables. If the lifetime of the lambda expression (i.e., the time it is called or executed) exceeds that of the local variables it captures, then when the lambda tries to access these variables, they may no longer exist, leading to dangling references.

Typical scenarios include:

  1. Lambda as return value or stored externally: When a function returns a lambda that captures local variables, or stores such a lambda in a class member variable, and that lambda is called after the local variable’s lifetime has ended.
  2. Lambda passed to asynchronous operations: If the asynchronous operation executes the lambda after the original local variable’s function has exited, it will have issues.
  3. Putting a lambda that captures local variables into an STL container, where the container’s lifetime exceeds that of the local variables.

Example:

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

// Example 1: Function returns a lambda that captures a local variable
std::function<void()> createLambdaWithDanglingReference() 
{
    int local_var = 100; // Local variable, lifetime limited to this function
    std::cout << "Inside createLambda: local_var address = " << &local_var << std::endl;

    // Warning: here [&] captures the reference of local_var
    // When createLambdaWithDanglingReference function returns, local_var will be destroyed
    auto myLambda = [&]() {
        // Attempt to access local_var, which may no longer exist
        std::cout << "Inside returned lambda: local_var = " << local_var << std::endl;
    };

    return myLambda; // Return the lambda
}

// Example 2: Asynchronous execution leading to dangling reference
void asyncOperation(std::function<void()> task) 
{
    // Simulate asynchronous execution, could be a new thread or event loop
    std::cout << "Starting async operation..." << std::endl;
    // In actual applications, there may be delays or execution in another thread
    task(); // Execute the lambda that captured a dangling reference
    std::cout << "Async operation finished." << std::endl;
}

int main() 
{
    // --- Demo 1: Returning lambda leading to dangling reference ---
    std::cout << "--- Demo 1: Returning Lambda ---" << std::endl;
    std::function<void()> danglingLambda = createLambdaWithDanglingReference();
    // At this point, local_var has already gone out of scope and been destroyed

    std::cout << "Calling danglingLambda..." << std::endl;
    danglingLambda(); // Undefined behavior occurs! May print garbage values or crash the program
    std::cout << "After calling danglingLambda." << std::endl;

    std::cout << "\n--- Demo 2: Async Operation ---" << std::endl;
    int data = 42; // Local variable
    std::cout << "Main: data address = " << &data << std::endl;

    // Capture data by reference
    auto asyncTask = [&]() {
        std::cout << "Inside asyncTask: data = " << data << std::endl;
    };

    // Assume asyncOperation executes immediately, but if it is asynchronous,
    // and the main function ends before asyncTask executes, data will be destroyed
    // For demonstration, we assume asyncOperation is synchronous,
    // but imagine if asyncOperation started a thread and returned immediately...
    asyncOperation(asyncTask);

    // If asyncOperation is asynchronous, and the main function ends here,
    // then asyncTask will be executed in the future, and data will be a dangling reference.
    // To avoid this undefined behavior, it is common to use [=] capture or smart pointers.

    std::cout << "Main function ending." << std::endl;

    return 0;
}

Output:

--- Demo 1: Returning Lambda ---
Inside createLambda: local_var address = 0x7ffdb418b3ec
Calling danglingLambda...
Inside returned lambda: local_var = 23655
After calling danglingLambda.

--- Demo 2: Async Operation ---
Main: data address = 0x7ffdb418b414
Starting async operation...
Inside asyncTask: data = 42
Async operation finished.
Main function ending.

To avoid dangling references caused by <span>[&]</span><span>, the key is to ensure that the lifetime of the captured variables is at least as long as that of the lambda expression.</span>

  1. Prefer using <span>[=]</span> capture (by value): If the lambda does not modify external variables and the variables are small objects or basic types, then choosing <span>[=]</span><span> is best.</span>
  2. For only a few variables, explicitly capture by value. Use reference capture only when the variables to be modified have a sufficiently long lifetime.
  3. Use smart pointers: If the lambda needs to capture objects on the heap and the object’s lifetime needs to be managed, use smart pointers <span>std::shared_ptr</span> or <span>std::unique_ptr</span>.
  4. If the lambda is only called immediately within the function that defines it and will not be passed out or stored, then using <span>[&]</span><span> is fine.</span>
  5. Avoid capturing references to temporary objects or function parameters.

If unsure whether the lambda’s lifetime exceeds that of the captured local variables, absolutely do not use <span>[&]</span><span> to capture local variables; it is better to use </span><code><span>[=]</span><span> capture or smart pointers.</span> This is a very important safety principle in C++ lambda programming.

5. Conclusion

<span>[=]</span> capture (by value capture) provides independence and safety by creating independent copies of external variables for the lambda expression. It ensures that any operations on variables inside the lambda do not affect the original external variables and avoids dangling reference issues.

<span>[&]</span> capture (by reference capture) allows the lambda to directly read and modify the original external variables without copying overhead. This method is very efficient, but <span>[&]</span><span> capture's greatest risk is the </span><strong><span>dangling reference risk</span></strong><span>. If the captured local variables are destroyed before the lambda expression executes, accessing these invalid memory locations through references will lead to undefined behavior.</span>

Previous tutorials:

C++ Training Camp: Customized Improvement Plan + Code Review to Aid Growth

C++

STL Algorithms: Master these, and your code efficiency will increase tenfold!

Building

Using Google Test for C++ Unit Testing

Applications

Thoroughly Understanding Asynchronous and Multithreading: Concepts, Principles, and Application Scenario Comparisons

In-Depth Understanding of C++ Lambda Expressions: Reference Capture Principles, Usage, and Potential PitfallsLion Lian Welcome to follow my public account for learning technology or submissions

Leave a Comment