Modern C++: Lambda Expressions

Dagang Wei
7 min readJun 22, 2024

--

This blog post is part of the series Modern C++.

Introduction

Lambda expressions, introduced in C++11, are a powerful feature that can significantly streamline your code and make it more expressive. They offer a concise way to define anonymous functions directly within your code, exactly where you need them.

Why Use Lambdas?

  • Readability: Lambdas let you keep related code together, improving the flow of your programs.
  • Flexibility: They can be passed around as arguments to functions or stored in variables.
  • Conciseness: They eliminate the need to write separate, named functions for simple tasks.

What Exactly Are Lambda Expressions?

Think of a lambda expression as a self-contained function that you can create on the fly. Here’s a quick glimpse at a lambda expression in action:

auto multiplyByTwo = [](int num) { return num * 2; };
int result = multiplyByTwo(5); // result will be 10

As you can see, this compact expression does the work of a complete function. Now, let’s break down its individual parts:

  1. Capture Clause (Optional): This specifies which variables from the surrounding scope the lambda can access. An empty [] means no variables are captured.
  2. Parameter List (Optional): Just like regular functions, lambdas can take arguments. In our example, (int num) indicates that the lambda takes an integer argument.
  3. Mutable Keyword (Optional): If you need to modify captured variables within the lambda, you can use the mutable keyword (only for captures by value).
  4. Return Type (Often Inferred): What the lambda gives back. C++ often figures this out for you based on the return statement in the lambda body. In our case, it’s an int.
  5. Body: The actual code the lambda executes. In our example, { return num * 2; } doubles the input number.

The Capture Clause Deep Dive

The capture clause is where the magic of lambdas really shines. It lets you customize how a lambda interacts with its environment.

Capture Types

  • Empty Capture []: The lambda doesn't capture any outside variables. This is the default if you don't specify a capture clause.
  • Capture by Value [=]: The lambda makes copies of all the variables it uses from the surrounding scope. These copies are stored inside the lambda object, so they're independent of the original variables.
  • Capture by Reference [&]: The lambda holds references to the variables it uses from the surrounding scope. Changes to these variables inside the lambda will also affect the originals.
  • Mixed Capture: You can combine captures by value and reference in the same clause:
int x = 5;
auto myLambda = [x, &y]() { ... }; // x is captured by value, y by reference
  • Capture with Initializers (C++14): Create and initialize new variables directly within the capture clause.
auto myLambda = [count = 0]() mutable { count++; std::cout << count << " "; }; 
myLambda(); // Output: 1
myLambda(); // Output: 2
myLambda(); // Output: 3

Example

Let’s use the following example to illustrate the behavior of captured variables with different capture types:

#include <iostream>

int main() {
int x = 5;
int y = 10;
// Lambda capturing x by value and y by reference
auto myLambda = [x, &y]() mutable {
x += 2; // Modifies the captured copy of x
y += 5; // Modifies the original y
std::cout << "Inside lambda: x = " << x << ", y = " << y << std::endl;
};
myLambda(); // Call the lambda
std::cout << "Outside lambda: x = " << x << ", y = " << y << std::endl;
return 0;
}

Capture:

  • x: captured by value, meaning a copy of x (with its initial value of 5) is stored within the lambda object.
  • y: captured by reference, so the lambda directly accesses the original y variable.

Mutable: The mutable keyword is added to the lambda to allow modifying the captured value of x. Without it, modifications to x would be illegal inside the lambda.

Inside the Lambda:

  • x += 2; increases the captured copy of x to 7.
  • y += 5; increases the original y to 15.

Outside the Lambda:

  • x is still 5 (since the lambda modified a copy).
  • y is now 15 (changed by the lambda).

Output:

Inside lambda: x = 7, y = 15
Outside lambda: x = 5, y = 15

Key Takeaways:

  • Capture by Value: The lambda gets its own copy of the variable, making changes within the lambda isolated.
  • Capture by Reference: The lambda operates directly on the original variable, so changes are visible outside.
  • Mutable: Allows modification of captured variables by value (note that the original variable is unchanged).

Bonus Question

What if we call `myLambda` twice in the code above? Will the x inside the lambda become 9 or still 7?

Answer: 9.

Key Point: The lambda essentially remembers the value of x across calls due to capture by value, while y is continually modified because of capture by reference.

Capture Gotchas

Dangling References: Be extremely cautious when capturing by reference. If the captured variable goes out of scope, the lambda will hold a reference to invalid memory.

State Management: Capturing variables by value creates a self-contained copy at the time of the lambda’s creation. Changes to the original variable won’t be reflected inside the lambda, and vice-versa.

Mutable Lambdas: By default, lambdas capture by value variables as const. To modify them, you'll need to use the mutable keyword.

What Type is a Lambda?

Each lambda expression you create has its own unique, unnamed type. This type is a bit like a class that’s generated behind the scenes by the compiler. You can’t directly write this type out yourself, but it’s important to understand it conceptually.

