Both C++ and C# have lambdas, but they have quite a few differences. Today we’ll go into how C++ lambdas work, including all their features and how they compare and contrast with C# lambdas. Read on to learn all the details!

Table of Contents

Basic Syntax

Syntactically, lambdas look different in C++ than they do in C#. First, there’s no equivalent to C#’s “expression lambdas:” (arg1, arg2, ...) => expr. C++ only has the equivalent of C#’s “statement lambdas:” (arg1, arg2, ...) => { stmnt1; stmnt2; ... }. In their simplest form, they look like this:

[]{ DebugLog("hi"); }

The first part ([]) is the list of captures, which we’ll go into deeply in a bit. The second part ({ ... }) is the list of statements to execute when the lambda is invoked.

Now let’s add an arguments list:

[](int x, int y){ return x + y; }

Besides the capture list ([]) and the omission of an => after the arguments list, this now looks just like a C# lambda. In the first form that omitted the arguments list, the lambda simply takes no arguments.

Note that, unlike all the named functions we’ve seen so far, there’s no return type stated here. The return type is implicitly deduced by the compiler by looking at the type of our return statements. That’s just like we’ve seen before when declaring functions with an auto return type or what we get in C#.

If we’d rather explicitly state the return type, we can do so with the “trailing” return type syntax:

[](int x, int y) -> int { return x + y; }

Like normal functions, we can also take auto-typed arguments:

[](auto x, auto y) -> auto { return x + y; }
[](auto x, auto y) { return x + y; } // Trailing return type is optional
[](auto x, int y) { return x + y; } // Not every argument has to be auto
Lambda Types

So what type does a lambda expression have? In C#, we get a type that can be converted to a delegate type like Action or Func<int, int, int>. In C++, the compiler generates an unnamed class. It looks like this:

// Compiler-generated class for this lambda:
//   [](int x, int y) { return x + y; }
// Not actually named LambdaClass
class LambdaClass
{
    // Lambda body
    // Not actually named LambdaFunction
    static int LambdaFunction(int x, int y)
    {
        return x + y;
    }
 
public:
 
    // Default constructor
    // Only if no captures
    LambdaClass() = default;
 
    // Copy constructor
    LambdaClass(const LambdaClass&) = default;
 
    // Move constructor
    LambdaClass(LambdaClass&&) = default;
 
    // Destructor
    ~LambdaClass() = default;
 
    // Function call operator
    int operator()(int x, int y) const
    {
        return LambdaFunction(x, y);
    }
 
    // User-defined conversion function to function pointer
    // Only if no captures
    operator decltype(&LambdaFunction)() const noexcept
    {
        return LambdaFunction;
    }
};

Since it’s just a normal class, we can use it like a normal class. The only difference is that we don’t know its name, so we have to use auto for its type:

void Foo()
{
    // Instantiate the lambda class. Equivalent to:
    //   LambdaClass lc;
    auto lc = [](int x, int y){ return x + y; };
 
    // Invoke the overloaded function call operator
    DebugLog(lc(200, 300)); // 500
 
    // Invoke the user-defined conversion operator to get a function pointer
    int (*p)(int, int) = lc;
    DebugLog(p(20, 30)); // 50
 
    // Call the copy constructor
    auto lc2{lc};
    DebugLog(lc2(2, 3)); // 5
 
    // Destructor of lc and lc2 called here
}
Default Captures

So far, our lambdas have always had an empty list of captures: []. In C#, captures are always implicit. In C++, we have much more control over what we capture and how we capture it.

To start, let’s look at the most C#-like kind of capture: [&]. This is a “capture default” that says to the compiler “capture everything the lambda uses as a reference.” Here’s how it looks:

// Something outside the lambda
int x = 123;
 
// Default capture mode set to "by reference"
auto addX = [&](int val)
{
    // Lambda references "x" that's outside the lambda
    // Compiler captures "x" by reference: int&
    return x + val;
};
 
DebugLog(addX(1)); // 124

We can see that x is captured by reference by modifying x after we capture it:

int x = 123;
 
// Capture reference to x, not a copy of x
auto addX = [&](int val) { return x + val; };
 
// Modify x after the capture
x = 0;
 
// Invoke the lambda
// Lambda uses the reference to x, which is 0
DebugLog(addX(1)); // 1

