Demystifying C++ Lambdas:They’re Basically Classes Under the Hood

Hey there!! If you've been messing around with C++11 or later, you've likely used lambda functions. They're super handy for writing quick, inline functions without all the boilerplate of regular functions or functors. While tinkering on a side project, I hit a wall when I couldn't modify a variable captured by value using [=]. This blog dives into my journey to understand why that happened. Spoiler alter: Under the covers, the compiler turns them into classes. Yeah, you heard that right, actual classes!!!!.

A Quick Refresher: What Are Lambdas in C++?

Before we get into the internals, let’s recall what a lambda looks like. Introduced in C++11, a lambda expression is an anonymous function object that can capture variables from its surrounding scope. The basic syntax is:

    [ captures ] ( parameters ) specifiers -> return_type { body }
  • Captures: How the lambda grabs variables from outside (e.g., [=] for by-value, [&] for by-reference).
  • Parameters: Similar to function agruments (optional).
  • Specifiers: Things like mutable or constexpr (more on these later).
  • Return Type: Trailing return type (optional; compiler deduces if omitted).
  • Body: The code to execute.

Example

    auto add = [](int a, int b) { return a + b; };
    std::cout << add(3, 4);  // Outputs: 7

Lambdas as Compiler-Generated Classes

At compile time, the C++ compiler translates your lambda into an unnamed class. This class:

  1. Has Data Members for Captures: Each captured variable becomes a private member variable in the class.
  2. Overloads operator(): This makes the object callable like a function.
  3. May Inherit or Use Templates: For generic lambdas or other features.
  4. Is Unique Per Lambda: Each lambda expression generates its own type, even if they look identical.

Examples

  1. A simple lamda function to compute the product of two numbers
#include <iostream>

int main()
{
    int x =10, y=20;
    auto product = [x,y]() -> int{
        return x * y;
    };
    std::cout << product();
}

Lets inspect the above code from the eyes of a complier

#include <iostream>

int main()
{
    int x = 10;
    int y = 20;
    
    class __lambda_7_20
    {
        public: 
            inline /*constexpr */ int operator()() const
            {
                return x * y;
            }
        
        private: 
            int x;
            int y;
        
        public:
            __lambda_7_20(int & _x, int & _y): x{_x}, y{_y} {} 
    };
    
    __lambda_7_20 product = __lambda_7_20{x, y};
    std::cout.operator<<(product.operator()());
    return 0;
}

Key points:

  • The class is local to the scope where the lambda is defined.
  • If no captures, it’s like a stateless functor (A functor or a function object is an object of a class or a struct that can be called like a function - Achieved through overloading () operator).
  1. Lets tweak the above example by having the lambda function take in a parameter
#include <iostream>

int main()
{
    int x =10, y=20;
    auto bodmas = [x,y](auto z) -> int{
        return x * y + z;
    };
    std::cout << bodmas(100);
}

From the eyes of a compiler:

#include <cstdio>
#include <iostream>

int main()
{
  int x = 10;
  int y = 20;
    
  class __lambda_7_19
  {
    public: 
    template<typename type_parameter_0_0>
    inline /*constexpr */ int operator()(type_parameter_0_0 z) const
    {
      return (x * y) + z;
    }
    
    // Template instantiation 
    #ifdef INSIGHTS_USE_TEMPLATE
    template<>
    inline /*constexpr */ int operator()<int>(int z) const
    {
      return (x * y) + z;
    }
    #endif
    
    private: 
    int x;
    int y;
    
    public:
    __lambda_7_19(int & _x, int & _y): x{_x}, y{_y}{}    
  };
  
  __lambda_7_19 bodmas = __lambda_7_19{x, y};
  std::cout.operator<<(bodmas.operator()(100));
  return 0;
}

It can be seen that the parameter z of type auto is deduced to the template type parameter T.

The Main Topic: The Modification Error

This is where it gets interesting, and honestly, the heart of why understanding the class model matters. When you capture by value ([=] or [var]), the compiler copies the variable into a class member. But because operator() is const by default, that member is treated as read-only inside the lambda body. Trying to modify it? Compiler says no. Let's take the same example of multiplying two numbers.

int main()
{
   int x =10, y=20;
   auto product_tweaked = [=]() -> int{
       x = x+1; // error
       return x * y;
   };
   std::cout << product_tweaked();
}

The complier sees it as:

#include <iostream>

int main()
{
 int x = 10;
 int y = 20;
   
 class __lambda_7_20
 {
   public: 
       inline /*constexpr */ int operator()() const // const locks it down
       {
           x = x + 1; // modification prohibited in a const method :)
           return x * y;
       }
   
   private: 
       int x;
       int y;
       
   public:
    __lambda_7_20(int & _x, int & _y): x{_x}, y{_y} {}  
 };
 
 __lambda_7_20 product = __lambda_7_20{x, y};
 std::cout.operator<<(product.operator()());
 return 0;
}

The const qualifier means the method promises not to change the object's state. Since x is a member, incrementing it violates that.

The Fix: Add mutable

Slap on mutable after the params, and it removes the const from operator():

 int main()
{
    int x =10, y=20;
    auto product_tweaked = [=]() mutable -> int{
        x = x+1; // can successfully modify
        return x * y;
    };
    std::cout << product_tweaked();
}
#include <iostream>

int main()
{
  int x = 10;
  int y = 20;
    
  class __lambda_7_28
  {
    public: 
    inline /*constexpr */ int operator()() // No const – free to modify
    {
      x = x + 1;
      return x * y;
    }
    
    private: 
    int x;
    int y;
    
    public:
    __lambda_7_28(int & _x, int & _y): x{_x}, y{_y}{}
    
  };
  
  __lambda_7_28 product_tweaked = __lambda_7_28{x, y};
  std::cout.operator<<(product_tweaked.operator()());
  return 0;
}

Boom, it works. You're modifying the copy inside the lambda's "object," not the original variable.

Takeaways

  • Mixing: [=] with mutable lets you tweak all by-value copies.
  • mutable only affects by-value captures. By-reference captures ([&]) are always modifiable because they're refs/pointers to the original – no const issue there (you are not modifying the actual reference).
  • Standards note: This has been in C++11 from the start. Check expr.prim.lambda in the standard for the gory details.