Here’s why it matters:

  • Storage: You can store a lambda in a variable, but you need to use auto (or std::function if you need more flexibility). auto lets the compiler deduce the type for you.
  • Passing to Functions: You can pass lambdas as arguments to functions that expect function objects (objects that can be called like functions).

Lambda Expressions and std::function

It’s a common and useful way to use std::function with lambdas in C++.

Understanding std::function

std::function is a versatile function wrapper in the C++ Standard Library. It can hold any callable object (function pointers, functors, member functions, or lambdas) as long as the signature (return type and parameters) matches.

Assigning Lambdas

You can directly assign a lambda to a std::function object, and the compiler will automatically handle the type deduction:

std::function<int(int, int)> func = [](int x, int y) { return x + y; };
int result = func(5, 3); // result is 8

Here, std::function<int(int, int)> specifies that func will hold a callable object that takes two int arguments and returns an int.

Why Use std::function?

  • Flexibility: You can change the callable object stored in std::function at runtime, which isn't possible with regular function pointers or plain auto variables holding lambdas.
  • Type Erasure: std::function hides the actual type of the callable object it holds. This is useful when you want to work with functions in a generic way without knowing their specific types.
  • Error Checking: You get compile-time type checking when assigning a lambda to std::function. This can help catch signature mismatches early.

Example: Passing std::function as an Argument

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

void modifyVector(std::vector<int>& vec, std::function<int(int)> operation) {
std::transform(vec.begin(), vec.end(), vec.begin(), operation);
}
int main() {
std::vector<int> data = {1, 2, 3, 4, 5};
// Pass a lambda to modifyVector
modifyVector(data, [](int x) { return x * 2; }); // Double each element
for (int val : data) {
std::cout << val << " "; // Output: 2 4 6 8 10
}
std::cout << std::endl;
return 0;
}

In this example, the modifyVector function takes a vector and a std::function that represents the modification operation to be applied to each element. This makes the function very flexible – you can pass different lambdas to it depending on what you need to do with the vector's data.

Important Note:

  • std::function introduces a small overhead due to type erasure and dynamic dispatch. If performance is critical, consider using templates or function pointers directly if the types are known at compile time.

A Few Simple Examples

Basic Lambda:

auto greet = []() { std::cout << "Hello from a lambda!\n"; }; // Type is deduced by auto
greet(); // Calls the lambda

Lambda with Parameters:

auto add = [](int x, int y) { return x + y; }; // Type is deduced by auto
int sum = add(5, 3); // sum will be 8

Lambda with Capture:

int base = 10;
auto addBase = [base](int x) { return x + base; }; // Type is deduced by auto
int result = addBase(7); // result will be 17

More Advanced Examples

Passing Lambdas to Functions:

void applyOperation(int a, int b, auto operation) {
std::cout << operation(a, b) << std::endl;
}

applyOperation(5, 3, [](int x, int y) { return x + y; }); // Prints 8
applyOperation(5, 3, [](int x, int y) { return x * y; }); // Prints 15

Generic Lambdas (C++14):

auto printTwice = [](auto value) {
std::cout << value << " " << value << std::endl;
};
printTwice("Hello");
printTwice(42);

Immediately Invoked Lambda (IIFE):

[](){
std::cout << "This lambda executes immediately!" << std::endl;
}();

Lambdas with STL Algorithms:

std::vector<int> numbers = {1, 2, 3, 4, 5};

std::transform(numbers.begin(), numbers.end(), numbers.begin(),
[](int x) { return x * 2; }); // Doubles each element

Recursive Lambdas (using std::function):

std::function<int(int)> factorial = [&factorial](int n) -> int {
return (n <= 1) ? 1 : n * factorial(n - 1);
};

std::cout << factorial(5); // Outputs 120

Common Mistakes to Avoid

  • Dangling References: When capturing by reference, be careful the captured variable doesn’t go out of scope before the lambda is used.
auto badLambda = [&x]() { return x; };  // x is a local variable
// x goes out of scope here
badLambda(); // Undefined behavior!
  • Unnecessary Capture by Reference: Capture by value is generally preferred unless you explicitly need to modify the variable from within the lambda.
  • Overly Complex Lambdas: If your lambda gets too long or complicated, consider refactoring it into a regular named function.

Going Beyond the Basics (C++14 and Later)

  • Generic Lambdas: C++14 introduced the ability to create lambdas with auto parameters, making them more flexible.
  • Capture with Initializers: In C++14, you can initialize new variables directly within the capture clause.
  • Constexpr Lambdas: C++17 made it possible to use lambdas in constant expressions.

Wrapping Up

Lambdas are a versatile tool in the C++ programmer’s arsenal. By mastering their usage, you’ll write cleaner, more efficient code and unlock a new level of expressiveness in your C++ projects. So, go ahead and experiment with lambdas in your own code. Happy coding!

--

--