If we don’t like this behavior, we can switch the “capture default” to [=] which means “capture everything the lambda uses as a copy.” Here’s how that looks:

int x = 123;
 
// Capture a copy of x, not a reference to x
auto addX = [=](int val) { return x + val; };
 
// Modify x after the capture
// Does not modify the lambda's copy
x = 0;
 
// Invoke the lambda
// Lambda uses the copy of x, which is 123
DebugLog(addX(1)); // 124

While it’s deprecated starting with C++20, it’s important to note that [=] can implicitly capture a reference to the current object: *this. Here’s one way that happens:

struct CaptureThis
{
    int Val = 123;
 
    auto GetLambda()
    {
        // Default capture mode is "copy"
        // Lambda uses "this" which is outside the lambda
        // "this" is copied to a CaptureThis*
        return [=]{ DebugLog(this->Val); };
    }
};
 
auto GetCaptureThisLambda()
{
    // Instantiate the class on the stack
    CaptureThis ct{};
 
    // Get a lambda that's captured a pointer to "ct"
    auto lambda = ct.GetLambda();
 
    // Return the lambda. Calls the destructor for "ct".
    return lambda;
}
 
void Foo()
{
    // Get a lambda that's captured a pointer to "ct" which has had its
    // destructor called and been popped off the stack
    auto lambda = GetCaptureThisLambda();
 
    // Dereference that captured pointer to "ct"
    lambda(); // Undefined behavior: could do anything!
}

This example happened to create a “dangling” pointer to this, but the same can happen with any other pointer or reference. It’s important to make sure that captured pointers and references don’t end their lifespan before the lambda does!

Individual Captures

The next kind of element we can add to a capture list is called an “individual capture” since it captures something specific from outside the lambda.

There are a few forms of individual capture. First up, we can simply put a name:

int x = 123;
 
// Individually capture "x" by copy
auto addX = [x](int val)
{
    // Use the copy of "x"
    return x + val;
};
 
// Modify "x" after the capture
x = 0;
 
DebugLog(addX(1)); // 124

If we want to initialize the captured copy, we can add any of the usual forms of initialization:

int x = 123;
 
// Individually capture "x" by copying it to a variable named "a"
auto addX = [a = x](int val)
{
    // Use the copy of "x" via the "a" variable
    return a + val;
};
 
// Modify "x" after the capture
x = 0;
 
DebugLog(addX(1)); // 124

The captured variable can even have the same name as what it captures, similar to when we used just [x]:

[x = x](int val){ return x + val; };

Other initialization forms are also available. Here are a couple:

[a{x}](int val){ return a + val; };
[a(x)](int val){ return a + val; };

In contrast, we can individually capture by reference:

int x = 123;
 
// Individually capture "x" by reference
auto addX = [&x](int val)
{
    // Use the reference to "x"
    return x + val;
};
 
// Modify "x" after the capture
x = 0;
 
DebugLog(addX(1)); // 1

We can initialize individually-captured references, too:

int x = 123;
 
// Individually capture "x" by reference as a reference named "a"
auto addX = [&a = x](int val)
{
    // Use the reference to "x" via "a"
    return a + val;
};
 
// Modify "x" after the capture
x = 0;
 
DebugLog(addX(1)); // 1

Regardless of whether we capture by reference or by copy, we can initialize using arbitrary expressions rather than simply the name of a variable:

auto lambda = [a = 2+2]{ DebugLog(a); };
lambda(); // 4

We also have two ways to individually capture this. The first is just [this] which captures this by reference:

struct CaptureThis
{
    int Val = 123;
 
    int Foo()
    {
        // Capture "this" by reference
        auto lambda = [this]
        {
            // Use captured "this" reference
            return this->Val;
        };
 
        // Modify "Val" after the capture
        this->Val = 0;
 
        // Invoke the lambda
        // Uses reference to "this" which has a modified Val
        return lambda();
    }
};
 
CaptureThis ct{};
DebugLog(ct.Foo()); // 0

The second way to capture this is with [*this], which makes a copy of the class object:

struct CaptureThis
{
    int Val = 123;
 
