Scoped (class) enums: fundamentals and examples

A C++11 scoped enums deep dive and comparison with C++98 unscoped enums.

Tutorial | Jun 4, 2020 | hkumar 

Overview

Scoped enums (enum class/struct) are strongly typed enumerations introduced in C++11. They address several shortcomings of the old C-style (C++98) enums, mainly associated with type-safety and name collisions. Consequently, the C-style enums are now known as unscoped enums, and that is how we would address them in the rest of this article.

As part of the C++11 enum revamp, a few improvements were made to the unscoped enums also, in particular, the ability to specify an underlying integral type and accessing enumerators through the scope resolution operator ("::") — more on that in the next section.

In this article, we would talk about the various features of the scoped enums. We would also contrast those features with the unscoped enums, wherever applicable, mainly to show the motivation behind them.

Lastly, C++17 standard added the list-initialization ("{}") support for scoped enums. We would discuss that too.

2. Features

2.1 Strong Type-Safety

Unscoped enums have some type-safety characteristics — specifically, the implicit conversion from an integer value to enum is not allowed. Also, one enum type does not convert to a different enum type. For instance, these are some declarations in a hypothetical trading application's header file:

//Side of order 
enum OrderSide { BUY=1, SELL=-1 };

//A trading signal enumeration from tech. analysis 
enum TradeSignal { Buy, Sell }; 

//Interface to buy/sell a stock at current price
void sendMarketOrder(const std::string& ticker, uint32_t size, OrderSide side);

//....

Unscoped enums have some type-safety. They do not implicitly initialize from integer values:

 sendMarketOrder("IBM", 1000, BUY); //OK

 sendMarketOrder("IBM", BUY, 1000); //Error! Good

But unscoped enums can implicitly convert to integer values, which could lead to some unexpected results. This might happen in sendMarketOrder:

void sendMarketOrder(const std::string& ticker, uint32_t size, OrderSide side) {
   //'side' and 'Buy' are promoted for comparison
   if(side == Buy) { //Oops! Compares to TradeSignal::Buy 
    //....
   } 
   //.....
}

Scoped enums provide strong type-safety because they do not implicitly convert to or from integer values:

enum class AccountType { Checking, Savings };

void spam() { 
 AccountType at0 = 0; //Error!
 int at1 = AccountType::Checking; //Error!

 AccountType at2 = AccountType::Checking; //OK
 auto at3 = AccountType::Checking; //OK

 if(at2 == at3) { //OK
 }  

 if(at2 == 0) { //Error! Cannot compare with int
 }
}  

In those situations where it is necessary, we can explicitly type-cast scoped enums to and from integers:

AccountType at0 = static_cast<AccountType>(0); //OK
int at1 = static_cast<int>(AccountType::Checking); //OK

Note that scoped enums provide strong type-safety despite being primitive integer types. This detail is particularly notable. A determined programmer could go full nine yards of wrapping an enum in a class and defining all the necessary operators to achieve strong type-safety:

class Cat {
 enum Cat_ { Tiger_, Lion_, Cheetah_ }; //enum wrapped
 Cat_ value;
 explicit Cat(Cat::Cat_ v):value(v){}

public:
 static const Cat Tiger, Lion, Cheetah; //static Cat types. Fully scoped

 bool operator<(const Cat& d) const { return value < d.value; }
 bool operator==(const Cat& d) const { return value == d.value; }
 //.. more operators
};

const Cat Cat::Lion(Cat::Lion_);
const Cat Cat::Tiger(Cat::Tiger_);
const Cat Cat::Cheetah(Cat::Cheetah_); 

void foo() {
 Cat c0 = 1; //Error
 int c1 = Cat::Tiger; //Error

 Cat c2 = Cat::Tiger; //OK

 if(c2 == Cat::Tiger) { //OK    
 }

 if(c2 == 1) {  //Error
 }
}

But the above approach of wrapping (unscoped) enum in a class to effectuate type-safety is too tedious and repetitive. It also relies on compiler to optimize away the wrapper class (struct), which might not be permissible on some ABI's.

Therefore, each scoped enum is a distinct type with the performance benefits of a primitive integer. We have more on that below in the "Braced Initialization" section.

2.2 Strongly Scoped

Enumerator names defined in an unscoped enum leak into the enclosing scope. This can lead to name clashes. For example, two enums cannot have enumerators with the same names, and more, as shown:

enum Day { Sun, Mon, Tue, Wed, Thu, Fri, Sat }; //All identifiers injected into Day's parent scope

enum Star { Sun, Sirius, Pollux }; //Error!  ('Sun' already defined in parent scope)

