Here’s an encapsulation challenge that I frequently run into: how to let users iterate over an internal data structure without leaking implementation details, but still giving them full control over the loop?
Implementing a custom iterator type requires significant boilerplate and/or complexity, depending on the underlying data structure.
Coroutines are simple and elegant, but the codegen is atrocious – definitely unsuitable for hot paths.
Lambdas seem to do the trick – but they can’t break!
This article walks through a lightweight solution to this classic problem.
the problem
Suppose we want to store a collection of Entity objects while tracking their liveness. A possible internal representation might look like:
We want users to iterate over valid entities without revealing that we’re using std::optional or a std::vector under the hood. So exposing m_entities directly is not an option.
A custom iterator could solve this, but writing and maintaining one can be boilerplate-heavy – especially as the complexity of the data structure increases.
Coroutines (e.g., std::generator) have beautiful syntax, but the generated code is inefficient, especially for real-time applications.
That leaves us with callbacks – i.e., higher-order functions – but there’s a problem: break and continue don’t work inside lambdas.
higher-order iteration
Let’s add a simple lambda-based iteration member function:
(We could use concepts to constrain f, but it’s not a big deal here.)
The usage is delightfully clean:
This works fine for visiting all valid entities – but what if you want to skip some or exit early?
you’ll never be a real loop
Even if it visually resembles a loop, we are actually invoking a function multiple times. Neither continue nor break would work here:
These won’t compile – break and continue are scoped to actual loops, and a lambda isn’t one.
You can simulate continue with return:
However, the use of return can be misleading to readers. Regardless, still doesn’t give you a way to break out of the underlying loop.
do what I say
Let’s define an enum class that represents what we want to do after each lambda call (i.e. “iteration”):
Now, we expect ControlFlow as to be returned from the lambda:
Finally, the user can either explicitly continue or break from within the lambda:
is it fast?
You might be wondering if this approach is slower compared to a regular loop. You might also be wondering why we didn’t just use coroutines and std::generator. The answer is all in the codegen.
Let’s first compare direct iteration versus a higher-order function without ControlFlow:
Without optimizations enabled, there is significant overhead due to a call instruction being emitted every time the lambda is invoked.
(The overhead can be almost entirely be removed by marking the lambda with __attribute__((always_inline)). I don’t recommend doing that in general, but it might be a reasonable thing to do for hot loops in low-level parts of a library/application that you want to be as fast as possible in -O0.)
As soon as we switch on -O1, both f_direct and f_hof produce the same exact code.
Let’s now introduce our ControlFlow into the mix:
Even for f_hof_cf, the codegen is exactly the same!
But what about coroutines? It would be extremely nice to write:
Unfortunately, the codegen is quite bad, even with -O2 – check it out for yourself. Despite this being a very simple coroutine, a heap allocation is needed, and dozens of extra bookkeeping instructions are produced.
That’s a shame, because the syntax is extremely nice, and co_yield allows us to elegantly express any form of iteration/visitation over arbitrarily complex data structures.
Hopefully compilers will be able to do a better job here in the future, however this will always likely be an unacceptable technique for hot paths in realtime applications (e.g. games, audio processing, simulations, etc.).
For many performance-sensitive applications, ControlFlow emerges as a practical winner!
smoothing out the edges
While effective, the ControlFlow approach introduces boilerplate. Even in the simplest case where the user never wants to break and just wants to process all entities, they are forced to add return ControlFlow::Continue; at the end of their lambda.
Let’s fix that!
We can infer the user’s intent with if constexpr and the std::is_void_v type trait:
Above, decltype(f(*optEntity)) evaluates to the return type of the user-provided lambda. If that return type is void, we assume that the user always wants to continue.
This allows omitting return ControlFlow::Continue; in the simple case – the boilerplate is only there when needed:
make it dry
The if constexpr logic inside forEntities is neat, but if we have many such iteration functions (forEntitiesMatching, forEntitiesInGroup, etc.), this logic will be repeated. We can encapsulate this “regularization” of the lambda’s return value into a helper function:
(I deliberately omitted perfect-forwarding above to make the code easier to read. In a real implementation, std::forward<F>(f)(std::forward<Args>(args)...) should be used instead of f(args...).)
Now, forEntities (and similar functions) become wonderfully clean:
optional: let me out!
What if you want the loop to not only break, but also exit the calling function?
This is a bigger leap, not always needed – but let’s explore a solution anyway.
In a normal loop, return would exit from the function containing the loop. However, using return within a lambda only exits the scope of that lambda. We must expand ControlFlow to represent the intent of returning:
forEntities needs to change as well, propagating the ControlFlow to its caller:
The user code would then look like this:
This is clunky, but sometimes necessary in deeply nested control flows.
And that’s the gist of it! A simple enumeration, combined with a hint of compile-time introspection, can significantly enhance the flexibility of higher-order iteration functions.
training and mentoring
- Interested in deeply understanding Modern C++ concepts?
- Would you like your C++ codebase to compile in seconds, not minutes?
- Having trouble managing complexity in your games or applications?
- Been hunting down a bug for days with little success?
Exciting news! I’m now offering bespoke C++ training, mentoring, and consulting services.
Check out romeo.training, alternatively you can reach out at mail (at) vittorioromeo (dot) com or on Twitter.
shameless self-promotion
Check out my newly-released game on Steam: BubbleByte – it’s only $4.99 and one of those games that you can either play actively as a timewaster or more passively to keep you company in the background while you do something else.
My book “Embracing Modern C++ Safely” is available from all major resellers.
- For more information, read this interview: “Why 4 Bloomberg engineers wrote another C++ book”
If you enjoy fast-paced open-source arcade games with user-created content, check out Open Hexagon, my VRSFML-powered game available on Steam and on itch.io.
- Open Hexagon is a community-driven spiritual successor to Terry Cavanagh’s critically acclaimed Super Hexagon.