cpp-switcheroo: A reimagined switch-case

12 July 2024

cpp-switcheroo header image

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::visiting 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;
}
  1. Include the switcheroo/switcheroo.h header file. This is a single-header library with no dependencies.
  2. Optionally create an alias for the std::variant type. Color can hold a Red, a Green, or a Blue type.
  3. Call switcheroo::match with the variant you want to match.
  4. Define what to do when Color is a Red. You can access the value of the variant and return a value.
  5. You may ignore the value of the variant. All return types must be the same.
  6. If you are to ignore the value of the variant, you can alternatively omit the lambda parameter.
  7. Call run to execute the lambda that matches the variant and return a value if specified.
  8. colorName’s type is deduced by the return type of the lambda. In this case, a const 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
  1. Wrap the enum values in the Wrapped template class. We use Month as a non-type template parameter, which is allowed since it’s practically an integer, i.e. a constant-evaluated expression.
  2. Create a std::variant type that holds the wrapped enum values and alias it as MonthT.
  3. Create a variant and assign a wrapped enum value to it, in this case Month::February.
  4. Match the variant and return true if the month is June, July, or August, otherwise return false.

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.