Rust enums in Modern C++ – Match Pattern
This month I did a post about how the std::variant is the equivalent of a rust enum in C++. This time I will present to you a kind of a match pattern that is possible with std::visit that will warn you for unused values.
Table of Contents
Disclaimer: Objective Opinion
This article aims to be pretty objective. Since on my last article there were quite a few people which commented or wrote me that Rust is way better and C++ has some cheap option that is not worth the attention. To address this comments this time I start with a disclosure that:
- I am not undermining the rust language
- C++’s version is not built into the language
- The goal of the article is not to convert people from Rust to C++
Otherwise rust’s superiority is still in the hands of the programmer that writes the code. Since the lazy developer can just define a default handler which will go around this compiler “error” meaning you just have more syntax for cases you might not care about. It is the same as with the rust error types and optionals which you could just unwrap()
which essnetially again is just more syntax for the bad developer who would unwrap everything.
Now I am not hating on Rust as it is a great tool but to be efficient the programmer has to make use of the tool without having to circumvent the compiler warnings or errors. It is still better in some ways than C++ as it will make you write out more syntax essentially blaming you for unwrapping or default matching all those values.
Visitor Overload Pattern
In C++ for variants there is the visitor pattern. The visitor pattern is based around the std::visit
function which takes a callable object and then variable number of arguments that are variants that can be matched to at least one function of the callable object.
If you look at the C++ reference site for this visitor pattern there is a suggestion that you can use lambdas in such a way that it almost looks like a match statement:
using var_t = std::variant<int, long, double, std::string>;
// helper type for the visitor #4
template<class... Ts>
struct overloaded : Ts... { using Ts::operator()...; };
// explicit deduction guide (not needed as of C++20)
template<class... Ts>
overloaded(Ts...) -> overloaded<Ts...>;
int main()
{
std::vector<var_t> vec = {10, 15l, 1.5, "hello"};
// ...
for (auto& v: vec)
{
// 4. another type-matching visitor: a class with 3 overloaded operator()'s
// Note: The `(auto arg)` template operator() will bind to `int` and `long`
// in this case, but in its absence the `(double arg)` operator()
// *will also* bind to `int` and `long` because both are implicitly
// convertible to double. When using this form, care has to be taken
// that implicit conversions are handled correctly.
std::visit(overloaded {
[](auto arg) { std::cout << arg << ' '; },
[](double arg) { std::cout << std::fixed << arg << ' '; },
[](const std::string& arg) { std::cout << std::quoted(arg) << ' '; }
}, v);
}
}
In this example it uses a vector of variant types and you can see that each of them is mapped to one of the 3 functions passed to the overloaded struct type. The overloaded
struct is constructed to be callable with the lambdas that are passed.
This should actually be pretty much all you need to match all values since it will warn you if the overloaded values are missing any of the variants and cannot map to anything. They will map to the most appropriate variant otherwise and you can even have a default handler through the auto keyword.
Modifying the Overload Pattern
I use this exact pattern but with a little modification through a few macros. I know people don’t like macros for a few reasons but in this case it is pretty safe since there is little chance those exact macros to match existing code names of functions.
I introduce exactly three macros essentially splitting the std::visit
statement into 3 parts: The beggining before the first curly brackets, each case to hide the lambda expressiveness, and finally the value which comes at the end.
template<class... Ts>
struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts>
overloaded(Ts...) -> overloaded<Ts...>;
/// Starts the std::visit function
#define do_match std::visit(overloaded
/// The first part of a lambda statement
#define match_case(T) [&](T& var)
/// Closes the std::visit function passing in the matched value
#define match_value(value) , value)
If you can’t imagine it yet let’s take the example from my last post which matches the examples that rust gives. We have two IP structs for each different IP version. They each have a string as a value. Then the usage with the macros will resemble more or less a match statement as if it was integrated in the language itself:
struct IPv4 { std::string value; };
struct IPv6 { std::string value; };
int main() {
std::variant<IPv4, IPv6> variant_value = IPv4{"192.168.0.1"};
do_match {
match_case(IPv4) { std::cout << "IPv4: " << var.value << std::endl; },
match_case(IPv6) { std::cout << "IPv6: " << var.value << std::endl; },
match_case(auto) { std::cout << "Any case" << std::endl; },
} match_value(variant_value);
return 0;
}
The lambdas in the macro are accepting everything by reference so that the end result resembles as if the match statmenet is executed in place. It does define them as lambdas and this code will still jump around even if you optimize it so it might be slower than a equivalent rust program. I haven’t profiled it separately but it does improve the readability as well as the writing by a lot.
Conclusion
This is a short article again but I hope you learned something new and I gave you ideas on how to improve the visibility of your code. If you have a better solution or an opinion on this approach don’t hesitate to comment down bellow or write to me directly. I am open to discussions.
Leave a comment