I am also a rookie QAQ, so I will share my notes on learning C++ Lambda. This is just like a glimpse of the big guys on Zhihu exploring the vast world of wisdom through a narrow slit. If I make any mistakes, please correct me.
Lambda expressions are introduced in the C++11 standard and allow anonymous functions to be defined in code. Each chapter of this article will have a large number of code examples to help you understand. Some of the code in this article refers to Microsoft official documentation | Lambda expressions in C++ | Microsoft Learn.
Table of contents
Basics
- 1. Lambda Basic Syntax
- 2. How to use Lambda expressions
- 3. Detailed discussion of capture lists
- 4. mutable keyword
- 5. Lambda return value deduction
- 6. Nested Lambda
- 7. Lambda, std:function and delegates
- 8. Lambda in asynchronous and concurrent programming
- 9. Generic Lambda (C++14)
- Lambda Scope
- practice
Intermediate
- 1. Lambda's underlying implementation
- 2. Lambda type, decltype and conditional compilation
- 3. Lambda’s evolution in the new standard
- 4. State-preserving Lambda
- 5. Optimization and Lambda
- 6. Integration with other programming paradigms
- 7. Lambda and Exception Handling
Advanced
- 1. Lambda and noexcept
- 2. Template parameters in Lambda (C++20 feature)
- 3. Lambda Reflection
- 4. Cross-platform and ABI issues
Basics
1. Lambda Basic Syntax
Lambda basically looks like this:
[ capture_clause ] ( parameters ) -> return_type { // function_body }
- Capture clause (capture_clause) determines which variables in the outer scope will be captured by this lambda and how they will be captured (by value, by reference, or not captured). We discuss capture clauses in detail in the next chapter.
- Parameter list (parameters) and the function body (function_body) is the same as a normal function, there is no difference.
- Return Type (return_type) is slightly different. If the function body contains multiple statements and needs to return a value, the return type must be explicitly specified, unless all return statements return the same type, in which case the return type can be inferred automatically.
2. How to use Lambda expressions
Syntax example:
// a lambda that captures no outer variables, takes no arguments, and has no return value auto greet = [] { std::cout << "Hello, World!" << std::endl; }; // a lambda that captures outer variables by reference, takes one int argument, and returns an int int x = 42; auto add_to_x = [&x](int y) -> int { return x + y; }; // a lambda that captures all outer variables by value, takes two arguments, and has its return type automatically inferred int a = 1, b = 2; auto sum = [=](int x, int y) { return a + b + x + y; }; // a lambda that creates new variables using initializer capture (C++14 feature) auto multiply = [product = a * b](int scalar) { return product * scalar; };
Practical example:
- As a sorting criterion
// As sorting criteria #include #include #include int main() { std::vector v{4, 1, 3, 5, 2}; std::sort(v.begin(), v.end(), [](int a, int b) { return a < b; // Sort in ascending order}); for (int i : v) { std::cout << i << ' '; } // Output: 1 2 3 4 5 }
- For forEach operation
#include #include #include int main() { std::vector v{1, 2, 3, 4, 5}; std::for_each(v.begin(), v.end(), [](int i) { std::cout << i * i << ' '; // print the square of each number }); // output: 1 4 9 16 25 }
- For cumulative functions
#include #include #include int main() { std::vector v{1, 2, 3, 4, 5}; int sum = std::accumulate(v.begin(), v.end(), 0, [](int a, int b) { return a + b; // Sum }); std::cout << sum << std::endl; // Output: 15 }
- For thread constructor
#include #include int main() { int x = 10; std::thread t([x]() { std::cout << "Value in thread: " << x << std::endl; }); t.join(); // Output: Value in thread: 10 // Note: x used in the thread is captured by value when the thread is created }
3. Detailed discussion of the capture list
The capture list is optional. It specifies the external variables that can be accessed from within the lambda expression. Referenced external variables can be modified from within the lambda expression, but external variables captured by value cannot be modified, that is, variables prefixed with an ampersand (&) are accessed by reference, and variables without the prefix are accessed by value.
- Do not capture any external variables:
cpp []{ /…/ }
This lambda does not capture any variables from the outer scope.
- By default, all external variables are captured (by reference):
cpp [&]{ /…/ }
This lambda captures all variables in the outer scope and captures them by reference. If the captured variables are destroyed or out of scope when the lambda is called, undefined behavior occurs.
- By default, all external variables are captured (by value):
cpp[=]{ /…/ }
This lambda captures all outer scope variables by value, which means it uses a copy of the variables.
- Explicitly capture specific variables (by value):
cpp [x]{ /…/ }
This lambda captures the outer variable x by value.
- Explicitly capture specific variables (by reference):
cpp [&x]{ /…/ }
This lambda captures the outer variable x by reference.
- Mixed capture (by value and by reference):
cpp [x, &y]{ /…/ }
This lambda captures the variable x by value and the variable y by reference.
- By default, variables are captured by value, but some variables are captured by reference.:
cpp [=, &x, &y]{ /…/ }
This lambda captures all outer variables by value by default, but captures variables x and y by reference.
- By default, variables are captured by reference, but some are captured by value.:
cpp [&, x, y]{ /…/ }
This lambda captures all outer variables by reference by default, but captures variables x and y by value.
- Capturing the this pointer:
cpp [this]{ /…/ }
This allows the lambda expression to capture the this pointer of the class member function, thus giving access to the class's member variables and functions.
- Capture with initializer expression (since C++14) – Generic lambda capture:cpp [x = 42]{ /…/ } creates an anonymous variable x inside the lambda, which can be used in the lambda function body. This is quite useful, for example, you can directly transfer std::unique_ptr with move semantics, which is discussed in detail in the "reference" below.
- Capturing the asterisk this (since C++17):cpp [this]{ /…*/ } This lambda captures the current object (the instance of its class) by value. This avoids the risk of the this pointer becoming a dangling pointer during the lambda's lifetime. Before C++17, you could get this by reference, but this had a potential memory risk, that is, if the lifetime of this ended, it would cause a memory leak. Using the asterisk this is equivalent to making a deep copy of the current object.
std::unique_ptr is a smart pointer with exclusive ownership. Its original design is to ensure that only one entity can own the object at a time. Therefore, std::unique_ptr cannot be copied, but can only be moved. If you want to capture by value, the compiler will report an error. If you capture by reference, the compiler will not report an error. But there are potential problems. I can think of three:
- The life of std::unique_ptr ends before lambda. In this case, accessing this destroyed std::unique_ptr from within lambda will cause the program to crash.
- If std::unique_ptr is moved after capture, the reference in the lambda is null, causing the program to crash.
- In a multi-threaded environment, the above two problems will occur more frequently. To avoid these problems, you can consider value capture, that is, explicitly use std::move to transfer ownership. In a multi-threaded environment, lock.
Code example:
- Using Lambda as callback function – This example also involves function()
#include #include // Suppose there is a function that calls the callback function void performOperationAsync(std::function callback) { // Async operation... int result = 42; // Assume this is the result of the asynchronous operation callback(result); // Call callback function } int main() { int capture = 100; performOperationAsync([capture](int result) { std::cout << "Async operation result: " << result << " with captured value: " << capture << std::endl; }); }
- Used with smart pointers – This example also involves the mutable keyword
#include #include void processResource(std::unique_ptr ptr) { // do some processing std::cout << "Processing resource with value " << *ptr << std::endl; } int main() { auto ptr = std::make_unique (10); // Use Lambda to delay resource processing auto deferredProcess = [p = std::move(ptr)]() { processResource(std::move(p)); }; // Do some other operations... // ... deferredProcess(); // Finally process the resource }
- Synchronizing data access in multiple threads
int main() { std::vector data; std::mutex data_mutex; std::vector threadsPool; // Lambda is used to add data to vector to ensure thread safety auto addData = [&](int value) { std::lock_guard lock(data_mutex); data.push_back(value); std::cout << "Added " << value << " to the data structure." << std::endl; }; threadsPool.reserve(10); for (int i = 0; i < 10; ++i) { threadsPool.emplace_back(addData, i); } // Wait for all threads to complete for (auto& thread: threadsPool) { thread.join(); } }
- Application of Lambda in range query
#include int main() { std::vector v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; int lower_bound = 3; int upper_bound = 7; // Use Lambda to find all numbers in a specific range auto range_begin = std::find_if(v.begin(), v.end(), [lower_bound](int x) { return x >= lower_bound; }); auto range_end = std::find_if(range_begin, v.end(), [upper_bound](int x) { return x > upper_bound; }); std::cout << "Range: "; std::for_each(range_begin, range_end, [](int x) { std::cout << x << ' '; }); std::cout << std::endl; }
- Delayed execution
#include // Simulate a potentially time-consuming operation void expensiveOperation(int data) { // Simulate a time-consuming operation std::this_thread::sleep_for(std::chrono::seconds(1)); std::cout << "Processed data: " << data << std::endl; } int main() { std::vector <std::function > deferredOperations; deferredOperations.reserve(10); // Assume this is a loop that needs to perform expensive operations, but we don't want to do them right away for (int i = 0; i < 10; ++i) { // Capture i and defer execution deferredOperations.emplace_back([i] { expensiveOperation(i); }); } std::cout << "All operations have been scheduled, doing other work now." << std::endl; // Assume now is a good time to do these expensive operations for (auto& operation : deferredOperations) { // Execute the lambda expression on a new thread to avoid blocking the main thread std::thread(operation).detach(); } // Give the thread some time to process the operation std::this_thread::sleep_for(std::chrono::seconds(2)); std::cout << "Main thread finished." << std::endl; } /* Note: In actual multithreaded programs, you usually need to consider thread synchronization and resource management, such as using std::async instead of std::thread().detach(), and using appropriate synchronization mechanisms such as mutexes and condition variables to ensure thread safety. In this simplified example, these details are omitted to maintain clarity and focus on the delay operation. */ // The following shows a more reasonable version of this example #include #include #include #include // Simulate a potentially time-consuming operation int expensiveOperation(int data) { // Simulate a time-consuming operation std::this_thread::sleep_for(std::chrono::seconds(1)); return data * data; // Return some processing results } int main() { std::vector <std::future > deferredResults; // Launch multiple asynchronous tasks deferredResults.reserve(10); for (int i = 0; i < 10; ++i) { deferredResults.emplace_back( std::async(std::launch::async, expensiveOperation, i) ); } std::cout << "All operations have been scheduled, doing other work now." << std::endl; // Get the results of asynchronous tasks for (auto& future : deferredResults) { // get() will block until the asynchronous operation is completed and returns the result std::cout << "Processed data: " << future.get() << std::endl; } std::cout << "Main thread finished." << std::endl; } /* Note: std::async manages all of this for us. We also don't need to use mutexes or other synchronization mechanisms because each asynchronous operation runs on its own thread and will not interfere with each other, and the returned future object handles all the necessary synchronization for us. std::async is used with the std::launch::async parameter, which ensures that each task runs asynchronously on a different thread. If you don't specify std::launch::async, the C++ runtime can decide to execute the tasks synchronously (delayed), which is not what we want to see. The future.get() call will block the main thread until the corresponding task is completed and the result is returned. This allows us to safely obtain the results without race conditions or the need to use mutexes. */
4. mutable keyword
First, let's review what the mutable keyword is. In addition to being used in lambda expressions, we also generally use it in class member declarations.
When in aClass member variablesWhen you use the mutable keyword, you can modify this member variable in the const member function of the class. This is usually used for members that do not affect the external state of the object, such as caches, debugging information, or data that can be calculated lazily.
class MyClass { public: mutable int cache; // int data can be modified in const member functions; MyClass() : data(0), cache(0) {} void setData(int d) const { // data = d; // Compile error: non-mutable member cache = d cannot be modified in const function; } };
In lambda expressions, the mutable keyword allows you to modify the copy of the variable captured inside the Lambda. By default, the () in the Lambda expression is const, and generally you cannot modify the variable captured by value. Unless you use mutable.
HereKey PointsMutable allows modification of the closure's own member variablesInstances, rather than the original variables in the outer scope. This means that the closure has "Closedness" is still maintained, because it does not change the state of the outer scope, but only changes its own internal state.
Invalid example:
int x = 0; auto f = [x]() { x++; // Error: cannot modify captured variable}; f();
It should be like this:
int x = 0; auto f = [x]() mutable { x++; std::cout << x << std::endl; }; f(); // Correct: outputs 1
Practical example:
- Capturing variable modifications
#include #include int main() { int count = 0; // creates a mutable lambda expression that increments count on each call auto increment = [count]() mutable { count++; std::cout << count << std::endl; }; increment(); // prints 1 increment(); // prints 2 increment(); // prints 3 // The external count is still 0 because it was captured by value std::cout << "External count: " << count << std::endl; // prints External count: 0 }
- Generate a unique ID
#include int main() { int lastId = 0; auto generateId = [lastId]() mutable -> int { return ++lastId; // Increment and return the new ID }; std::cout << "New ID: " << generateId() << std::endl; // Output New ID: 1 std::cout << "New ID: " << generateId() << std::endl; // Output New ID: 2 std::cout << "New ID: " << generateId() << std::endl; // Output New ID: 3 }
- State retention
#include #include #include int main() { std::vector numbers = {1, 2, 3, 4, 5}; // Initial state int accumulator = 0; // Create a mutable lambda expression to accumulate values auto sum = [accumulator](int value) mutable { accumulator += value; return accumulator; // Returns the current accumulated value }; std::vector runningTotals(numbers.size()); // Apply sum to each element to generate a running total std::transform(numbers.begin(), numbers.end(), runningTotals.begin(), sum); // Print the running total for (int total : runningTotals) { std::cout << total << " "; // Prints 1 3 6 10 15 } std::cout << std::endl; }
5. Lambda return value deduction
When lambda expressions were introduced in C++11, the return type of the lambda usually needed to be explicitly specified.
Starting from C++14, the deduction of Lambda return values has been improved and automatic type deduction has been introduced.
The deduction of lambda return values in C++14 follows the following rules:
- If the lambda function body contains the return keyword, and the type of the expressions following all return statements is the same, then the lambda return type is deduced to be that type.
- If the body of the lambda function is a single return statement, or can be considered a single return statement (such as a constructor or brace initializer), the return type is inferred to be the type of the return statement expression.
- If the lambda function does not return any value (i.e. there is no return statement in the function body), or if the function body contains only return statements that do not return a value (i.e. return;), the deduced return type is void.
- C++11 return value deduction example
In C++11, if the lambda body contains multiple return statements, the return type must be explicitly specified.
auto f = [](int x) -> double { // explicitly specify the return type if (x > 0) return x * 2.5; else return x / 2.0; };
- C++14 automatic deduction
In C++14, the return type of the above lambda expression can be automatically deduced.
auto f = [](int x) { // The return type is automatically deduced to double if (x > 0) return x * 2.5; // double else return x / 2.0; // double };
- Error demonstration
If the type of the return statement does not match, it cannot be automatically deduced, which will result in a compilation error.
auto g = [](int x) { // Compilation error because the return type is inconsistent if (x > 0) return x * 2.5; // double else return x; // int };
But after C++17, if the return types are so different that they cannot be unified into a common type directly or through conversion, you can use std::variant or std::any, which can contain multiple different types:
#include auto g = [](int x) -> std::variant { if (x > 0) return x * 2.5; // Returns double type else return x; // Returns int type };
The lambda expression returns a std::variant type, that is, a superposition state of int or double type, and subsequent callers can then check this variable and handle it accordingly. This part will not be discussed in detail.
6. Nested Lambda
It can also be called nested lambda, which is an advanced functional programming technique to write a lambda inside a lambda.
Here is a simple example:
#include #include #include int main() { std::vector numbers = {1, 2, 3, 4, 5}; // Outer Lambda is used to iterate over the collection std::for_each(numbers.begin(), numbers.end(), [](int x) { // Nested Lambda is used to calculate the square auto square = [](int y) { return y * y; }; // Call the nested Lambda and print the result std::cout << square(x) << ' '; }); std::cout << std::endl; return 0; }
But we need to pay attention to many issues:
- Don't make it too complicated; readability is the main consideration.
- Note the lifetime of variables in the capture list, which will also be discussed in detail in the following examples.
- Capture lists should be kept as simple as possible to avoid errors.
- The compiler may not optimize nested lambdas as well as top-level functions or class member functions.
If a nested Lambda captures local variables of an outer Lambda, you need to pay attention to the lifecycle of the variables. If the execution of the nested Lambda continues beyond the lifecycle of the outer Lambda, the captured local variables will no longer be valid and an error will be reported.
#include #include std::function createLambda() { int localValue = 10; // local variable of the outer lambda // returns a lambda that captures localValue return [localValue]() mutable { return ++localValue; // attempts to modify captured variable (legal since it's value capture) }; } int main() { auto myLambda = createLambda(); // myLambda now holds a copy of a captured local variable that has been destroyed std::cout << myLambda() << std::endl; // this will print 11, but depends on the destroyed copy of localValue std::cout << myLambda() << std::endl; // calling it again will print 12, continuing to depend on that copy return 0; }
To explain, since Lambda captures localValue by value, it holds a copy of localValue, and the life cycle of this copy is the same as that of the returned Lambda object.
When we call myLambda() in the main function, it operates on the state of the localValue copy, not the original localValue (which has been destroyed after the createLambda function is executed). Although undefined behavior is not triggered here, the situation will be different if we use reference capture:
std::function createLambda() { int localValue = 10; // Local variable of outer Lambda // Return a Lambda that captures the localValue reference return [&localValue]() mutable { return ++localValue; // Attempt to modify the captured variable }; } // Using the Lambda returned by createLambda at this point will result in undefined behavior
7. Lambda, std:function and delegates
Lambda expression, std::function and delegate are three different concepts used to implement function call and callback mechanism in C++. Next, we will explain them one by one.
- Lambda
C++11 introduces a syntax for defining anonymous function objects. Lambda is used to create a callable entity, namely a Lambda closure, which is usually passed to an algorithm or used as a callback function. Lambda expressions can capture variables in scope, either by value (copy) or by reference. Lambda expressions are defined inside functions, their types are unique, and cannot be explicitly specified.
auto lambda = [](int a, int b) { return a + b; }; auto result = lambda(2, 3); // Calling Lambda Expression
- std::function
std::function is a type-erased wrapper introduced in C++11 that canstorage,CallandcopyAny callable entity, such as function pointers, member function pointers, lambda expressions, and function objects. The cost is that the overhead is large.
std::function func = lambda; auto result = func(2, 3); // Call the Lambda expression using the std::function object
- Delegation
Delegate is not a formal term in C++. Delegate is usually a mechanism to delegate function calls to other objects. In C#, a delegate is a type-safe function pointer. In C++, there are generally several ways to implement delegates: function pointer, member function pointer, std::function and function object. The following is an example of a delegate constructor.
class MyClass { public: MyClass(int value) : MyClass(value, "default") { // delegates to another constructor std::cout << "Constructor with single parameter called." << std::endl; } MyClass(int value, std::string text) { std::cout << "Constructor with two parameters called: " << value << ", " << text << std::endl; } }; int main() { MyClass obj(30); // this will call both constructors }
- Comparison of the three
Lambda ExpressionsIt is lightweight and well suited for defining simple local callbacks and as parameters to algorithms.
std::function is heavier, but more flexible. For example, if you have a scenario where you need to store different types of callback functions, std::function is an ideal choice because it can store any type of callable entity. An example that demonstrates its flexibility.
#include #include #include // A function that takes an int and returns void void printNumber(int number) { std::cout << "Number: " << number << std::endl; } // A Lambda expression auto printSum = [](int a, int b) { std::cout << "Sum: " << (a + b) << std::endl; }; // A function object class PrintMessage { public: void operator()(const std::string &message) const { std::cout << "Message: " << message << std::endl; } }; int main() { // Create a vector of std::function that can store any type of callable object std::vector <std::function > callbacks; // Add a callback for a normal function int number_to_print = 42; callbacks.push_back([=]{ printNumber(number_to_print); }); // Add a callback for a Lambda expression int a = 10, b = 20; callbacks.push_back([=]{ printSum(a, b); }); // Add a callback for a function object std::string message = "Hello World"; PrintMessage printMessage; callbacks.push_back([=]{ printMessage(message); }); // Execute all callbacks for (auto& callback : callbacks) { callback(); } return 0; }
DelegationUsually related to event handling. There is no built-in event handling mechanism in C++, so std::function and Lambda expressions are often used to implement the delegation pattern. Specifically, you define a callback interface, and users can register their own functions or Lambda expressions to this interface so that they can be called when an event occurs. The general steps are as follows (by the way, an example):
- Defines the types that can be called: You need to determine what parameters your callback function or Lambda expression needs to accept and what type of result it returns.
using Callback = std::function ; // callback with no parameters and return value
- Create a class to manage callbacks: This class will hold all callback functions and allow users to add or remove callbacks.
class Button { private: std::vector onClickCallbacks; // Container for storing callbacks public: void addClickListener(const Callback& callback) { onClickCallbacks.push_back(callback); } void click() { for (auto& callback : onClickCallbacks) { callback(); // Execute each callback } } };
- Provide a method to add a callback: This method allows users to add their own functions or Lambda expressionsRegister as callback.
Button button; button.addClickListener([]() { std::cout << "Button was clicked!" << std::endl; });
- Provide a method to execute the callback:When necessary, this method willCall all registered callback functions.
button.click(); // User clicks the button to trigger all callbacks
Isn’t it very simple? Let’s take another example to deepen our understanding.
#include #include #include class Delegate { public: using Callback = std::function ; // Define the callback type, the callback here receives an int parameter // Register the callback function void registerCallback(const Callback& callback) { callbacks.push_back(callback); } // Trigger all callback functions void notify(int value) { for (const auto& callback : callbacks) { callback(value); // Execute callback } } private: std::vector callbacks; // container for storing callbacks }; int main() { Delegate del; // users register their own functions del.registerCallback([](int n) { std::cout << "Lambda 1: " << n << std::endl; }); // another Lambda expression del.registerCallback([](int n) { std::cout << "Lambda 2: " << n * n << std::endl; }); // trigger callback del.notify(10); // this will call all registered Lambda expressions return 0; }
8. Lambda in asynchronous and concurrent programming
All because Lambda has the function of capturing and storing state, which makes it very useful when we write modern C++ concurrent programming.
- Lambda and Threads
Use lambda expressions directly in the std::thread constructor to define the code that the thread should execute.
#include #include int main() { int value = 42; // Create a new thread, using a Lambda expression as the thread function std::thread worker([value]() { std::cout << "Value in thread: " << value << std::endl; }); // Main thread continues executing... // Wait for the worker thread to finish worker.join(); return 0; }
- Lambda and std::async
std::async is a tool that allows you to easily create asynchronous functions. After the calculation is completed, it returns a std::future object. You can call get, but it will block if the execution is not completed. There are many interesting things about async, which I will not go into here.
#include #include int main() { // Start an asynchronous task auto future = std::async([]() { // Do some work... return "Result from async task"; }); // In the meantime, the main thread can do other tasks... // Get the result of the asynchronous operation std::string result = future.get(); std::cout << result << std::endl; return 0; }
- Lambda and std::funtion
These two are often used together, so let's take an example of storing a callable callback.
#include #include #include #include // A task queue std::vector that stores std::function objects <std::function > tasks; // Function to add tasks void addTask(const std::function & task) { tasks.push_back(task); } int main() { // Add a Lambda expression as a task addTask([]() { std::cout << "Task 1 executed" << std::endl; }); // Start a new thread to process the task std::thread worker([]() { for (auto& task : tasks) { task(); // Execute task } }); // The main thread continues to execute... worker.join(); return 0; }
9. Generic Lambda (C++14)
Use the auto keyword to perform type inference in the argument list.
Generic basic syntax:
auto lambda = [](auto x, auto y) { return x + y; };
Example:
#include int main() { std::vector vi = {1, 2, 3, 4}; std::vector vd = {1.1, 2.2, 3.3, 4.4, 5.5}; // Use generic Lambda to print int elements std::for_each(vi.begin(), vi.end(), [](auto n) { std::cout << n << ' '; }); std::cout << '\n'; // Use generic Lambda to print double elements std::for_each(vd.begin(), vd.end(), [](auto n) { std::cout << n << ' '; }); std::cout << '\n'; // Use generic Lambda to calculate the sum of a vector of int auto sum_vi = std::accumulate(vi.begin(), vi.end(), 0, [](auto total, auto n) { return total + n; }); std::cout << "Sum of vi: " << sum_vi << '\n'; // Use generic Lambda to calculate the sum of a vector of double type auto sum_vd = std::accumulate(vd.begin(), vd.end(), 0.0, [](auto total, auto n) { return total + n; }); std::cout << "Sum of vd: " << sum_vd << '\n'; return 0; }
It is also possible to make a lambda that prints any type of container.
#include int main() { std::vector vec{1, 2, 3, 4}; std::list lst{1.1, 2.2, 3.3, 4.4}; auto print = [](const auto& container) { for (const auto& val : container) { std::cout << val << ' '; } std::cout << '\n'; }; print(vec); // print vector print(lst); // print list return 0; }
10. Lambda Scope
First, Lambda can capture local variables within the scope in which it is defined. After capture, even if the original scope ends, copies or references of these variables (depending on the capture method) can still continue to be used.
It is important to note that if a variable is captured by reference and the original scope of the variable has been destroyed, this will lead to undefined behavior.
Lambda can also capture global variables, but this is not achieved through a capture list, because global variables can be accessed from anywhere.
If you have a lambda nested inside another lambda, the inner lambda can capture variables in the capture list of the outer lambda.
When Lambda captures a value, even if the original value is gone and Lambda is gone (returned to somewhere else), all variables captured by the value will be copied to the Lambda object. The life cycle of these variables will automatically continue until the Lambda object itself is destroyed. Here is an example:
#include #include std::function createLambda() { int localValue = 100; // local variable return [=]() mutable { // copy localValue by value capture std::cout << localValue++ << '\n'; }; } int main() { auto myLambda = createLambda(); // Lambda copies localValue myLambda(); // Even if the scope of createLambda has ended, the copied localValue still exists in myLambdamyLambda(); // You can safely continue to access and modify the copy }
When lambda captures a reference, it’s another story. Smart readers should be able to guess that if the scope of the original variable ends, the lambda depends on a dangling reference, which will lead to undefined behavior.
11. Practice – Function Compute Library
After all this talk, it's time to put it into practice. No matter what you do, the following are the knowledge points you need to master:
- capture
- Higher-order functions
- Callable Objects
- Lambda Storage
- Mutable Lambdas
- Generic Lambda
Our goal in this section is to create a math library that supports vector operations, matrix operations, and provides a function parser that accepts a mathematical expression in string form and returns a computable Lambda. Let's get started right away.
This project starts with simple mathematical function calculations and gradually expands to complex mathematical expression parsing and calculations. Project writing steps:
- Basic vector and matrix operations
- Function parser
- More advanced math functions
- Composite Functions
- Advanced Mathematical Operations
- More expansion...
Basic vector and matrix operations
First, define the data structure of vectors and matrices and implement basic arithmetic operations (addition and subtraction).
In order to simplify the project and focus on the use of Lambda, I did not use templates, so all data is implemented with std::vector.
In the following code, I have implemented a basic vector framework. Please improve the framework by yourself, including vector subtraction, dot multiplication and other operations.
// Vector.h #include #include class Vector { private: std::vector elements; public: // Constructor - explicit to prevent implicit conversion Vector() = default; explicit Vector(const std::vector &elems); Vector operator+const Vector& rhs) const; // Get the vector size [[nodiscard]] size_t size() const { return elements.size(); } // Access the element and return a reference to the object double&. ::ostream& operator<<(std::ostream& os, const Vector& v); }; /// Vector.cpp #include "Vector.h" Vector::Vector(const std::vector<std::ostream> os, const Vector& v); }; /// Vector.cpp #include "Vector.h" Vector::Vector(const std::vector<std::ostream> os, const Vector& v); }; /// Vector.cpp #include "Vector.h" Vector::Vector(const std::vector<std::ostream> os, const Vector& v); }; /// Vector.cpp #include "Vector.h" Vector::Vector(const std::vector<std::ostream> os, const Vector& v); & elems) : elements(elems){} Vector Vector::operator+(const Vector &rhs) const { // First make sure the two vectors are consistent if( this->size() != rhs.size() ) throw std::length_error("Vector sizes are inconsistent!"); Vector result; result.elements.reserve(this->size()); // Allocate memory in advance// Use iterators to traverse each element of the vector std::transform(this->begin(), this->end(), rhs.begin(), std::back_inserter(result.elements), [](double_t a,double_t b){ return a+b; }); return result; } std::ostream& operator<<(std::ostream& os, const Vector& v) { os << '['; for (size_t i = 0; i < v.elements.size(); ++i) { os << v.elements[i]; if (i < v.elements.size() - 1) { os << ", "; } } os << ']'; return os; }
You can use the [[nodiscard]] tag in the declaration operation to remind the compiler to check whether the return value is used, and then users of the library will be reminded in the editor, such as the following.
Function parser
Design a function parser that can convert mathematical expressions in string form into Lambda expressions.
Creating a function parser that can parse mathematical expressions in string form and convert them into Lambda expressions involves parsing theory. To simplify the example, we currently only parse the most basic + and -. Then package the function parser into an ExpressionParser tool class.
First we create a parser that recognizes + and – signs:
// ExpressionParser.h #include #include using ExprFunction = std::function ; class ExpressionParser { public: static ExprFunction parse_simple_expr(const std::string& expr); }; // ExpressionParser.cpp #include "ExpressionParser.h" ExprFunction ExpressionParser::parse_simple_expr (const std::string &expr) { if (expr.find ('+') != std::string::npos) { return [](double x, double y) { return x + y; }; } else if (expr.find('-') != std::string::npos) { return [](double x, double y) { return x - y; }; } // More operations... return nullptr; }
This section is not very relevant to Lambda, so you can skip it. Then we can improve the function parser to recognize numbers based on this. Split the string into tokens (numbers and operators), and then perform operations based on the operators. For more complex expressions, you need to use algorithms such as RPN or existing parsing libraries, so I won't make it so complicated here.
// ExpressionParser.h ... #include ... static double parse_and_compute(const std::string& expr); ... // ExpressionParser.cpp ... double ExpressionParser::parse_and_compute(const std::string& expr) { std::istringstream iss(expr); std ::vector tokens; std::string token; while (iss >> token) { tokens.push_back(token); } if (tokens.size() != 3) { throw std::runtime_error("Invalid expression format."); } double num1 = std::stod(tokens[0]); const std::string& op = tokens[1]; double num2 = std::stod(tokens[2]); if (op == "+") { return num1 + num2; } else if (op == "-") { return num1 - num2; } else { throw std:: runtime_error("Unsupported operator."); } }
test:
// main.cpp #include "ExpressionParser.h" ... std::string expr = "10 - 25"; std::cout << expr << " = " << ExpressionParser::parse_and_compute(expr) << std ::endl;
Interested readers can also try to parse multiple operators using an operator precedence parsing algorithm (such as the Shunting Yard algorithm) to convert infix expressions to Reverse Polish Notation (RPN).
exhibitA little bit of nonsense about data structures, which has little to do with Lambda.
#include #include #include #include #include
More advanced math functions
AssumptionsOur parser is already able to recognize more advanced mathematical operations, such as trigonometric functions, logarithms, exponentials, etc. We need to provide a Lambda expression for the corresponding operation.
First we modify the aliases of two std::function with different signatures.
// ExpressionParser.cpp using UnaryFunction = std::function ; using BinaryFunction = std::function ; ... // ExpressionParser.cpp UnaryFunction ExpressionParser::parse_complex_expr (const std::string& expr) { using _t = std::unordered_map ; static const _t functions = { {"sin", [](double x) -> double { return std::sin(x); }}, {"cos", [](double x) -> double { return std::cos(x); }}, {"log", [](double x) -> double { return std::log(x); }}, // ... add more functions }; auto it = functions.find(expr); if (it != functions.end()) { return it->second; } else { // Handle error or return a default function return [](double) -> double { return 0.0; }; // Example error handling } }
Composite Functions
To implement compound mathematical functions, you can combine multiple Lambda expressions. Here is a small example:
#include #include #include int main() { // define the first function f(x) = sin(x) auto f = [](double x) { return std::sin(x); }; // define the second function g(x) = cos(x) auto g = [](double x) { return std::cos(x); }; // create the composite function h(x) = g(f(x)) = cos(sin(x)) auto h = [f, g](double x) { return g(f(x)); }; // use the composite function double value = M_PI / 4; // PI/4 std::cout << "h(pi/4) = cos(sin(pi/4)) = " << h(value) << std::endl; return 0; }
If you want a more complicated composite function, say $\text{cos}(\text{sin}(\text{exp}(x))$ , you can do this:
auto exp_func = [](double x) { return std::exp(x); }; // Create a composite function h(x) = cos(sin(exp(x))) auto h_complex = [f, g, exp_func](double x) { return g(f(exp_func(x))); }; std::cout << "h_complex(1) = cos(sin(exp(1))) = " << h_complex(1) << std::endl;
One of the advantages of using lambda expressions for function composition is that they allow you to easily create higher-order functions, that is, composite functions that are built on top of each other.
auto compose = [](auto f, auto g) { return [f, g](double x) { return g(f(x)); }; }; auto h_composed = compose(f, g); std:: cout << "h_composed(pi/4) = " << h_composed(M_PI / 4) << std::endl;
The above example is the core idea of higher-order functions.
Advanced Mathematical Operations
Implements differential and integral calculators that can use lambda expressions to approximate the derivatives and integrals of mathematical functions.
The differentiation here uses the forward difference method of numerical differentiation to approximate the reciprocal $f'(x)$.
The integration was performed using the numerical integration method using the trapezoidal rule.
// Derivative auto derivative = [](auto func, double h = 1e-5) { return [func, h](double x) { return (func(x + h) - func(x)) / h; }; }; // For example, derivative of sin(x) auto sin_derivative = derivative([](double x) { return std::sin(x); }); std::cout << "sin'(pi/4) ≈ " << sin_derivative(M_PI / 4) << std::endl; // Integration - lower limit a, upper limit b and number of divisions n auto trapezoidal_integral = [](auto func, double a, double b, int n = 1000) { double h = (b - a) / n; double sum = 0.5 * (func(a) + func(b)); for (int i = 1; i < n; i++) { sum += func(a + i * h); } return sum * h; }; // For example, integrate sin(x) from 0 to pi/2 auto integral_sin = trapezoidal_integral([](double x) { return std::sin(x); }, 0, M_PI / 2); std::cout << "∫sin(x)dx from 0 to pi/2 ≈ " << integral_sin << std::endl;
Numerical Differentiation – Forward Difference Method
The numerical approximation of the derivative of the function $$f(x)$$ at the point $$x$$ can be given by the forward difference formula:
Here $$h$$ represents a small increase in the value of $$x$$. When $$h$$ approaches 0, the ratio approaches the true value of the derivative. In the code, we set a relatively small value $$10^{-5}$$.
Numerical Integration – Trapezoidal Rule
The numerical approximation of the definite integral $$\int_a^bf(x) d x$$ can be calculated using the trapezoidal rule:
Where $$n$$ is the number of small intervals into which the interval $$[a, b]$$ is divided, and $$h$$ is the width of each small interval, which is calculated as:
Intermediate
1. Lambda's underlying implementation
On the surface, lambda expressions seem to be just syntactic sugar, but in fact, the compiler will perform some underlying transformations on each lambda expression.
First, the type of each lambda expression is unique. The compiler generates a unique class type for each lambda, which is often calledClosure Types.
The concept of closure comes from closure in mathematics. It refers to a structure whose internal operations are closed and do not depend on elements outside the structure. In other words, the result of applying any operation to the elements in the collection will still be in the collection. In programming, this word is used to describe the combination of a function and its context. A closure allows you to access variables in the scope of an outer function even if the outer function has finished executing. A function "closes" or "captures" the state of the environment when it is created. By default, the operator() of the closure class generated by lambda expressions is const. In this case, developers cannot modify any data inside the closure, which ensures that they will not modify the captured values, which is consistent with the mathematical and functional origins of closures.
The compiler generates aClosure ClassThis classOverloadoperator() is added so that the closure object can be called like a function. This overloaded operator contains the code of the lambda expression.
Lambda expressions cancaptureExternal variables, which are implemented as member variables of the closure class. Capture can be value capture or reference capture, corresponding to the copying of values and the storage of references in the closure class, respectively.
The closure class has a constructor that initializes the captured outer variables. If it is a value capture, these values are copied to the closure object. If it is a reference capture, the reference of the outer variable is stored.
When a lambda expression is called, the operator() of the closure object is actually called.
Assume the lambda expression is as follows:
[capture](parameters) -> return_type { body }
Here is some pseudo code that a compiler might generate:
// The pseudocode of the closure class may be as follows: class UniqueClosureName { private: // Captured variable capture_type captured_variable; public: // Constructor, used to initialize the captured variable UniqueClosureName(capture_type captured) : captured_variable(captured) {} // Overloaded function call operator return_type operator()(parameter_type parameters) const { // The body of the lambda expression } }; // Use an instance of the closure class UniqueClosureName closure_instance(captured_value); auto result = closure_instance(parameters); // This is equivalent to calling a lambda expression
2. Lambda types and decltype with conditional compilation constexpr (C++17)
As we know, each lambda expression has its own unique type, which is automatically generated by the compiler. Even if two lambda expressions look exactly the same, their types are different. These types cannot be expressed directly in the code, we use templates and type inference mechanisms to operate and infer them.
The decltype keyword can be used to obtain the type of a lambda expression. In the following example, decltype(lambda) obtains the exact type of the lambda expression. In this way, another variable another_lambda of the same type can be declared and the original lambda can be assigned to it. This feature generally plays an important role in template programming.
Look at the following example of a chef cooking. You don't know the type of the ingredient yet, but you can use decltype to get the type of the ingredient. The key point is that you can clearly get the type of the return value and mark the return type for the lambda.
template auto cookDish(T ingredient) -> decltype(ingredient.prepare()) { return ingredient.prepare(); }
Furthermore, an important use of decltype in C++ isCompile timeChoose different code paths according to different types, that is,Conditional compilation.
#include template void process(T value) { if constexpr (std::is_same ::value) { std::cout << "Processing integer: " << value << std::endl; } else if constexpr (std::is_same ::value) { std::cout << "handle floating point numbers: " << value << std::endl; } else { std::cout << "handle other types: " << value << std::endl; } }
The following example is about lambda.
#include #include // A generic function that performs different operations depending on the lambda type passed in template void executeLambda(T lambda) { if constexpr (std::is_same ::value) { std::cout << "Lambda is a void function with no parameters." << std::endl; lambda(); } else if constexpr (std::is_same ::value) { std::cout << "Lambda is a void function taking an int." << std::endl; lambda(10); } else { std::cout << "Lambda is of an unknown type." << std::endl; } } int main() { // Lambda with no parameters auto lambda1 = []() { std::cout << "Hello from lambda1!" << std::endl; }; // Lambda with one int parameter auto lambda2 = [](int x) { std::cout << "Hello from lambda2, x = " << x << std::endl; }; executeLambda(lambda1); executeLambda(lambda2); return 0; }
3. Lambda’s evolution in the new standard
C++11
- Introducing Lambda Expressions: Lambda expressions were first introduced in the C++11 standard, which can easily define anonymous function objects. The basic form is capture -> return_type { body }.
- Capture List: Supports capturing external variables by value (=) or reference (&).
C++14
- Generic Lambda: Allows the use of the auto keyword in the parameter list, making Lambda work like a template function.
- Capture Initialization: Allows the use of initializer expressions in capture lists to create lambda-specific data members.
C++17
- Default construction and assignment: The closure type produced by a lambda expression can be default constructible and assignable under certain conditions.
- Capture the *this pointer: By capturing *this, you can copy the current object to Lambda by value to avoid the dangling pointer problem.
- constexpr Lambda: constexpr Lambda can be used to perform calculations at compile time. It is particularly useful in scenarios such as template metaprogramming and compile-time data generation.
C++20
- Template Lambda: Lambda expressions can have template parameter lists, similar to template functions.
- More flexible capture lists: Capture lists of the form [=, this] and [&, this] are allowed.
- Implicit motion capture: Automatically use move capture when appropriate (only copy and reference capture are supported in C++14).
4. State-preserving Lambda
In the following example, the value and reference capture variable x is the key to keep the state of Lambda. It can also capture and maintain its own state.
#include int main() { int x0 = 10, x1 = 20, count = 0; auto addX = [x0, &x1, count](int y) mutable { count++; return x0 + x1 + y + count; }; std::cout << addX(5) << std::endl; // output 36 std::cout << addX(5) << std::endl; // output 37 std::cout << addX(5) << std::endl; // output 38 }
5. Optimization and Lambda
Why is Lambda good?
- Inline optimization: Lambda is generally short, and inline optimization reduces function call overhead.
- Avoid unnecessary object creation: Reference capture and move semantics can reduce the overhead of transferring and copying large objects.
- Deferred computation: Calculations are performed only when the result is actually needed.
6. Integration with other programming paradigms
Functional Programming
class StringBuilder { private: std::string str; public: StringBuilder& append(const std::string& text) { str += text; return *this; } const std::string& toString() const { return str; } }; // Use StringBuilder builder; builder.append("Hello, ").append("world! "); std::cout << builder.toString() << std::endl; // Output "Hello, world! "
Pipeline call
#include #include #include int main() { std::vector vec = {1, 2, 3, 4, 5}; auto pipeline = vec | std::views::transform([](int x) { return x * 2; }) | std::views::filter([](int x) { return x > 5; }); for (int n : pipeline) std::cout << n << " "; // Output elements that meet the conditions }
7. Lambda and Exception Handling
auto divide = [](double numerator, double denominator) { if (denominator == 0) { throw std::runtime_error("Division by zero."); } return numerator / denominator; }; try { auto result = divide( 10.0, 0.0); } catch (const std::runtime_error& e) { std::cerr << "Caught exception: " << e.what() << std::endl; }
Although a lambda expression itself cannot contain a try-catch block (before C++20), exceptions can be caught outside of a lambda expression. That is:
auto riskyTask = []() { // Assume that there is a possibility of exception being thrown here }; try { riskyTask(); } catch (...) { // Handle the exception }
Starting from C++20, lambda expressions support exception specifications.
Before C++17, you could use dynamic exception specifications in function declarations, such as throw(Type), to specify the types of exceptions that a function might throw. However, this practice was deprecated in C++17 and completely removed in C++20. Instead, the noexcept keyword is used to indicate whether a function throws an exception.
auto lambdaNoExcept = []() noexcept { // This guarantees that no exception will be thrown};
Advanced
1. Lambda and noexcept (C++11)
noexcept can be used to specify whether a lambda expression is guaranteed not to throw exceptions.
auto lambda = []() noexcept { // The code here is guaranteed not to throw an exception};
When the compiler knows that a function will not throw exceptions, it can generate more optimized code.
You can also explicitly throw exceptions to improve code readability, but it's the same as not writing any.
auto lambdaWithException = []() noexcept(false) { // Code here may throw an exception};
2. Template parameters in Lambda (C++20)
In C++20, Lambda expressions have received an important enhancement, which is the support of template parameters. How cool!
auto lambda = [] (T param) { // Code using template parameter T}; auto print = [] (const T& value) { std::cout << value << std::endl; }; print(10); // prints an integer print("Hello"); // prints a string
3. Lambda Reflection
I don’t know, I’ll write about it later.
4. Cross-platform and ABI issues
I don’t know, I’ll write about it later.
Leave a Reply