How to Filter a Collection by Arbitrary Types in Modern C++ (Variadic Templates)
Eliminating Template Boilerplate with Variadic Templates and Fold Expressions
When working with Entity Component Systems (ECS) or type-driven architectures in C++, you often need to query or filter elements based on an arbitrary list of types. Writing separate overloads for one type, two types, three types, and so on leads to massive code duplication that is tedious and error-prone to maintain.
With variadic templates (introduced in C++11) and fold expressions (introduced in C++17 and expanded in C++20/C++23), you can write a single, clean function that accepts any number of types.
The Core Problem: AND vs. OR Semantics
In the original snippet, each check uses a guard clause:
if (myInt != GetComponentId<T>()) continue;
if (myInt != GetComponentId<U>()) continue;
Logically, if myInt must not differ from GetComponentId<T>() and also must not differ from GetComponentId<U>(), myInt would have to equal both IDs simultaneously. In real-world component lookups, you usually want one of two behaviors:
- Match Any (OR): The ID equals component
Tor componentU. - Entity Match All (AND): In full ECS engines, an entity must possess component
Tand componentU.
Fold expressions can effortlessly handle either case.
Solution 1: Fold Expressions with Variadic Templates
You can define a parameter pack typename... Components and unpack the condition using a fold expression:
#include <set>
template <typename T>
int GetComponentId(); // Mandatory external helper
class Container {
std::set<int> allMyIntegers{};
public:
// Overload for zero arguments: return everything
[[nodiscard]] std::set<int> GetInts() const {
return allMyIntegers;
}
// Overload for one or more types
template <typename... Components>
requires (sizeof...(Components) > 0)
[[nodiscard]] std::set<int> GetInts() const {
std::set<int> filteredInts{};
for (const int myInt : allMyIntegers) {
// Match ANY type (OR condition):
// Expands to: (myInt == GetComponentId<C1>() || myInt == GetComponentId<C2>() || ...)
if ((... || (myInt == GetComponentId<Components>()))) {
filteredInts.insert(myInt);
}
}
return filteredInts;
}
};
If you genuinely intended the strict AND condition from the question details, simply change the binary operator in the fold:
// Match ALL types (AND condition):
if ((... && (myInt == GetComponentId<Components>()))) {
filteredInts.insert(myInt);
}
Solution 2: Modern C++23 Ranges and std::ranges::to
In C++23, you can leverage ranges and views along with std::ranges::to to construct the resulting std::set in a single, expressive pipeline:
#include <ranges>
#include <set>
class Container {
std::set<int> allMyIntegers{};
public:
template <typename... Components>
[[nodiscard]] std::set<int> GetInts() const {
// If no types are provided, return the whole set
if constexpr (sizeof...(Components) == 0) {
return allMyIntegers;
} else {
auto matches = [](int id) {
return (... || (id == GetComponentId<Components>()));
};
return allMyIntegers
| std::views::filter(matches)
| std::ranges::to<std::set<int>>();
}
}
};
Key Benefits of This Approach
- Zero Boilerplate: A single template function replaces infinite hand-written overloads.
- Compile-Time Optimization: The compiler unrolls the fold expression directly. If
GetComponentId<T>()isconstexpr, the IDs are calculated at compile time, eliminating runtime function call overhead. - Expressive Intent: Using
if constexpr (sizeof...(Components) == 0)lets you combine the zero-argument fallback and the multi-argument filter into a single unified function.