What Are Unscoped Enumerator Integral Conversions?

Every enumerator in an unscoped enumeration is really an integer wearing a name. The compiler assigns each one an integral value, and an unscoped enumeration converts to that value implicitly. That conversion is convenient right up until it happens somewhere you did not intend.

The relationship is the same one char has with its numeric value: char grade{ 'B' }; stores the number 66, and the character is just how it is spelled.

How the Values Are Assigned

Left alone, the compiler numbers enumerators by position: the first is 0 and each one after is one greater.

#include <iostream>

enum Aspect
{
    danger,
    caution,
    clear,
};

int main()
{
    Aspect signal{ clear };

    std::cout << "Signal shows " << signal << '\n';

    return 0;
}

Output:

Signal shows 2

clear is the third enumerator, so its value is 2, and that is what reaches std::cout.

You can also set the values yourself. They may be negative, they may skip, and they may repeat. Any enumerator you leave unassigned continues from the previous one:

#include <iostream>

enum PlatformLevel
{
    basement = -3,
    subway,
    concourse,
    ground = 4,
    mezzanine = 4,
    roof,
};

int main()
{
    std::cout << subway << ' ' << concourse << ' ' << mezzanine << ' ' << roof << '\n';

    PlatformLevel level{};
    std::cout << "Value-initialized: " << level << '\n';

    return 0;
}

Output:

-2 -1 4 5
Value-initialized: 0

subway and concourse continue from basement at -2 and -1. mezzanine deliberately repeats ground, which C++ permits and which makes the two names interchangeable rather than distinct. That is rarely what a reader expects.

Best Practice
Leave the values to the compiler unless you have a specific reason not to, such as matching an external protocol or file format.

Value Initialization Always Gives You Zero

Look again at the last two lines of that output. PlatformLevel level{} zero-initializes, so level holds 0, and this enumeration has no enumerator with value 0 at all. The object is in a state none of its names describe.

Two consequences follow, and both are about which enumerator sits at zero.

If an enumerator has value 0, value initialization means that enumerator. So choose it deliberately. This is a poor arrangement:

enum InspectionResult
{
    failed,  // value 0, so this is the default
    passed,
};

Every value-initialized InspectionResult now claims the inspection failed.

If nothing has value 0, value initialization produces a meaningless state. The fix is to give that state a name and handle it:

enum InspectionResult
{
    inspectionPending, // value 0, an honest default
    passed,
    failed,
};
Best Practice
Make the zero-valued enumerator the one that is the best default. Where no sensible default exists, add an explicit "unknown" or "pending" enumerator at zero so the state is documented and can be checked for.

The Implicit Conversion to Integers

An enumeration is a compound type, not an integral type. Even so, an unscoped enumeration converts implicitly to its integral value, and because enumerators are compile-time constants, that conversion is constexpr.

The printing above is that rule in action. Resolving std::cout << signal proceeds in two steps: the compiler looks for an operator<< that accepts an Aspect, finds none, then looks again after converting signal to its underlying integral value, finds the int overload, and prints 2.

Related Content
Printing a name instead of a number, by converting an enumeration to a string and by teaching std::cout about the type, is covered in later lessons.

The Underlying Type

The integral type actually used to hold the values is the enumeration's underlying type, sometimes called its base.

For unscoped enumerations the standard does not say which type that is. It is implementation-defined, and most compilers use int unless the values need something wider. Do not write code that depends on the choice.

You can state it explicitly, which matters when the size does, such as data going over a network:

#include <cstdint>
#include <iostream>

enum Aspect : std::int16_t
{
    danger,
    caution,
    clear,
};

int main()
{
    Aspect signal{ caution };

    std::cout << "Bytes: " << sizeof(signal) << '\n';
    std::cout << "Value: " << signal << '\n';

    return 0;
}

Output:

Bytes: 2
Value: 1
Best Practice
Specify a base only when you actually need one.
Warning
std::int8_t and std::uint8_t are usually aliases for character types. Use one as a base and your enumerators will print as characters rather than numbers, which is why the example above uses std::int16_t.

Integers Do Not Convert Back

The conversion runs one way only. An enumeration becomes an integer implicitly; an integer does not become an enumeration:

enum Aspect
{
    danger,
    caution,
    clear,
};

int main()
{
    Aspect signal{ 2 };

    return 0;
}

This program is shown to demonstrate the error, and does not compile:

s.cpp: In function 'int main()':
s.cpp:10:20: error: invalid conversion from 'int' to 'Aspect' [-fpermissive]
   10 |     Aspect signal{ 2 };
      |                    ^
      |                    |

There are two ways through it.

static_cast performs the conversion explicitly, which is the general solution and works regardless of how the enumeration was declared.

Brace initialization with an explicit base, from C++17, is accepted directly. Note that this applies to brace initialization specifically: direct initialization with parentheses, copy initialization with =, and plain assignment are all still errors.

#include <iostream>

enum Aspect : int
{
    danger,
    caution,
    clear,
};

int main()
{
    Aspect fromCast{ static_cast<Aspect>(2) };
    Aspect fromBraces{ 1 };

    std::cout << fromCast << ' ' << fromBraces << '\n';

    return 0;
}

Output:

2 1

Casting is safe for any value an enumerator actually has. It is also safe for any value that fits the underlying type's range, even one no enumerator names. Casting outside that range is undefined behavior.

For advanced readers
With an explicit base, the enumeration's range is simply that type's range. Without one, the compiler may choose any signed or unsigned type large enough to hold every enumerator, so the safe range is the smallest bit-width that fits them all. Enumerators of 1, 7, and 15 fit an unsigned 4-bit range, making 0 through 15 safe to cast. Enumerators of -20, 5, and 10 need a signed 6-bit range, making -32 through 31 safe.

Summary

Values are integers: the first enumerator defaults to 0 and each subsequent one is a step higher.

Explicit values may be negative, may skip, and may repeat, but repeated values make two names interchangeable rather than distinct. Prefer the compiler's numbering unless something external dictates otherwise.

Value initialization gives 0 whether or not an enumerator has that value. Put the best default at zero, or add an explicit "unknown" enumerator there.

Implicit conversion to integers is why streaming an enumeration prints a number: no operator<< matches the enumeration type, so the value converts and the integral overload runs.

The underlying type is implementation-defined for unscoped enumerations, usually int. Specify a base only when the size matters, and avoid the 8-bit types, which print as characters.

Integers do not convert back implicitly. Use static_cast, or brace initialization when the enumeration has an explicit base, which C++17 allows. Casting a value outside the underlying type's range is undefined behavior.