cpp-switcheroo: A reimagined switch-case
12 July 2024
When teaching C++ to beginners,
I often find myself explaining the switch
statement.
It’s a simple and powerful tool that allows you to execute different code blocks based
on the value of integral types.
At the same time, it has some drawbacks, like the lack of type safety, forgetting a break
statement,
and of course, that you are limited to integral types.
Drawing some inspiration from other languages and the
“Overload” pattern, I created cpp-switcheroo
.
cpp-switcheroo is a header-only library that provides a compile-time “switch-like” statement for C++17 and later.
It is a safer alternative to the traditional switch
statement, and a (IMHO) more readable alternative to
visiting std::variant
types with the “Overload” pattern. Let’s illustrate the issues with the traditional switch
statement and how cpp-switcheroo
can help.
enum class Color { Red, Green, Blue };
int main()
{
const Color color{Color::Green};
std::string colorName{};
switch (color) {
case Color::Red:
colorName = "Red";
break;
case Color::Green:
colorName = "Green";
break;
case Color::Blue:
colorName = "Blue";
break;
default:
colorName = "Unknown";
}
std::cout << colorName << std::endl;
return 0;
}
The classic switch
statement is easy to understand and familiar, it’s very efficient, and it works with any C++ standard.
However, it can only be used with integral types, you can forget a case
or break
statement, and there is no type safety.
For example, you may static cast an integer to the enum type, which is unsafe.
That being said, my primary concern is that it’s not error-proof for forgetful developers like me.
Additionally, if you are using the switch statement to set a value to a variable, you may need
to initialize the variable with a default value and have it as a non-constant, which is not ideal.
Alternatively, you may try to use a lambda and immediately invoke it, but that’s not very readable:
const std::string colorName = [color]() {
switch (color) {
case Color::Red:
return "Red";
case Color::Green:
return "Green";
case Color::Blue:
return "Blue";
default:
return "Unknown";
}
}();
To alleviate these issues, a common pattern is to use std::visit
with the
“Overload” pattern.
Let’s see how it looks like if you are using the C++20 standard:
#include <iostream>
#include <variant>
struct Red {};
struct Green {};
struct Blue {};
using Color = std::variant<Red, Green, Blue>;
template<typename... Ts>
struct Overload : Ts... {
using Ts::operator()...;
};
int main()
{
const Color color{Green{}};
const auto colorName = std::visit(Overload{[](Red) { return "Red"; },
[](Green) { return "Green"; },
[](Blue) { return "Blue"; }},
color);
std::cout << colorName << std::endl;
const auto anotherColorName = std::visit(Overload{[](Red) { return "Red"; },
[](auto) { return "not red"; }},
color);
std::cout << anotherColorName << std::endl;
return 0;
}
The tricky part which I still find confusing is the Overload
struct.
The simplified explanation is that it’s a struct that inherits from all the lambdas you provide.
This means that Overload
now has inherited all the operator()
functions from the lambdas,
and you can use when std::visit
ing the std::variant
.
The std::variant
by the way is a type-safe union, meaning it can hold one of the types you specify.
In the example above, it can hold either a Red
, a Green
, or a Blue
type.
The cool thing about this approach is that you cannot forget a case. If you forget to specify a lambda for a type,
there will be a compilation error. There’s no way to accidentally forget a break
statement either and it’s type-safe.
If you notice, you may use any type in the Overload
struct, meaning you can access the value of the variant,
unlike the traditional switch
statement.
In my opinion, the drawback of Overload
is that it’s hard to grasp for someone who is not already familiar with the pattern.
Of course you can get used to it, but let me show you my approach with cpp-switcheroo
.
This approach should be taken as a fresh take on the issues of the traditional switch
statement
and the low readability of the “Overload” pattern:
#include <iostream>
#include "switcheroo/switcheroo.h" // 1
struct Red { auto c_str() const { return "red"; } };
struct Green {};
struct Blue {};
using Color = std::variant<Red, Green, Blue>; // 2
int main()
{
using namespace switcheroo;
const Color color{Green{}};
const auto colorName = match(color) // 3
.when<Red>([](const Red& r) { return r.c_str(); }) // 4
.when<Green>([](auto) { return "green"; }) // 5
.when<Blue>([]() { return "blue"; }) // 6
.run(); // 7
std::cout << colorName << std::endl; // 8
return 0;
}
- Include the
switcheroo/switcheroo.h
header file. This is a single-header library with no dependencies. - Optionally create an alias for the
std::variant
type.Color
can hold aRed
, aGreen
, or aBlue
type. - Call
switcheroo::match
with the variant you want to match. - Define what to do when
Color
is aRed
. You can access the value of the variant and return a value. - You may ignore the value of the variant. All return types must be the same.
- If you are to ignore the value of the variant, you can alternatively omit the lambda parameter.
- Call
run
to execute the lambda that matches the variant and return a value if specified. colorName
’s type is deduced by the return type of the lambda. In this case, aconst char*
.
I find this approach easier to understand than the “Overload” pattern. Let’s see some more examples:
const auto anotherColorName = match(color)
.when<Red>([] () { return "red"; })
.otherwise([] (auto greenOrRed) {
// Do something with greenOrRed
return "not red";
})
.run();
In this example, we use otherwise
to specify a default case. If you have already covered all the cases,
you cannot use otherwise
. This is a safety feature to prevent you from adding a default case if all cases are covered.
When using otherwise
, you may access the value of the variant. However, since you typically don’t know the type in advance,
you should use auto
as the type of the lambda parameter.
const auto colorValue = match(color)
.when<Red, Green>([] { return 1; })
.when<Blue>([] { return 2; })
.run();
In this example, we use when
with multiple types. This is useful when you want to combine multiple cases.
It’s similar to “fallthrough” cases in the traditional switch
statement.
I’ve also skipped the empty ()
in the lambda for brevity.
By the way, if your when
statement has multiple types and want to use the value of the variant,
you should use auto
as the type of the lambda parameter to accommodate all types.
Next, I want to show you a trick. It’s not a feature of cpp-switcheroo
,
but it’s a neat trick that allows you to use enum
values as types.
This can be useful if you have some existing code that uses a switch
statement
and you want to migrate to cpp-switcheroo
.
Let’s say you have a Month
enum and a function that returns true
if the month is June, July, or August:
enum class Month {
January,
February,
March,
April,
May,
June,
July,
August,
September,
October,
November,
December
};
bool isGoodWeather(Month month)
{
switch (month) {
case Month::June:
case Month::July:
case Month::August:
return true;
default:
return false;
}
}
You can wrap the enum
values in a template class and use them as types in a std::variant
:
template<Month T>
struct Wrapped {
static constexpr Month value = T; // Optional but useful
}; // 1
using MonthT = std::variant<Wrapped<Month::January>,
Wrapped<Month::February>,
Wrapped<Month::March>,
Wrapped<Month::April>,
Wrapped<Month::May>,
Wrapped<Month::June>,
Wrapped<Month::July>,
Wrapped<Month::August>,
Wrapped<Month::September>,
Wrapped<Month::October>,
Wrapped<Month::November>,
Wrapped<Month::December>>; // 2
const MonthT month{Wrapped<Month::February>{}}; // 3
const auto goodWeather
= match(month)
.when<Wrapped<Month::June>>([] { return true; })
.when<Wrapped<Month::July>>([] { return true; })
.when<Wrapped<Month::August>>([] { return true; })
.otherwise([] { return false; })
.run(); // 4
- Wrap the
enum
values in theWrapped
template class. We useMonth
as a non-type template parameter, which is allowed since it’s practically an integer, i.e. a constant-evaluated expression. - Create a
std::variant
type that holds the wrappedenum
values and alias it asMonthT
. - Create a variant and assign a wrapped
enum
value to it, in this caseMonth::February
. - Match the variant and return
true
if the month is June, July, or August, otherwise returnfalse
.
You may of course combine the cases within a single when
statement:
const auto goodWeather
= match(month)
.when<Wrapped<Month::June>,
Wrapped<Month::July>,
Wrapped<Month::August>>([] { return true; })
.otherwise([] { return false; })
I’ve prepared a comparison table between the traditional switch
statement, the “Overload” pattern, and cpp-switcheroo
.
Don’t take it too seriously. I understand I am proposing a potentially over-engineered solution
for a problem that may not exist for many developers, who don’t see any issues with the traditional switch
statement
or the “Overload” pattern.
Feature | switch |
Overload |
cpp-switcheroo |
---|---|---|---|
Use with many types | ❌ | ✅ | ✅ |
Combine multiple cases | ✅ | ❌ | ✅ |
Inhibit forgetting a case | ❌ | ✅ | ✅ |
Avoid unnecessary default case | ❌ | ❌ | ✅ |
Easy to understand | 🥇 | 🥉 | 🥈 (IMHO) |
Works with standards before C++17 | ✅ | ❌ | ❌ |
Type-safe | ❌ | ✅ | ✅ |
Requires additional code | 🥇 | 🥈 | 🥉 |
Efficiency | 🥇 | 🥈 | 🥉 |
Overall, I hope you find cpp-switcheroo
if not useful then at least interesting.
It’s fun to experiment with reimagining the most fundamental parts of a programming language.
Writing this library was also a satisfying exercise in template metaprogramming for me.
While templates can be daunting, they are powerful tools that can help write safer code
by moving checks from runtime to compile-time.
👉 cpp-switcheroo is available on GitHub under an MIT license, as a single-header library with no dependencies.