Type-safe integer types with list initialization of scoped enums

List-initialization of scoped enums (aka enum class) since C++17 makes it possible to roll out new distinct type-safe integer types.

Tutorial | Jun 7, 2020 | hkumar 

1. TL;DR

Scoped enums have a strong type-safety and can have a specified underlying type. Therefore, they are a means to introduce new distinct integer types:

enum class AccountNumber : uint32_t {}; //No Enumerators

The empty AccountNumber enumeration is a distinct 32-bit unsigned integer type that does not implicitly convert to or from any other type. An interface using AccountNumber is safer than directly using an uint32_t:

void payBill(AccountNumber account, double amount) {
  //...
} 

payBill(AccountNumber(817986000), 100.15); //OK

payBill(100, AccountNumber(817986000)); //Error! Good.

In contrast, type-aliases are good for readability and maintainability, but they do not provide strong type-safety. Suppose AccountNumber is an alias, as follows:

using AccountNumber = uint32_t;

payBill(100, AccountNumber(817986000)); //Oops! Compiles.

However, until C++17, there was no clean way to initialize a scoped enum with an underlying integer value. We used functional-cast above to initialize an AccountNumber, which can cause narrowing conversion. Once again:

enum class AccountNumber : uint32_t {}; //No Enumerators

//Implicit conversion not allowed 
AccountNumber a0 = 817986000; //Error. We know that.

//cast-initialization works but not safe
AccountNumber a1 = AccountNumber(817986000); //OK, but dangerous 

//If the account number is too large for uint32_t (an extra 0 below)
// — narrowing happens
payBill(AccountNumber(8179860000), 100.15); //Oops!  Compiles.

C++17 added the support for the list (braced) initialization ("{}") of scoped enums with one integer value. And, braced-initialization naturally does not allow narrowing conversion:

//Only since C++17
payBill(AccountNumber{817986000}, 100.15); //OK.

//Can catch narrowing
payBill(AccountNumber{8179860000}, 100.15); //Error - Narrowing Conversion!

Another good example of using a scoped enum to create a new type is std::byte from the standard library. std::byte was also introduced in C++17. The motivation behind std::byte is to create a byte type that is distinct from the concept of arithmetic integer types and character types. It represents a collection of bits, therefore it supports only bitwise operations. So:

//in namespace std
enum class byte : unsigned char {};

The following sections cover this subject in detail with more examples. The article assumes the familiarity with scoped enums. For in-depth coverage of scoped enums, check out "Scoped (class) enums: fundamentals and examples."

2. New Type-Safe Integer Types

The strong type-safety and deterministic underlying type properties of scoped enums have opened up a possibility to create new distinct integer types.

Distinct primitive types are often represented with type aliases (through using or typedef) for legibility and maintainability. However, type aliases lack strong type-safety because they do not introduce new distinct types.

Another technique that sort of imitates type-safe integer types is to wrap an integer in a struct. That approach suffers from poor readability and expects (a reasonable expectation though) compilers to optimize away the wrapper struct. Scoped enums are compiled as primitive integer types, even in the least forgiving ABIs.

Let's consider a hypothetical medical billing system where an Invoice record has an invoice number and patient's SSN, among other fields. We have declared InvoiceNumber and SSN as type-aliases:

using SSN = uint32_t;
using InvoiceNumber = uint32_t;

struct Invoice {
 InvoiceNumber invoiceNumber;
 SSN ssn;
 double amount;    
 std::string name;  //Patient's Name
};

We store invoice records in an std::map against their InvoiceNumber as key. Two functions, addInvoice and findInvoice are used for adding and searching an invoice record respectively:

std::map<InvoiceNumber, std::shared_ptr<Invoice>> invoices;

//Adds an invoice 
void addInvoice(InvoiceNumber invoiceNumber,
                SSN ssn,
                double amount,
                const std::string& name) {

 auto sp = std::make_shared<Invoice>(Invoice{ invoiceNumber, ssn, amount, name });
 invoices[invoiceNumber] = sp;
}

//Find an invoice by InvoiceNumber
std::shared_ptr<Invoice> findInvoice(InvoiceNumber invoiceNumber) {
 auto itr = invoices.find(invoiceNumber);
 if(itr != invoices.end())
    return itr->second;
 return std::shared_ptr<Invoice>(); //return null
}

An invoice is added to the system by processing a Form object from UI (or a network API). Form is part of an interface of a library maintained separately:

class Form {
public: 
 uint32_t getInvoiceNumber() const;
 uint32_t getSSN() const;      
 double getAmount() const;
 std::string getName() const; 
private:
 //....
}; 

There is a possibility that the two integer parameters to addInvoice can be accidentally swapped, causing unintended headache:

void processForm(const Form& form) {
 //....
 addInvoice(SSN(form.getSSN()), //<-ssn instead of invoiceNumber
            InvoiceNumber(form.getInvoiceNumber()), //<-invoiceNumber instead of ssn
            form.getAmount(), 
            form.getName()); //Oops! Compiles.
}

It is much safer if the SSN and InvoiceNumber are declared as scoped enums:

