The evolution of enums

Sandor Dargo - Feb 15 '23 - - Dev Community

Constants are great. Types are great. Constants of a specific type are really great. This is why enum classes are just fantastic.

Last year, we talked about why we should avoid using boolean function parameters. One of the solutions proposed uses strong types, in particular using enums instead of raw booleans. This time, let's see how enums and the related support evolved during the course of the years.

Unscoped enumerations

Enumerations are part of the original C++ language, in fact, they were taken over from C. Enumerations are distinct types with a restricted range of values. The range of values is restricted to some explicitly named constants. Let's quickly have a look at an enum.

enum Color { red, green, blue };
Enter fullscreen mode Exit fullscreen mode

After having read this very small example, it's worth noticing two things:

  • The enum itself is a singular noun, even though it enumerates multiple values. We use such conventions because we keep in mind that it will be always used with one value. If you take a Color function parameter, one colour will be taken. When you compare against a value, you'll compare against one value. E.g. it reads better to compare against Color::red than against Colors::red
  • The enumerator values are not written in ALL_CAPS! Even though there is a fair chance that you are used to that. I also used to do that. So why didn't I follow that practice? Because for writing this article, I checked the core guidelines and Enum.5 clearly says that we should not use ALL_CAPS in order to avoid clashes with macros. By the way, Enum.1 clearly said that we should use enumerations over macros.

Since C++11, the number of possibilities to declare an enum grew. C++11 introduced the possibility of specifying the underlying type of an enum. If it's left undefined, the underlying type is implementation-defined but in any case, it's an integral type.

How to specify it? Syntax-wise it might seem a bit like inheritance! Though there are no access levels to define.

enum Color : int { red, green, blue };
Enter fullscreen mode Exit fullscreen mode

With that you can be sure what the underlying type is. Still, it must be an integral type! For example, it cannot be a string. Should you try that and you'll get a very explicit error message:

main.cpp:4:19: error: underlying type 'std::string' {aka 'std::__cxx11::basic_string<char>'} of 'Color' must be an integral type
    4 | enum Color : std::string { red, green, blue };
      |                   ^~~~~~
Enter fullscreen mode Exit fullscreen mode

Note that the core guidelines advocate against this practice! You should only specify the underlying value if it is necessary.

Why can it be necessary? It gives us two reasons.

  • If you know that the number of choices will be very limited and you want to save a bit of memory:
enum Direction : char { north, south, east, west,
     northeast, northwest, southeast, southwest }; 
Enter fullscreen mode Exit fullscreen mode
  • Or if you happen to forward declare an enum then you also must declare the type;
enum Direction : char;
void navigate(Direction d);

enum Direction : char { north, south, east, west,
     northeast, northwest, southeast, southwest }; 
Enter fullscreen mode Exit fullscreen mode

You can also specify the exact value of one or all the enumerated values as long as they are constexpr.

enum Color : int { red = 0, green = 1, blue = 2 };
Enter fullscreen mode Exit fullscreen mode

Once again, the guidelines recommends us not to do this, unless it's necessary! Once you start doing it, it's easy to make mistakes and mess it up. We can rely on the compiler assigning subsequent values to the subsequent enumerator values.

  • A good reason to specify the enumerator value is to define only the starting value. If you define the months and you don't want to start with zero.
enum Month { jan = 1, feb, mar, apr, may, jun,
                   jul, august, sep, oct, nov, dec }; 
Enter fullscreen mode Exit fullscreen mode
  • Another reason can be if you want to define the values as some meaningful character
enum altitude: char {
    high = 'h',
    low = 'l'
}; 
Enter fullscreen mode Exit fullscreen mode
  • One other reason can be emulating some bitfields. So you don't want subsequent values, but you always want the next power of two
enum access_type { read = 1, write = 2, exec = 4 };
Enter fullscreen mode Exit fullscreen mode

Scoped enumerations

In the previous section, we saw declarations such as enum EnumName{};. C++11 brought a new type of enumeration called scoped enums. They declared either with the class or with the struct keywords and there is no difference between those two.

The syntax is the following:

enum class Color { red, green, blue };
Enter fullscreen mode Exit fullscreen mode

For scoped enumerations the default underlying type is defined in the standard, it is int. This also means that if you want to forward declare a scoped enum, you don't have to specify the underlying type. If it is meant to be int, this is enough:

enum class Color;
Enter fullscreen mode Exit fullscreen mode

Apart from how the syntactical differences between how they are declared, what other differences exist?

Unscoped enums can be implicitly converted to their underlying type. Implicit conversions are often not what you want, and scoped enums don't have this "feature". Exactly because of the unwelcome implicit conversions, the Core Guidelines strongly recommends using scoped over unscoped enums.

void Print_color(int color);

enum Web_color { red = 0xFF0000, green = 0x00FF00, blue = 0x0000FF };
enum Product_info { red = 0, purple = 1, blue = 2 };

Web_color webby = Web_color::blue;

// Clearly at least one of these calls is buggy.
Print_color(webby);
Print_color(Product_info::blue);
Enter fullscreen mode Exit fullscreen mode

Unscoped enums export their enumerators to the enclosing scope which might lead to name clashes. On the other hand, with scoped enums, you must always specify the name of the enum alongside with the enumerators.

enum UnscopedColor { red, green, blue };
enum class ScopedColor { red, green, blue };

int main() {
    [[maybe_unused]] UnscopedColor uc = red;
    // [[maybe_unused]] ScopedColor sc = red; // Doesn't compile
    [[maybe_unused]] ScopedColor sc = ScopedColor::red;
}
Enter fullscreen mode Exit fullscreen mode

What else

Now that we saw how un/scoped enums work and what are the differences between them, let's see what other enum related functionalities the language or standard library offers.