Scoped enums are strongly scoped user-defined types. Enumerators of a scoped enum stay in the enum's scope and must be accessed through the scope resolution operator ("::"). This feature avoids the namespace contamination of the enclosing scope:

enum class Day { Sun, Mon, Tue, Wed, Thu, Fri, Sat };

enum class Star { Sun, Sirius, Pollux }; //OK

Star s = Star::Sun; //Must use Star:: to access enumerators.

There is a small improvement made to the unscoped enums also in this regard. Since C++11, unscoped enums also have their own scope. But for the backward compatibility, their enumerators are injected into enclosing scope too. The enum level scope is introduced for unscoped enums so one can write the same code notwithstanding the kind of enum:

enum Color { Red, Green, Blue };

Color c1 = Red; //OK (Parent Scope of Color)
Color c2 = Color::Red; //OK since C++11 (Color's scope)

2.3 Specific Underlying Type

The size (e.g., int vs. short) and signedness (e.g., int vs. unsigned) of enums before C++11 was implementation-dependent. Since C++11, it is possible to specify an integral underlying type for both scoped and unscoped enums:

enum Asset : uint8_t { Stock, Bond }; 
enum class Shape : uint8_t { Circle, Rectangle, Triangle };

If an underlying type is not specified for scoped enums, it defaults to int. However, for backward compatibility, the default underlying type of unscoped enums is still up to implementations to determine.

The trait class std::underlying_type_t<E> can be used to determine the underlying type of an enum. Here is an example of a generic code that casts an enum to its underlying type to print it:

template<typename E>
void printEnum(E e) {
 //Prefix '+' so that std::cout prints 'char' as a number instead of ASCII
 std::cout << +static_cast<std::underlying_type_t<E>>(e) << "\n";
}

printEnum(Stock);  //prints 0
printEnum(Shape::Triangle);  //prints 2

Because compilers are free to decide the underlying integer type of unscoped enum (if not specified), they often choose to optimize for size or speed depending on the enumerators:

enum AB { A=1, B=2 }; //sizeof(AB) could be 1, 2 or 4
enum CD { C=1, D=5000000000 }; //sizeof(CD) should be 8

As the size of an unscoped enum without a specific underlying type depends on its enumerators, there is no way to forward-declare them:

//forward-declaration
enum AB; //Error

However, it is fine to forward-declare a scoped enum or an unscoped enum with a specified underlying type:

//forward-declaration
enum Asset : uint8_t; //OK 
enum class Shape : uint8_t; //OK

The uncertainty about the size and signedness of unscoped enums is problematic across different implementations also. For instance, if the space taken by a type varies by implementation, it is unsuitable for representing the data that needs to be serialized for sending to other applications running on different platforms. Therefore, it is a common practice to enclose integers instead of enums in the user-defined types that need to be serialized. Such as, consider a message struct that is forwarded over the network:

enum MsgType { Trade, Quote };

struct Message {
 //MsgType msgType; //MsgType's size is not known

 uint8_t msgType; //Use uint8_t instead of MsgType
 //cast to MsgType
 MsgType getMsgType() {
  return MsgType(msgType);
 }

 /*Other Data...*/
};

But scoped enums (or unscoped enums with underlying type) have known size and can be part of data that is sent across applications.

2.4 List (Braced) Initialization

Scoped enums do not implicitly convert to and from integer values. This strong type-safety is sometimes deemed overzealous that hindered the adoption of scoped enums.

For instance, an empty scoped enum is a strongly typed distinct integer type. A distinct integer type that does not implicitly convert to or from integers is more desirable than simple type-aliases where safety is a big concern:

enum class TaxId : uint16_t { }; //No Enumerators

//TaxId is a distinct integer type.

However, there was no cleaner way to initialize a scoped enum with an underlying value until C++17:

TaxId tid1 = 32005; //Error. Does not convert from int
TaxId tid2 = TaxId(32005); //OK. But cast-initialization.

C++17 addressed this by introducing the braced-initialization ("{}") of enums by their underlying value. Braced-initialization is safer than functional cast because it avoids the narrowing conversion:

TaxId tid3{32005}; //OK since C++17

//{}-initialization detects narrowing conversion
TaxId tid4 = TaxId(67005); //Oops! type-cast cannot detect narrowing
TaxId tid5{67005}; //Error! Narrowing conversion

Braced-initialization is supported for unscoped enums also.

3. Conclusion

C++98 enums are an archaic undeveloped concept. Whereas, C++11 scoped enums are fully type-safe enumerations with their scope and a well-known integral underlying type.

Scoped enums should be preferred over C++98 unscoped enums wherever possible.

4. References

Strongly Typed Enums - Proposal (n2347)

Enumeration declaration - cppreference