Introduction
Today, I'll guide you through the thought process behind computing the Cartesian product of two sets (std::tuples
in our case) in compile time with C++. Instead of just describing what each piece of code does, let's talk about why we wrote it the way we did and how everything comes together.
The Idea
Given two std::tuple
types, we aim to generate all possible pairwise combinations of the types contained within them and store the results in a std::variant
. Instead of manually specifying each combination, we leverage metaprogramming to do the heavy lifting for us.
using FirstTypeTuple = std::tuple<A, B, C>;
using SecondTypeTuple = std::tuple<D, E, F>;
And we want to transform them into:
std::variant<std::tuple<A, D>, std::tuple<A, E>, std::tuple<A, F>,
std::tuple<B, D>, std::tuple<B, E>, std::tuple<B, F>,
std::tuple<C, D>, std::tuple<C, E>, std::tuple<C, F>>;
Step 1: Converting a Tuple to a Variant
The first step is to implement a helper to convert a std::tuple
into a std::variant
as we will be using a tuple of tuples to store the generated type pairs.
template <typename... Tuple>
struct TupleToVariant;
template <typename... Types>
struct TupleToVariant<std::tuple<Types...>> {
using type = std::variant<Types...>;
};
Step 2: Generating the Cartesian Product
Next, we need to generate all possible type pairs between two tuples. After breaking this into two smaller problems, it's actually very easy to implement using template parameter packs.
First, we create a mechanism to pair a type with all the types in the second tuple (SecondTypes
). This is done by the pairs
alias template.
Then, we repeat this process for every type in the first tuple (FirstTypes
) and then concatenate the resulting pairs into a single tuple, as shown in the next line.
template <typename... Tuples>
struct CartesianVariantCtor;
template <typename... FirstTypes, typename... SecondTypes>
struct CartesianVariantCtor<std::tuple<FirstTypes...>, std::tuple<SecondTypes...>> {
template <typename T>
using pairs = std::tuple<std::tuple<T, SecondTypes>...>;
using type = typename TupleToVariant<decltype(std::tuple_cat(std::declval<pairs<FirstTypes>>()...))>::type;
};
Step 3: Making It Easy to Use
To simplify usage, we define an alias:
template <typename FirstTuple, typename SecondTuple>
using CartesianVariant = typename CartesianVariantCtor<FirstTuple, SecondTuple>::type;
Now we can just say:
using CV = CartesianVariant<FirstTypeTuple, SecondTypeTuple>;
Example Usage
Let's put it all together:
using FirstTypeTuple = std::tuple<A, B, C>;
using SecondTypeTuple = std::tuple<D, E, F>;
using CV = CartesianVariant<FirstTypeTuple, SecondTypeTuple>;
And we can verify that it is indeed computed in compile time by using static_assert
along with some fancy constexpr
parsing functions that belong to details (C++23).
constexpr auto types = GetVariantTypes<CV>();
static_assert(types == std::array<std::string_view, 9>{"A, D", "A, E", "A, F", "B, D", "B, E", "B, F", "C, D", "C, E", "C, F"});
But Why?
You might be wondering—what's the purpose of something like this? To be honest, the practical uses are limited. Originally, I created it for an old renderer where I wanted to pair any shape with any material and store everything in a vector (thus std::variant
).
Sure, runtime polymorphism could have handled this, but I had the idea in mind and thought, "Why not?" I even hoped for a performance boost since it would eliminate virtual function calls. However, it ended up adding more complexity than I anticipated later in the project, so I ultimately abandoned it.
Still, I think it's a cool concept, which is why I decided to share it here. Complete source code is listed below. Hope you enjoyed!