    int Foo()
    {
        // Capture "this" by copy
        auto lambda = [*this]
        {
            // Use captured "this" copy
            return this->Val;
        };
 
        // Modify "Val" after the capture
        this->Val = 0;
 
        // Invoke the lambda
        // Uses copy of "*this" which has the original Val
        return lambda();
    }
};
 
CaptureThis ct{};
DebugLog(ct.Foo()); // 123
Captured Data Members

So what does it mean when a lambda “captures” something? Mostly, it just means that data members are added to the lambda’s class and initialized via its constructor. Say we have this lambda:

[&m{multiply}, a{add}](float val){ return m*val + a; }

We can use the lambda like this:

float multiplyAndAddLoopLambda(float multiply, float add, int n)
{
    // Capture "multiply" by reference as "m"
    // Capture "add" by copy as "a"
    auto madd = [&m{multiply}, a{add}](float val){ return m*val + a; };
 
    float cur = 0;
    for (int i = 0; i < n; ++i)
    {
        cur = madd(cur);
    }
    return cur;
}
 
DebugLog(multiplyAndAddLoopLambda(2.0f, 1.0f, 5)); // 31

The reason is that the compiler generates a class for the lambda that looks like this:

// Compiler-generated class for this lambda:
//   [&m{multiply}, a{add}](float val){ return m*val + a; }
// Not actually named LambdaClass
class LambdaClass
{
    // "Captures" of the lambda
    // Order is unspecified
    // Not actually named "m" and "a"
    float& m;
    const float a;
 
public:
 
    // Constructor
    // Initializes captures
    LambdaClass(float& multiply, float add)
        : m{multiply}, a{add}
    {
    }
 
    // Copy constructor
    LambdaClass(const LambdaClass&) = default;
 
    // Move constructor
    LambdaClass(LambdaClass&&) = default;
 
    // Destructor
    ~LambdaClass() = default;
 
    // Function call operator
    float operator()(float val) const
    {
        // Lambda body
        return m*val + a;
    }
};

Notice that the default constructor has been replaced by a constructor that initializes the captures, be they by reference or copy. If there’s no capture initializer ([x] or [&x]), captures are direct-initialized. Otherwise, they’re copy-initialized or direct-initialized as specified by the capture initializer ([x{y}] or [x = y]). Array elements are direct-initialized in sequential order.

Another change in this compiler-generated lambda class is that the user-defined conversion operator to a function pointer has been removed. That’s because a plain function pointer doesn’t have access to the this pointer required to get the captures it needs to do its work. It’s as though we tried to write this:

float LambdaFunction(float val)
{
    // Compiler error: no "m"
    // Compiler error: no "a"
    return m*val + a;
}

Since we may need control over the modifiers placed on the lambda class’ data members, we can add keywords like mutable and noexcept to the lambda and they’ll be added to the data members too:

int x = 1;
 
// Compiler error
// LambdaClass::operator() is const and LambdaClass::x isn't mutable
auto lambda1 = [x](){ x = 2; };
 
// OK: LambdaClass::x is mutable
auto lambda2 = [x]() mutable { x = 2; };

When we used the lambda above, the compiler generated code to use the lambda’s class that looks more or less like this:

float multiplyAndAddLoopClass(float multiply, float add, int n)
{
    // "Capture" the "multiply" and "add" variables as data members of "madd"
    LambdaClass madd{multiply, add};
 
    float cur = 0;
    for (int i = 0; i < n; ++i)
    {
        cur = madd(cur);
    }
    return cur;
}
 
DebugLog(multiplyAndAddLoopClass(2.0f, 1.0f, 5)); // 31
Capture Rules

There are a number of language rules about how we can use captures. First, if the default capture mode is by reference, individual captures can’t also be by reference:

int x = 123;
 
// Compiler error: can't individually capture by reference when the default
//                 capture mode is by reference
auto lambda = [&, &x]{ DebugLog(x); };

Second, if the default capture mode is by copy then all individual captures must be by reference, this, or *this:

// Compiler error: can't individually capture by copy when the default
//                 capture mode is by copy
auto lambda1 = [=, =x]{ DebugLog(x); };
 
auto lambda2 = [=, &x]{ DebugLog(x); }; // OK
auto lambda3 = [=, this]{ DebugLog(this->Val); }; // OK
auto lambda4 = [=, *this]{ DebugLog(this->Val); }; // OK

