Using scoped enumerations
Enumeration is a basic type in C++ that defines a collection of values, always of an integral underlying type. Their named values, which are constant, are called enumerators. Enumerations declared with the keyword enum
are called unscoped enumerations, while enumerations declared with enum class
or enum struct
are called scoped enumerations. The latter ones were introduced in C++11 and are intended to solve several problems with unscoped enumerations, which are explained in this recipe.
How to do it...
When working with enumerations, you should:
- Prefer to use scoped enumerations instead of unscoped ones
- Declare scoped enumerations using
enum class
orenum struct
:enum class Status { Unknown, Created, Connected }; Status s = Status::Created;
The enum class
and enum struct
declarations are equivalent, and throughout this recipe and the rest of this book, we will use enum class
.
Because scope enumerations are restricted namespaces, the C++20 standard allows us to associate them with a using
directive. You can do the following:
- Introduce a scoped enumeration identifier in the local scope with a
using
directive, as follows:int main() { using Status::Unknown; Status s = Unknown; }
- Introduce all the identifiers of a scoped enumeration in the local scope with a
using
directive, as follows:struct foo { enum class Status { Unknown, Created, Connected }; using enum Status; }; foo::Status s = foo::Created; // instead of // foo::Status::Created
- Use a
using enum
directive to introduce the enum identifiers in aswitch
statement to simplify your code:void process(Status const s) { switch (s) { using enum Status; case Unknown: /*…*/ break; case Created: /*...*/ break; case Connected: /*...*/ break; } }
Converting a scoped enumeration to its underlying type is sometimes necessary, especially in the context of using old-style APIs that take integers as arguments. In C++23, you can convert to the underlying type of a scoped enumeration by using the std::to_underlying()
utility function:
void old_api(unsigned flag);
enum class user_rights : unsigned
{
None, Read = 1, Write = 2, Delete = 4
};
old_api(std::to_underlying(user_rights::Read));
How it works...
Unscoped enumerations have several issues that create problems for developers:
- They export their enumerators to the surrounding scope (for which reason, they are called unscoped enumerations), and that has the following two drawbacks:
- It can lead to name clashes if two enumerations in the same namespace have enumerators with the same name
- It’s not possible to use an enumerator using its fully qualified name:
enum Status {Unknown, Created, Connected}; enum Codes {OK, Failure, Unknown}; // error auto status = Status::Created; // error
- Prior to C++ 11, they could not specify the underlying type, which is required to be an integral type. This type must not be larger than
int
, unless the enumerator value cannot fit a signed or unsigned integer. Owing to this, forward declaration of enumerations was not possible. The reason for this was that the size of the enumeration was not known. This was because the underlying type was not known until the values of the enumerators were defined so that the compiler could pick the appropriate integer type. This has been fixed in C++11. - Values of enumerators implicitly convert to
int
. This means you can intentionally or accidentally mix enumerations that have a certain meaning and integers (which may not even be related to the meaning of the enumeration) and the compiler will not be able to warn you:enum Codes { OK, Failure }; void include_offset(int pixels) {/*...*/} include_offset(Failure);
The scoped enumerations are basically strongly typed enumerations that behave differently than the unscoped enumerations:
- They do not export their enumerators to the surrounding scope. The two enumerations shown earlier would change to the following, no longer generating a name collision and making it possible to fully qualify the names of the enumerators:
enum class Status { Unknown, Created, Connected }; enum class Codes { OK, Failure, Unknown }; // OK Codes code = Codes::Unknown; // OK
- You can specify the underlying type. The same rules for underlying types of unscoped enumerations apply to scoped enumerations too, except that the user can explicitly specify the underlying type. This also solves the problem with forward declarations, since the underlying type can be known before the definition is available:
enum class Codes : unsigned int; void print_code(Codes const code) {} enum class Codes : unsigned int { OK = 0, Failure = 1, Unknown = 0xFFFF0000U };
- Values of scoped enumerations no longer convert implicitly to
int
. Assigning the value of anenum class
to an integer variable would trigger a compiler error unless an explicit cast is specified:Codes c1 = Codes::OK; // OK int c2 = Codes::Failure; // error int c3 = static_cast<int>(Codes::Failure); // OK
However, the scoped enumerations have a drawback: they are restricted namespaces. They do not export the identifiers in the outer scope, which can be inconvenient at times, for instance, if you are writing a switch
and you need to repeat the enumeration name for each case label, as in the following example:
std::string_view to_string(Status const s)
{
switch (s)
{
case Status::Unknown: return "Unknown";
case Status::Created: return "Created";
case Status::Connected: return "Connected";
}
}
In C++20, this can be simplified with the help of a using
directive with the name of the scoped enumeration. The preceding code can be simplified as follows:
std::string_view to_string(Status const s)
{
switch (s)
{
using enum Status;
case Unknown: return "Unknown";
case Created: return "Created";
case Connected: return "Connected";
}
}
The effect of this using
directive is that all the enumerator identifiers are introduced in the local scope, making it possible to refer to them with the unqualified form. It is also possible to bring only a particular enum identifier to the local scope with a using
directive with the qualified identifier name, such as using
Status::Connected
.
The C++23 version of the standard adds a couple of utility functions for working with scoped enumerations. The first of these is std::to_underlying()
, available in the <utility>
header. What it does is convert an enumeration to its underlying type.
Its purpose is to work with APIs (legacy or not) that don’t use scoped enumerations. Let’s look at an example. Consider the following function, old_api()
, which takes an integer argument, which it interprets as flags controlling user rights, into the system:
void old_api(unsigned flag)
{
if ((flag & 0x01) == 0x01) { /* can read */ }
if ((flag & 0x02) == 0x02) { /* can write */ }
if ((flag & 0x04) == 0x04) { /* can delete */ }
}
This function can be invoked as follows:
old_api(1); // read only
old_api(3); // read & write
Conversely, a newer part of the system defines the following scoped enumeration for the user rights:
enum class user_rights : unsigned
{
None,
Read = 1,
Write = 2,
Delete = 4
};
However, invoking the old_api()
function with enumerations from user_rights
is not possible, and a static_cast
must be used:
old_api(static_cast<int>(user_rights::Read)); // read only
old_api(static_cast<int>(user_rights::Read) |
static_cast<int>(user_rights::Write)); // read & write
To avoid these static casts, C++23 provides the function std::to_underlying()
, which can be used as follows:
old_api(std::to_underlying(user_rights::Read));
old_api(std::to_underlying(user_rights::Read) |
std::to_underlying(user_rights::Write));
The other utility introduced in C++23 is a type trait called is_scoped_enum<T>
, available in the <type_traits>
header. This contains a member constant called value
, which is equal to true
if the template type parameter T
is a scoped enumeration type, or false
otherwise. There is also a helper variable template, is_scoped_enum_v<T>
.
The purpose of this type trait is to identify whether an enumeration is scoped or not in order to apply different behavior, depending on the type of the enumeration. Here is a simple example:
enum A {};
enum class B {};
int main()
{
std::cout << std::is_scoped_enum_v<A> << '\n';
std::cout << std::is_scoped_enum_v<B> << '\n';
}
The first line will print 0 because A
is an unscoped enum, while the second line will print 1
because B
is a scoped enum.
See also
- Chapter 9, Creating compile-time constant expressions, to learn how to work with compile-time constants