In the prior lesson (10.2 -- Unscoped enumerations), we mentioned that enumerators are symbolic constants. What we didn’t tell you then is that enumerators are integral symbolic constants. As a result, enumerated types actually hold an integral value.
This is similar to the case with chars (4.11 -- Chars). Consider:
char ch { 'A' };
A char is really just a 1-byte integral value, and the character 'A'
gets converted to an integral value (in this case, 65
) and stored.
When we define an enumerator, each enumerator is automatically assigned an integer value based on its position in the enumerator list. By default, the first enumerator is assigned the integral value 0
, and each subsequent enumerator has a value one greater than the previous enumerator:
#include <iostream>
enum Color
{
black, // assigned 0
red, // assigned 1
blue, // assigned 2
green, // assigned 3
white, // assigned 4
cyan, // assigned 5
yellow, // assigned 6
magenta, // assigned 7
};
int main()
{
Color shirt{ blue }; // This actually stores the integral value 2
return 0;
}
It is possible to explicitly define the value of enumerators. These integral values can be positive or negative, and can share the same value as other enumerators. Any non-defined enumerators are given a value one greater than the previous enumerator.
enum Animal
{
cat = -3,
dog, // assigned -2
pig, // assigned -1
horse = 5,
giraffe = 5, // shares same value as horse
chicken, // assigned 6
};
Note in this case, horse
and giraffe
have been given the same value. When this happens, the enumerators become non-distinct -- essentially, horse
and giraffe
are interchangeable. Although C++ allows it, assigning the same value to two enumerators in the same enumeration should generally be avoided.
Best practice
Avoid assigning explicit values to your enumerators unless you have a compelling reason to do so.
Unscoped enumerations will implicitly convert to integral values
Consider the following program:
#include <iostream>
enum Color
{
black, // assigned 0
red, // assigned 1
blue, // assigned 2
green, // assigned 3
white, // assigned 4
cyan, // assigned 5
yellow, // assigned 6
magenta, // assigned 7
};
int main()
{
Color shirt{ blue };
std::cout << "Your shirt is " << shirt; // what does this do?
return 0;
}
Since enumerated types hold integral values, as you might expect, this prints:
Your shirt is 2
When an enumerated type is used in a function call or with an operator, the compiler will first try to find a function or operator that matches the enumerated type. For example, when the compiler tries to compile std::cout << shirt
, the compiler will first look to see if std::cout
knows how to print an object of type Color
(because shirt
is of type Color
). It doesn’t.
If the compiler can’t find a match, the compiler will then implicitly convert an unscoped enumeration or enumerator to its corresponding integer value. Because std::cout
does know how to print an integral value, the value in shirt
gets converted to an integer and printed as integer value 2
.
Printing enumerator names
Most of the time, printing an enumeration as an integral value (such as 2
) isn’t what we want. Instead, we typically will want to print the name of whatever the enumerator represents (blue
). But to do that, we need some way to convert the integral value of the enumeration (2
) into a string matching the enumerator name ("blue"
).
As of C++20, C++ doesn’t come with any easy way to do this, so we’ll have to find a solution ourselves. Fortunately, that’s not very difficult. The typical way to do this is to write a function that takes an enumerated type as a parameter and then outputs the corresponding string (or returns the string to the caller).
The typical way to do this is to test our enumeration against every possible enumerator:
// Using if-else for this is inefficient
void printColor(Color color)
{
if (color == black) std::cout << "black";
else if (color == red) std::cout << "red";
else if (color == blue) std::cout << "blue";
else std::cout << "???";
}
However, using a series of if-else statements for this is inefficient, as it requires multiple comparisons before a match is found. A more efficient way to do the same thing is to use a switch statement. In the following example, we will also return our Color
as a std::string
, to give the caller more flexibility to do whatever they want with the name (including print it):
#include <iostream>
#include <string>
enum Color
{
black,
red,
blue,
};
// We'll show a better version of this for C++17 below
std::string getColor(Color color)
{
switch (color)
{
case black: return "black";
case red: return "red";
case blue: return "blue";
default: return "???";
}
}
int main()
{
Color shirt { blue };
std::cout << "Your shirt is " << getColor(shirt) << '\n';
return 0;
}
This prints:
Your shirt is blue
This likely performs better than the if-else chain (switch statements tend to be more efficient than if-else chains), and it’s easier to read too. However, this version is still inefficient, because we need to create and return a std::string
(which is expensive) every time the function is called.
In C++17, a more efficient option is to replace std::string
with std::string_view
. std::string_view
allows us to return string literals in a way that is much less expensive to copy.
#include <iostream>
#include <string_view> // C++17
enum Color
{
black,
red,
blue,
};
constexpr std::string_view getColor(Color color) // C++17
{
switch (color)
{
case black: return "black";
case red: return "red";
case blue: return "blue";
default: return "???";
}
}
int main()
{
Color shirt{ blue };
std::cout << "Your shirt is " << getColor(shirt) << '\n';
return 0;
}
We haven’t covered std::string_view
yet, but we do so in future lesson 11.7 -- An introduction to std::string_view. For now, it’s fine to use in this specific context.
Related content
Constexpr return types are covered in in lesson 6.14 -- Constexpr and consteval functions.
Teaching std::cout how to print an enumerator
Although the above example functions well, we still have to remember the name of the function we created to get the enumerator name. While this usually isn’t too burdensome, it can become more problematic if you have lots of enumerations. Using operator overloading (a capability similar to function overloading), we can actually teach std::cout
how to print the value of a program-defined enumeration! We haven’t explained how this works yet, so consider it a bit of magic for now:
#include <iostream>
enum Color
{
black,
red,
blue,
};
// Teach std::cout how to print a Color
// Consider this magic for now since we haven't explained any of the concepts it uses yet
// std::ostream is the type of std::cout
// The return type and parameter type are references (to prevent copies from being made)!
std::ostream& operator<<(std::ostream& out, Color color)
{
switch (color)
{
case black: out << "black"; break;
case red: out << "red"; break;
case blue: out << "blue"; break;
default: out << "???"; break;
}
return out;
}
int main()
{
Color shirt{ blue };
std::cout << "Your shirt is " << shirt; // it works!
return 0;
}
This prints:
Your shirt is blue
For advanced readers
For the curious, here’s what the above code is actually doing. When we try to print shirt
using std::cout
and operator<<
, the compiler will see that we’ve overloaded operator<<
to work with objects of type Color
. This overloaded operator<<
function is then called with std::cout
as the out
parameter, and our shirt
as parameter color
. Since out
is a reference to std::cout
, a statement such as out << "blue"
is really just printing "blue"
to std::cout
.
We cover overloading the I/O operators in lesson 14.4 -- Overloading the I/O operators. For now, you can copy this code and replace Color
with your own enumerated type.
Enumerated types are considered part of the integer family of types, and it’s up to the compiler to determine how much memory to allocate for an enum variable. The C++ standard says the enum size needs to be large enough to represent all of the enumerator values. Most often, it will make enum variables the same size as a standard int
.
However, it is possible to specify a different underlying type. For example, if you are working in some bandwidth-sensitive context (e.g. sending data over a network) you may want to specify a smaller type:
// Use an 8-bit unsigned integer as the enum base
enum Color : std::uint8_t
{
black,
red,
blue,
};
Since enumerators aren’t usually used for arithmetic or comparisons with integers, it’s generally safe to use an unsigned integer if desired.
Best practice
Specify the base type of an enumeration only when necessary.
Integer to unscoped enumerator conversion
While the compiler will implicitly convert unscoped enumerators to an integer, it will not implicitly convert an integer to an unscoped enumerator. The following will produce a compiler error:
#include <iostream>
enum Pet
{
cat, // assigned 0
dog, // assigned 1
pig, // assigned 2
whale, // assigned 3
};
int main()
{
Pet pet { 2 }; // compile error: integer value 2 won't implicitly convert to a Pet
pet = 3; // compile error: integer value 1 won't implicitly convert to a Pet
return 0;
}
There are two ways to work around this.
First, you can force the compiler to convert an integer to an unscoped enumerator using static_cast
:
#include <iostream>
enum Pet
{
cat, // assigned 0
dog, // assigned 1
pig, // assigned 2
whale, // assigned 3
};
int main()
{
Pet pet { static_cast<Pet>(2) }; // convert integer 2 to a Pet
pet = static_cast<Pet>(3); // our pig evolved into a whale!
return 0;
}
We’ll see an example in a moment where this can be useful.
Second, in C++17, if an unscoped enumeration has a specified base, then the compiler will allow you to initialize (but not assign) an unscoped enumeration using an integral value:
#include <iostream>
enum Pet: int // we've specified a base
{
cat, // assigned 0
dog, // assigned 1
pig, // assigned 2
whale, // assigned 3
};
int main()
{
Pet pet { 2 }; // ok: can initialize with integer
pet = 3; // compile error: can not assign with integer
return 0;
}
Unscoped enumerator input
Because Pet
is a program-defined type, the language doesn’t know how to input a Pet using std::cin
:
#include <iostream>
enum Pet
{
cat, // assigned 0
dog, // assigned 1
pig, // assigned 2
whale, // assigned 3
};
int main()
{
Pet pet { pig };
std::cin >> pet; // compile error, std::cin doesn't know how to input a Pet
return 0;
}
To work around this, we can read in an integer, and use static_cast
to convert the integer to an enumerator of the appropriate enumerated type:
#include <iostream>
enum Pet
{
cat, // assigned 0
dog, // assigned 1
pig, // assigned 2
whale, // assigned 3
};
int main()
{
std::cout << "Enter a pet (0=cat, 1=dog, 2=pig, 3=whale): ";
int input{};
std::cin >> input; // input an integer
Pet pet{ static_cast<Pet>(input) }; // static_cast our integer to a Pet
return 0;
}
Quiz time
Question #1
True or false. Enumerators can be:
- Given an integer value
- Given no explicit value
- Given a floating point value
- Given a negative value
- Given a non-unique value
- Initialized with the value of prior enumerators (e.g. magenta = red)