Third, we can only capture a single name or this once:

int x = 123;
 
// Compiler error: can't capture by name twice
auto lambda1 = [x, x]{ DebugLog(x); };
 
// Compiler error: can't capture by name twice (with initialization)
auto lambda2 = [x, x=x]{ DebugLog(x); };
 
// Compiler error: can't capture by name twice (mixed capture modes)
auto lambda3 = [x, &x]{ DebugLog(x); };
 
// Compiler error: can't capture "this" twice
auto lambda4 = [this, this]{ DebugLog(this->Val); };
 
// Compiler error: can't capture "this" twice (mixed capture modes)
auto lambda5 = [this, *this]{ DebugLog(this->Val); };

Fourth, if the lambda isn’t in a block or a class’ default data member initializer, it can’t use default captures or have individual captures without an initializer:

// Global scope...
 
// Compiler error: can't use default captures here
auto lambda1 = [=]{ DebugLog("hi"); };
auto lambda2 = [&]{ DebugLog("hi"); };
 
// Compiler error: can't use uninitialized captures here
auto lambda3 = [x]{ DebugLog(x); };
auto lambda4 = [&x]{ DebugLog(x); };

Fifth, class members can only be captured individually using an initializer:

class Test
{
    int Val = 123;
 
    void Foo()
    {
        // Compiler error: member must be captured with an initializer
        auto lambda1 = [Val]{ DebugLog(Val); };
 
        auto lambda2 = [Val=Val]{ DebugLog(Val); }; // OK
        auto lambda3 = [&Val=Val]{ DebugLog(Val); }; // OK
    }
};

Sixth, and similarly, class members are never captured by default capture modes. Only this is captured and members are accessed from that pointer.

class Test
{
    int Val = 123;
 
    void Foo()
    {
        // Member not captured by default capture mode
        // Only "this" is captured
        auto lambda1 = [=]{ DebugLog(Val); };
        auto lambda2 = [&]{ DebugLog(Val); };
    }
};

Seventh, lambdas in default arguments can’t capture anything:

// Compiler error: lambda in default argument can't have a capture
void Foo(int val = ([=]{ return 2 + 2; })())
{
    DebugLog(val);
}

Eigth, anonymous union members can’t be captured:

union
{
    int32_t intVal;
    float floatVal;
};
intVal = 123;
 
// Compiler error: can't capture an anonymous union member
auto lambda = [intVal]{ DebugLog(intVal); };

Ninth, and finally, if a nested lambda captures something that’s captured by the lambda it’s nested in, the nested capture is transformed in two cases. The first case is if the lambda it’s nested in captured something by copy. In this case, the nested lambda captures the data member of outer lambda’s class instead of what was originally-captured.

void Foo()
{
    int x = 1;
    auto outerLambda = [x]() mutable
    {
        DebugLog("outer", x);
        x = 2;
        auto innerLambda = [x]
        {
            DebugLog("inner", x);
        };
        innerLambda();
    };
    x = 3;
    outerLambda(); // outer 1 inner 2
}

The second case is if the lambda it’s nested in captured something by reference. In this case, the nested lambda captures the original variable or this:

void Foo()
{
    int x = 1;
    auto outerLambda = [&x]() mutable
    {
        DebugLog("outer", x);
        x = 2;
        auto innerLambda = [&x]
        {
            DebugLog("inner", x);
        };
        innerLambda();
    };
    x = 3;
    outerLambda(); // outer 3 inner 2
}
IILE

A common idiom in C++, seen above in the default function argument example, is known as an Immediately-Invoked Lambda Expression. We can use these in a variety of situations to work around various language rules. For example, many C++ programmers strive to keep everything const that can be const. If, however, the value to initialize a const variable to requires multiple statements then it may be necessary to remove const. For example:

Command command;
switch (byteVal)
{
    case 0:
        command = Command::Clear;
        break;
    case 1:
        command = Command::Restart;
        break;
    case 2:
        command = Command::Enable;
        break;
    default:
        DebugLog("Unknown command: ", byteVal);
        command = Command::NoOp;
}

Here we couldn’t make command into a const variable even though we may only be initializing it in the switch and never setting it afterward. We could have transformed the switch into a chain of conditional operators, but then we wouldn’t be able to print the error message in the default case:

const Command command = byteVal == 0 ?
    Command::Clear :
    byteVal == Command::Restart ?
        Command::Enable :
        Command::NoOp;
}
 
// Unnecessary branch instruction: we already determined it's NoOp above
if (command == Command::NoOp)
{
    DebugLog("Unknown command: ", byteVal);
}

To get around this, we can use an IILE to wrap the switch. To do so, we put parentheses around the lambda and then parentheses afterward to immediately invoke it:

const Command command = ([byteVal]{
    switch (byteVal)
    {
        case 0: return Command::Clear;
        case 1: return Command::Restart;
        case 2: return Command::Enable;
        default:
            DebugLog("Unknown command: ", byteVal);
            return Command::NoOp;
    }})();

The compiler will then create an instance of the lambda class that’s destroyed at the end of the statement. The overhead of the constructor and destructor will be optimized away, effectively making the IILE and the const it enables “free.”

C# Equivalency

We’ve compared C++ lambdas to C# lambdas a little so far, but let’s take a closer look. First, we’ve seen that only “statement lambdas” are supported in C++. We can’t write a C# “expression lambda” like this:

// C#
(int x, int y) => x + y

This example also shows another difference: C# lambda arguments are always explicitly typed. C++ lambda arguments may be auto to support a variety of argument types:

auto lambda = [](auto x, auto y){ return x + y; };
 
// int arguments
DebugLog(lambda(2, 3)); // 5
 
// float arguments
DebugLog(lambda(3.14f, 2.0f)); // 5.14

Similarly, C++ return types may be auto and that is in fact the default when a trailing return type like -> float isn’t used. C# lambdas must always have an implicit return type. To force it, a cast is typically used within the body of the lambda:

// C#
(float x, float y) => { return (int)(x + y); };

On the other hand, C# is more explicit than C++ when storing the lambda in a variable as var cannot be used:

// C#
Func<int, int, int> f1 = (int x, int y) => { return x + y;}; // OK
var f2 = (int x, int y) => { return x + y;}; // Compiler error

C++ allows for auto:

auto lambda = [](int x, int y){ return x + y; };

C# lambdas support discarding arguments:

// C#
Func<int, int, int> f = (int x, int _) => { return x; }; // Discard y

C++ can do that by either omitting the name, similar to _, or by casting the argument to void:

// Omit argument name
auto lambda1 = [](int x, int){ return x; };
 
// Cast argument to void
auto lambda2 = [](int x, int y){ static_cast<void>(y); return x; };

C# has static lambdas to prevent capturing local variables or non-static fields. That’s the default in C++. Capturing in C++ is opt-in via default and individual captures:

int x = 123;
 
// Capture nothing
// Compiler error: can't access x
auto lambda1 = []{ DebugLog(x); };
 
// Capture implicitly by copy
auto lambda2 = [=]{ DebugLog(x); };
 
// Capture implicitly by reference
auto lambda3 = [&]{ DebugLog(x); };
 
// Capture explicitly by copy
auto lambda4 = [x]{ DebugLog(x); };
 
// Capture explicitly by reference
auto lambda5 = [&x]{ DebugLog(x); };

C# forbids capturing in, ref, and out variables. C++ references and pointers, the closest match to C#, can be freely captured in a variety of ways.

C# supports async lambdas as it does with other kinds of functions. C++ has no built-in async and await system, so these are not supported.

Finally, and most significantly, C++ lambdas are not a delegate as they are in C#. C++ has no concept of managed types or any built-in construct that operates like a delegate with its garbage-collection and support for multiple listeners determined at runtime.

Instead, C++ lambdas are just regular C++ classes. They have constructors, assignment operators, destructors, overloaded operators, and user-defined conversion operators. As such, they behave like other C++ class objects rather than as managed, garbage-collected C# classes.

Conclusion

Lambdas in both languages fulfill a similar role: to provide unnamed functions. Aside from async lambdas in C#, the C++ version of lambdas offers a much broader feature set. The two languages’ approaches diverge as C# makes the trade-off in favor of safety by making lambdas be managed delegates. C++ takes the low, or often zero, overhead approach of using regular classes at the cost of possible bugs such as dangling pointers and references.