enum class SSN : uint32_t { }; //No Enumerators
enum class InvoiceNumber : uint32_t { }; //No Enumerators

Now the following code would be flagged as an error by the compiler:

void processForm(const Form& form) {
 //....
 addInvoice(SSN(form.getSSN()), 
            InvoiceNumber(form.getInvoiceNumber()), 
            form.getAmount(), 
            form.getName()); //Error! Good.
}

Note that the scoped enums can be compared with comparison operators and, therefore, can be used as a key in a map. The std::hash works out of the box; therefore, they can be used as an unordered_map key without any extra work.

Above, we declared the distinct integer types with scoped enums. But the way we initialized them (using functional cast) has the possibility of narrowing conversion. Narrowing conversion happens when a numeric value is assigned to a variable type that cannot fully hold it. Assigning a 4-byte integer to a short or a floating-point value to an int are examples of narrowing conversion. In that situation, parts of the numeric value are dropped:

 //These are narrowing conversions 
 uint8_t b1(256); //Oops! Compiles and stores the wrong value.
 uint8_t b2 = 256; //Same. Compiles. 
 int i1 = 32.8; //This is also narrowing conversion. 

C++11 introduced the list (or braced) initialization that does not allow the narrowing conversion:

uint8_t b3{256}; //Error! 
int8_t b4{128}; //Error! Max signed byte is 127
int i2{32.8}; //Error!

uint8_t b5{255}; //OK
int8_t b6{127}; //OK
int i3{32}; //OK

However, the list initialization was not supported for enums until C++17. Therefore, the following is valid only since C++17:

void processForm(const Form& form) {
 //....
 addInvoice(InvoiceNumber{form.getInvoiceNumber()}, //list-initialization is safer
            SSN{form.getSSN()}, //list-initialization is safer
            form.getAmount(), 
            form.getName()); //OK.  
}

The list initialization defends us against the situation where Form::getInvoiceNumber() is changed to return uint64_t:

class Form {
public: 
 uint64_t getInvoiceNumber() const;
 //....rest is same as above
}; 

void processForm(const Form& form) {
 //....
 //Narrowing conversion not allowed by "{}"
 addInvoice(InvoiceNumber{form.getInvoiceNumber()},  //<-Narrowing
            SSN{form.getSSN()}, 
            form.getAmount(), 
            form.getName()); //Error! 
}

The next section covers the list-initialization of the scoped enums in detail.

3. List Initialization of Scoped Enums

One of the most salient (and relevant to this discussion) features of the list initialization is that it does not allow the narrowing conversion. Support of list initialization of enums is added in C++17.

List (or braced) initialization is also known as uniform initialization because it works in (almost) all situations as opposed to the initialization using parentheses or equals (=).

However, list initialization also has its share of confusion when it comes to initializing objects that have a constructor with an std::initializer_list parameter (e.g., std::vector); therefore, so far it is uniform initialization only in concept. Complete coverage of list initializations and its pitfalls is outside the scope of this article. We would only talk about what is relevant to scoped enums here.

In general, you might encounter two forms of list initialization: the more common without-equals "{arg,..}", and the less common with-equals "= {arg,..}". The "{arg,..}" syntax is called direct-list-initialization, and the "={arg,..}" format is a kind of copy-list-initialization. For the most part, there is no difference between the two, except that the "= {arg,..}" considers only implicit construction:

struct X {
 X(int x) { /*...*/ } //implicit constructor
};

struct Y {
 Y() = default;
 explicit Y(int y) { /*...*/ } 
};

int i0{}; //OK. Default value-initialization
int i1{1}; //OK
int i2 = {2}; //OK (Same as above)


X x0{}; //Error! No Default Constructor
X x1{1}; //OK
X x2 = {2};  //OK. Implcit constructor used

Y y0{}; //OK. Default Construction
Y y1{1}; //OK
Y y2 = {2}; //Error! There is no implicit constructor 

When it comes to scoped enums, only direct-list-initialization with one element of the underlying type is supported. Knowing the distinction between "{arg}" and "={arg}" is essential here. Look at the different examples below:

//Byte is like std::byte
enum class Byte : uint8_t { };

Byte b0{}; //OK. Value-Initialized with 0
Byte b1{1}; //OK
Byte b1_2{1,2}; //Error. Too many elements.
Byte b2 = {2}; //Error! No implicit conversion from int allowed.
Byte b3 = Byte{3}; //OK
Byte b256{256}; //Error! Value too large

5. Conclusion

Scoped enums are type-safe enums of well-specified size. Because of their strong type safety, scoped enums are better than type-aliases when it comes to introducing new distinct integer types. They are better than struct-wrapped integers for readability and predictable performance.

However, until C++17, there was no safe way to initialize a scoped enum variable from its underlying value. The cast-initialization syntax made them susceptible to narrowing conversion.

List initialization has always disallowed narrowing conversion. Since C++17, a scoped enum variable can be initialized with an integer value using list initialization, which makes scoped enums an excellent way to declare the type-safe distinct integer types.

7. References

Construction Rules for enum class Values - Proposal

A byte type definition - Proposal