C++ For C# Developers: Part 22 – Lambdas
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
- Part 1: Introduction
- Part 2: Primitive Types and Literals
- Part 3: Variables and Initialization
- Part 4: Functions
- Part 5: Build Model
- Part 6: Control Flow
- Part 7: Pointers, Arrays, and Strings
- Part 8: References
- Part 9: Enumerations
- Part 10: Struct Basics
- Part 11: Struct Functions
- Part 12: Constructors and Destructors
- Part 13: Initialization
- Part 14: Inheritance
- Part 15: Struct and Class Permissions
- Part 16: Struct and Class Wrapup
- Part 17: Namespaces
- Part 18: Exceptions
- Part 19: Dynamic Allocation
- Part 20: Implicit Type Conversion
- Part 21: Casting and RTTI
- Part 22: Lambdas
- Part 23: Compile-Time Programming
- Part 24: Preprocessor
- Part 25: Intro to Templates
- Part 26: Template Parameters
- Part 27: Template Deduction and Specialization
- Part 28: Variadic Templates
- Part 29: Template Constraints
- Part 30: Type Aliases
- Part 31: Deconstructing and Attributes
- Part 32: Thread-Local Storage and Volatile
- Part 33: Alignment, Assembly, and Language Linkage
- Part 34: Fold Expressions and Elaborated Type Specifiers
- Part 35: Modules, The New Build Model
- Part 36: Coroutines
- Part 37: Missing Language Features
- Part 38: C Standard Library
- Part 39: Language Support Library
- Part 40: Utilities Library
- Part 41: System Integration Library
- Part 42: Numbers Library
- Part 43: Threading Library
- Part 44: Strings Library
- Part 45: Array Containers Library
- Part 46: Other Containers Library
- Part 47: Containers Library Wrapup
- Part 48: Algorithms Library
- Part 49: Ranges and Parallel Algorithms
- Part 50: I/O Library
- Part 51: Missing Library Features
- Part 52: Idioms and Best Practices
- Part 53: Conclusion
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.
#1 by Mark on August 8th, 2022 ·
Since C++17 you can do this:
#2 by Ozan on April 17th, 2024 ·
Hey, great writeup. I have a small nitpick though.
In C#, lambdas are also converted into classes. You can see the compiler generated code here: https://sharplab.io/#v2:CYLg1APgAgTAjAWAFBQMwAJboMLoN7LpGYZQAs6AsgBQCU+hxTjTRUArADwCWAdgC4AadHyEiBAPnQAzOOgC86aqPQAPYSoCe9eVLyYA7GvRh0mgNwBfcy3SXkloA===