In this lesson, we introduce Lambdas. They provide a more concise syntax for creating functions. Lambdas give us more options for designing our software, as they have two key differences compared to regular functions:
This makes lambdas the default choice for scenarios needing simple, one-off functions. For example, if we need a function to pass as the parameter of another function, lambdas are often the default choice.
This is because they make our code faster to write, and more readable. The lambda is defined at the same place it is being used and, if the lambda is used only in that place, we don't need to clutter our code with unnecessary identifiers.
A minimalist lambda expression uses a set of square brackets, []
, followed by a code block, {}
. Between the {
and }
, we implement the behaviors our lambda will perform.
In this example, we create a lambda that does some logging:
[]{
std::cout << "Hello from Lambda!";
};
By itself, the expression doesn't do anything. However, we can treat it like any other expression. For example, we can immediately call the lambda using ()
:
#include <iostream>
int main() {
[]{
std::cout << "Hello from Lambda!";
}();
}
Hello from Lambda!
We can store the lambda as a variable, and can then call it through that variable later:
#include <iostream>
int main() {
auto MyLambda { []{
std::cout << "Hello from Lambda!";
}};
MyLambda();
}
Hello from Lambda!
However, by far the most common use of a lambda is to pass it to another function. Below, our CallIfEven()
function accepts an integer and a callback. If the integer is even, the callback will be invoked.
In previous lessons, we saw how we could provide such a callback using a function pointer or a functor. But, these constructs require us to define a function or a type elsewhere in our code.
If we just need a simple callable to implement some behavior, using a lambda is the best choice:
#include <iostream>
void CallIfEven(int Num, auto Callback) {
if (Num % 2 == 0) {
Callback();
}
}
int main() {
CallIfEven(4, [] {
std::cout << "4 is even";
});
}
4 is even
Here, we’re always using auto
to let the compiler figure out the lambda type. Explicitly declaring the type of a lambda is quite tricky, and generally not something that is done.
We will introduce better ways of specifying function types in the next lesson.
To allow our lambda to accept parameters, we can introduce a set of parenthesis, (
and )
to the basic lambda structure.
Below, we provide an alternative way to define a lambda:
[](){
std::cout << "Hello from Lambda!";
};
This lambda still accepts no parameters, but we can now provide a parameter list within the (
and )
. This parameter list works like any other function.
Below, we define a lambda that accepts two int
arguments:
[](int x, int y){
std::cout << "The total is " << x + y;
};
We have access to all the same techniques as functions, including passing by reference and using const
:
[](const int& x, const int& y){
std::cout << "The total is " << x+y;
};
We also pass arguments into those parameters in the usual way, within the (
and )
when we call our lambda:
#include <iostream>
int main() {
auto MyLambda { [](int x, int y){
std::cout << "The total is " << x + y;
}};
MyLambda(2, 5);
}
The total is 7
By default, the compiler infers the return type of the lambda. This is equivalent to having a function with an auto
return type:
// This will return a boolean
[](int Number){
return Number % 2 == 0;
};
We may want to explicitly specify the return type of our lambda. If we wanted to explicitly set the return type to be a bool
, the syntax looks like the following:
[](int Number) -> bool {
return Number % 2 == 0;
};
This has the same effect as specifying a return type on a regular function. Specifically, if we attempt to return something that does not have that type, the compiler will attempt to implicitly convert it.
For example, the body of the following lambda attempts to return an int
, but because we’ve specified the lambda’s return type as bool
, our return value will be converted to a boolean. In this case, our lambda will return true
:
[](int Number) -> bool {
return Number % 2 == 0;
};
When specifying a return type, we must include a parameter list using (
and )
, even if it is empty:
[]() -> bool {
return true;
};
Normally, we can access values from the parent scope. However, this does not work with lambdas. The following code will not compile:
#include <iostream>
int main() {
int Number{2};
[] {
std::cout << "Number: " << Number;
};
}
error C3493: 'Number' cannot be implicitly captured because no default capture mode has been specified
error C2326: lambda function cannot access 'Number'
To give our lambda access to variables within the scope it is defined, we need to capture them.
The []
in a lambda expression is where we capture variables from the parent scope we want to use:
#include <iostream>
int main() {
int Number{2};
[Number] {
std::cout << "Number: " << Number;
};
}
Number: 2
We can capture multiple objects by separating them with commas:
#include <iostream>
int main() {
int x{1};
int y{2};
int z{3};
[x, y, z] {
std::cout << "Sum: " << x + y + z;
}();
}
Sum: 6
We can create new variables in the capture clause. This is useful mainly for performing subtle modifications to what we’re capturing. For example, we can give our captures different names, or cast them:
#include <iostream>
int main() {
int x{1};
[Value = x, Casted = static_cast<bool>(x)] {
std::cout << "Value: " << Value << '\n';
std::cout << "Casted type: "
<< typeid(Casted).name();
}();
}
Value: 1
Casted type: bool
mutable
LambdasBy default, lambdas capture objects by const
value. This means that the objects we capture are copied into our lambda, and we are then prevented from modifying them:
int main() {
int Number{2};
[Number] {
++Number;
};
}
error C3491: a by copy capture cannot be modified in a non-mutable lambda
To allow modification of these copies, we can mark our lambda as mutable
. Applying the mutable
keyword requires us to include the parameter list syntax ()
, even if it is empty:
#include <iostream>
int main() {
int Number{2};
[Number]() mutable {
std::cout << "Number in Lambda: "
<< ++Number;
}();
}
Number in Lambda: 3
mutable
?It might seem strange that lambdas capture values by const
value, but this is due to a nuance in how lambdas work. Behind the scenes, lambdas work similarly to functors, with the captured objects becoming member variables on that type.
This has implications when our lambda is called multiple times. If a lambda invocation modifies one of those data members, the outcome of future lambda calls can be affected:
#include <iostream>
int main() {
int Number{2};
auto Lambda{[Number]() mutable {
std::cout << "Number in Lambda: "
<< ++Number << '\n';
}};
Lambda();
Lambda();
Lambda();
std::cout << "Number in main: " << Number;
}
Number in Lambda: 3
Number in Lambda: 4
Number in Lambda: 5
Number in main: 2
This is rarely desirable, so the specification has decided that if we do want it, we have to take the extra step of marking the lambda mutable
. This ensures the behavior is intentional and clearly communicated.
Similar to parameters, we can elect to capture objects by reference instead of by value. We do this by prepending &
to the identifiers:
#include <iostream>
int main() {
int Number{2};
[&Number] {
++Number;
}();
std::cout << "Number: " << Number;
}
Number: 3
const
ReferenceWhen we want to capture an object by reference, and also mark that reference as const
, the std::as_const()
function within the <utility>
header can help us:
#include <utility>
int main() {
int x{1};
[&x = std::as_const(x)] {
x++;
};
}
error: increment of read-only reference 'x'
For simpler use cases, the compiler can help us create our capture list. It knows what variables we’re using in our lambda, so it can automatically capture them for us.
We can ask it to capture everything we use by value, using the =
symbol in our capture group:
#include <iostream>
int main () {
int x { 1 };
[=] {
std::cout << "x: " << x;
}();
}
x: 1
To capture everything by reference, we can use &
:
#include <iostream>
int main () {
int x { 1 };
[&] { ++x; }();
std::cout << "x: " << x;
}
x: 2
We can mix and match these techniques as needed. If we’re using a default capture, it needs to be first:
#include <iostream>
int main() {
int x{1};
int y{2};
int z{3};
// Capture x by reference
// Capture y by const reference
// Capture everything else by value
[=, &x, &y = std::as_const(y)] {
std::cout << "Sum: " << x + y + z;
}();
}
Sum: 6
Lambdas can specify attributes such as [[nodiscard]]
after the capture group:
int main() {
auto Lambda{[] [[nodiscard]] { return 5; }};
Lambda();
}
warning: ignoring return value of 'Lambda', declared with attribute 'nodiscard'
If our lambda includes a parameter list, we place the attribute before it. Below, we use the [[deprecated]]
attribute in a lambda that has both a parameter list and a return type:
int main() {
auto Lambda{[] [[deprecated("Oh no!")]]
(int x, int y) -> int { return x + y; }
};
Lambda(1, 2);
}
warning: Lambda is deprecated: Oh no!
In this lesson, we've explored lambdas, demonstrating how they provide a concise way to write inline functions for specific tasks. The key topics we learned includes:
->
syntax followed by the type.mutable
.=
or &
, respectively.[[nodiscard]]
or [[deprecated]]
to enforce certain behaviors or warnings at compile time.An introduction to lambda expressions - a concise way of defining simple, ad-hoc functions
Comprehensive course covering advanced concepts, and how to use them on large-scale projects.