Demystifying C++: From C++23 Recursive Lambdas to Type Erasure
Published 2026-07-18
In this blog post, we will embark on a hardcore deep dive into C++. We’ll start from the “Explicit Object Parameter” and recursive lambdas introduced in C++23, dig into the magic of the compiler frontend (AST lowering), and finally deconstruct the core design pattern behind std::function: Type Erasure.
1. Lambdas and self: The Ultimate Remedy for Recursion in Modern C++
Writing recursive lambdas used to be a painful experience in competitive programming (CP) and daily development. C++23 introduced the Deducing this (Explicit Object Parameter) mechanism, allowing us to pass the closure object itself as a parameter.
auto dfs = [&](this auto&& self, int n) -> void {
if (n <= 1) return;
self(n - 1);
};
Why must we use this auto&& self?
Many wonder: since self is almost always an Lvalue in standard calls, why can’t we just write this auto self?
The answer lies in performance and state consistency:
- The fatal trap of
this auto self(pass-by-value): If your closure captures a large amount of data by value or holds internal state (like a counter), passing by value means the compiler will copy the entire closure class on every recursive call. This causes heavy overhead and loses state consistency during recursion unwinding. this auto&& self(forwarding reference): This is the ultimate zero-copy solution. Whether you pass a named Lvalue or an anonymous temporary Rvalue, it handles them perfectly while ensuring you are always operating on the exact same closure instance.
Why did C++23 ban mutable here?
If you append mutable to a lambda with an explicit object parameter, the compiler will throw an error: a lambda with an explicit object parameter cannot be mutable.
Traditional lambdas have a default const qualifier on their operator(), and mutable is used to strip it away. However, in C++23, self is explicitly passed as a parameter, meaning the constness of the function is completely deduced from the type of self. Since deduction handles the mutability, adding mutable becomes a syntactic paradox.
2. Unmasking Lambdas: The Magic of the Compiler Frontend
Lambda expressions are fundamentally just syntactic sugar. During the Abstract Syntax Tree (AST) analysis phase, the compiler frontend lowers them into anonymous closure types.
When we write:
int count = 0;
auto dfs = [&count](this auto&& self, int n) { ... };
the compiler frontend generates internal code that looks something like this:
class __Lambda_Unnamed_Closure {
private:
int& __captured_var; // Captured variables become member variables with obfuscated names
public:
__Lambda_Unnamed_Closure(int& _c) : __captured_var(_c) {}
template <typename Self>
void operator()(this Self&& self, int n) { ... }
};
This is why we cannot use self.count to access captured variables outside or inside the lambda—the C++ standard mandates that the true names of captured members remain hidden (implementation-defined) from the programmer.
3. Deciphering Unreadable C++ Types: The Spiral Rule
C++ type declarations can look like hieroglyphs, but with the right underlying logic, they are perfectly traceable.
The Right-to-Left Rule for Const
Always read from right to left, translating * to “pointer” and const to “constant”.
int const * a;->ais a pointer to a constant int.int * const a;->ais a constant pointer to an int.
The Inside-Out / Spiral Rule
For ultimate boss-level types like void (*(*f[])())(), we start from the name, look right, look left, and jump out when hitting parentheses.
The best practice in modern C++ is to peel the onion using using aliases:
using VoidFuncPtr = void (*)();
using FactoryFuncPtr = VoidFuncPtr (*)();
FactoryFuncPtr f[];
// f is an array filled with function pointers that return other function pointers.
4. std::function: Type Erasure and Runtime Polymorphism
If lambdas are highly performant and easily inlined, why do we need std::function at all?
Because every lambda is a unique anonymous class. This “reproductive isolation” means you cannot put two different lambdas into the same array, nor can you pass them across API boundaries without exposing templates in your headers.
std::function utilizes Type Erasure, trading compile-time polymorphism for runtime polymorphism.
The Under-the-Hood Mechanism
It internally defines a pure virtual interface base class. When you assign a lambda, it dynamically instantiates a templated wrapper class inheriting from that base:
CallableBase* ptr = new CallableWrapper<T>(lambda);
// Type erasure happens right here!
At the exact moment of upcasting the derived class to a base pointer, the concrete type T is erased. All future invocations rely on dynamic dispatch via the vtable.
Devirtualization
Although std::function introduces virtual call overhead, modern compilers don’t just sit idle. With -O2 or LTO (Link Time Optimization), the compiler performs Escape Analysis. If it can definitively prove the exact type a pointer holds, it will strip away the virtual dispatch and revert to a direct call. In C++, this is known as Devirtualization.
Conclusion
C++ is a language with no true magic. Whether it’s the elegant this auto&& of C++23 or the powerful type-erased container of std::function, they all share rigorous and self-consistent underlying implementations. Understanding these concepts means you have truly touched the soul of C++ architectural design.