std::is_enum

C++11 introduced the <type_traits> header. It includes utilities to check the properties of types. Not surprisingly is_enum is there to check whether a type is an enum of not. It returns true both for scoped and unscoped versions.

Since C++17, is_enum_v is also available for easier usage.

#include <iostream>
#include <type_traits>

enum UnscopedColor { red, green, blue };
enum class ScopedColor { red, green, blue };
struct S{};

int main() {
    std::cout << std::boolalpha
              << std::is_enum<UnscopedColor>::value << '\n'
              << std::is_enum<ScopedColor>::value << '\n'
              << std::is_enum_v<S> << '\n';
}

Enter fullscreen mode Exit fullscreen mode

std::underlying_type

std::underlying_type was also an addition to C++11. It helps us retrieve the underlying type of an enum. Until C++20 if the checked enum is not completely defined or not an enum, the behaviour is undefined. Starting with C++, the program becomes ill-formed for incomplete enum types.

C++14 introduced a related helper, std::underlying_type_t.

#include <iostream>
#include <type_traits>

enum UnscopedColor { red, green, blue };
enum class ScopedColor { red, green, blue };
enum class CharBasedColor : char { red = 'r', green = 'g', blue = 'b' };

int main() {

  constexpr bool isUnscopedColorInt = std::is_same_v< std::underlying_type<UnscopedColor>::type, int >;
  constexpr bool isScopedColorInt = std::is_same_v< std::underlying_type_t<ScopedColor>, int >;
  constexpr bool isCharBasedColorInt = std::is_same_v< std::underlying_type_t<CharBasedColor>, int >;
  constexpr bool isCharBasedColorChar = std::is_same_v< std::underlying_type_t<CharBasedColor>, char >;

  std::cout
    << "underlying type for 'UnscopedColor' is " << (isUnscopedColorInt ? "int" : "non-int") << '\n'
    << "underlying type for 'ScopedColor' is " << (isScopedColorInt ? "int" : "non-int") << '\n'
    << "underlying type for 'CharBasedColor' is " << (isCharBasedColorInt ? "int" : "non-int") << '\n'
    << "underlying type for 'CharBasedColor' is " << (isCharBasedColorChar ? "char" : "non-char") << '\n'
    ;
}
Enter fullscreen mode Exit fullscreen mode

Using-enum-declaration since C++20

Since C++20, use can use using with enums. It introduces the enumerator names in the given scope.

The feature is smart enough to raise a compilation error in case a second using would introduce an enumerator name that was already introduced from another enum.

#include <type_traits>

enum class ScopedColor { red, green, blue };
enum class CharBasedColor : char { red = 'r', green = 'g', blue = 'b' };

int main() {
  using enum ScopedColor; // OK!
  using enum CharBasedColor; // error: 'CharBasedColor CharBasedColor::red' conflicts with a previous declaration
}
Enter fullscreen mode Exit fullscreen mode

It's worth noting that it doesn't recognize if an unscoped enum already introduced an enumerator name in the given namespace. In the following example, there is already red, green, and blue available from UnscopedColor, still, the using of ScopedColor with the same enumerator names is accepted.

#include <type_traits>

enum UnscopedColor { red, green, blue };
enum class ScopedColor { red, green, blue };

int main() {
  using enum ScopedColor;
}
Enter fullscreen mode Exit fullscreen mode

C++23 brings std::is_scoped_enum

C++23 will introduce one more enum related function in the <type_traits> header, one of it is std::is_scoped_enum and it's helper function std::is_scoped_enum_v. As the name suggests and the below snippet proves, it checks whether it is argument is a scoped enum or not.

#include <iostream>
#include <type_traits>

enum UnscopedColor { red, green, blue };
enum class ScopedColor { red, green, blue };
struct S{};

int main() 
{
    std::cout << std::boolalpha;
    std::cout << std::is_scoped_enum<UnscopedColor>::value << '\n';
    std::cout << std::is_scoped_enum_v<ScopedColor> << '\n';
    std::cout << std::is_scoped_enum_v<S> << '\n';
    std::cout << std::is_scoped_enum_v<int> << '\n';
}
/*
false
true
false
false
*/
Enter fullscreen mode Exit fullscreen mode

If you want to try out C++23 features, use the -std=c++2b compiler flag.

C++23 introduces std::to_underlying

C++23 will introduce another library feature for enums. The <utility> header will be enriched with std::to_underlying. It converts an enum to its underlying type. As mentioned, this is a library feature, meaning that it can be implemented in earlier versions.

This one is can be replaced with a static_cast if you have access only to earlier versions: static_cast<std::underlying_type_t<MyEnum>>(e);.

#include <iostream>
#include <type_traits>
#include <utility>

enum class ScopedColor { red, green, blue };

int main() 
{
    ScopedColor sc = ScopedColor::red;
    [[maybe_unused]] int underlying = std::to_underlying(sc);
    [[maybe_unused]] int underlyingEmulated = static_cast<std::underlying_type_t<ScopedColor>>(sc);
    [[maybe_unused]] std::underlying_type_t<ScopedColor> underlyingDeduced = std::to_underlying(sc);
}
Enter fullscreen mode Exit fullscreen mode

As a reminder, let me repate that if you want to try out C++23 features, use the -std=c++2b compiler flag.

Conclusion

In this article, we discussed all the language and library features that are about enumerations. We saw how scoped and unscoped enums differ and why it's better to use scoped enums. That's not the only Core Guidelines recommendation we discussed.

Then we checked how the standard library has been enriched during the years supporting an easier work with enums. We also had a sneak peek into the future and checked what C++23 will bring for us.

Connect deeper

If you liked this article, please

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .