C++ / FUNDAMENTAL TYPES AND VARIABLES
Enumerations and scoped enum classes
Declare unscoped enums and enum class types in C++, control their underlying integer type, and convert between an enum and a number deliberately.
What you will learn
- Choose enum class by default so enumerator names stay inside the enum's own scope
- Convert a scoped enum to its number only through an explicit static_cast
- Fix the underlying type with `enum class S : std::uint8_t` to pin size and range
- Read enumerator values: an unlisted one continues from the previous one, not from zero
Understanding Enumerations and scoped enum classes
An enumeration is a distinct type plus a fixed list of named constants that the compiler stores in an ordinary integer. With `enum Direction { North, East, South, West };` the enumerators are values of type Direction, but they are also injected into the surrounding scope, and a Direction converts to int whenever an int is wanted. The type therefore documents intent without enforcing it: a Direction can be handed to a function taking an int, or compared with a completely unrelated enum, and nothing complains.
`enum class` changes exactly two things, and both are about isolation. The enumerator names become members of the enum's scope, so `Status::Ok` is required and two different enums may both use the name Ok; and the implicit conversion to an integer disappears (the reverse direction always needed a cast anyway). The mental model is a type that borrows an integer's storage but none of the integer's behaviour: equality, ordering and switching work within one enum, and you get a compile error the moment the value is mixed with numbers or with a different enum.
Every enumeration also has an underlying integer type, which decides its size, its range, and what casting a number in is allowed to mean. A scoped enum's underlying type is int unless you write a base such as `: std::uint8_t`; an unscoped enum with no base gets whatever type the implementation considers wide enough for the listed enumerators, which is why its `sizeof` is not portable. When the underlying type is fixed, every value that fits in it is a legal value of the enum, so `static_cast<HttpCode>(404)` is well defined even with no matching enumerator, and a fixed base also allows the enum to be forward-declared because its size is known from the declaration alone.
concept placeholder
<cstdint>
<iostream>
enum Direction { North, East, South, West }; // unscoped
enum class Status : std::uint8_t { Ok = 0, Retry = 3, Fail }; // scoped, one byte
int main() {
Direction d = South; // the name South is visible here without qualification
int steps = d; // silent conversion to int
std::cout << "South as int: " << steps << '\n';
std::cout << "West prints as: " << West << '\n';
Status s = Status::Retry; // qualification is mandatory
// int bad = s; // error: no implicit conversion from Status to int
std::cout << "Retry as int: " << static_cast<int>(s) << '\n';
std::cout << "Fail as int: " << static_cast<int>(Status::Fail) << '\n';
std::cout << "s != Status::Ok: " << (s != Status::Ok) << '\n';
std::cout << "sizeof(Direction)=" << sizeof(Direction)
<< " sizeof(Status)=" << sizeof(Status) << '\n';
}A scoped enumeration is a distinct type whose enumerator names live inside it and which never turns into an integer on its own.
Worked examples
Explicit values and aliases
Shows how unlisted enumerators are numbered, how two names can share a value, and that a cast-in value need not be listed.
<iostream>
enum class HttpCode { // underlying type defaults to int
Ok = 200,
Created, // continues from the previous enumerator
Moved = 301,
Found,
Success = Ok // another name for 200, not a new value
};
int main() {
std::cout << static_cast<int>(HttpCode::Created) << '\n';
std::cout << static_cast<int>(HttpCode::Found) << '\n';
std::cout << (HttpCode::Success == HttpCode::Ok) << '\n';
HttpCode c = static_cast<HttpCode>(404); // no enumerator names it
std::cout << static_cast<int>(c) << '\n';
}Example explained
Line 1Created has no initialiser, so it takes the previous value plus one: 200 + 1 = 201.
Line 2Found follows Moved, giving 302; numbering resumes from the enumerator written just before it, not from the largest value so far.
Line 3Success = Ok makes two names for 200, so comparing them yields true, printed as 1.
Line 4404 fits the fixed underlying type int, so casting it in is well defined; an enum type does not restrict you to the listed values.
Bit flags with a scoped enum
Demonstrates opting back into bitwise arithmetic explicitly, which is what the missing implicit conversion costs you.
<cstdint>
<iostream>
enum class Perm : std::uint8_t { None = 0, Read = 1, Write = 2, Exec = 4 };
constexpr Perm operator|(Perm a, Perm b) {
return static_cast<Perm>(static_cast<std::uint8_t>(a) | static_cast<std::uint8_t>(b));
}
constexpr bool contains(Perm set, Perm bit) {
return (static_cast<std::uint8_t>(set) & static_cast<std::uint8_t>(bit)) != 0;
}
int main() {
Perm p = Perm::Read | Perm::Write;
std::cout << "bits: " << static_cast<int>(p) << '\n';
std::cout << "read: " << contains(p, Perm::Read) << '\n';
std::cout << "exec: " << contains(p, Perm::Exec) << '\n';
}Example explained
Line 1Perm::Read | Perm::Write would not compile without the overload: a scoped enum has no built-in bitwise operators.
Line 2The overload casts both operands down to the underlying type, combines them as integers, then casts back, so the result stays a Perm instead of decaying to int.
Line 3contains masks and compares against zero, returning bool: 1 for Read, 0 for Exec, since 3 has bits 1 and 2 set.
Line 4Marking both functions constexpr lets flag combinations be folded at compile time and used in constant expressions.
Same enumerator name in two enums
Shows that scoped enums keep their names apart and that a switch without a default catches unnamed values.
<iostream>
enum class Fruit { Apple, Orange };
enum class Brand { Apple, Orange }; // same names, separate scopes
const char* describe(Fruit f) {
switch (f) {
case Fruit::Apple: return "fruit: apple";
case Fruit::Orange: return "fruit: orange";
}
return "fruit: unknown";
}
int main() {
std::cout << describe(Fruit::Apple) << '\n';
std::cout << describe(static_cast<Fruit>(7)) << '\n';
std::cout << (Fruit::Apple < Fruit::Orange) << '\n';
// std::cout << (Fruit::Apple == Brand::Apple); // error: unrelated types
std::cout << static_cast<int>(Brand::Apple) << '\n';
}Example explained
Line 1Fruit and Brand can both declare Apple because each enumerator belongs to its enum's scope; two unscoped enums doing this would be a redefinition error.
Line 2static_cast<Fruit>(7) is a valid Fruit because the underlying type is int, so it matches no case label and reaches the fallback return.
Line 3Leaving out default: means the compiler warns about an unhandled case as soon as a third enumerator is added to Fruit.
Line 4Fruit::Apple == Brand::Apple is rejected even though both are 0: neither type converts to the other, which is the error the whole feature exists to produce.
Important notes
The `sizeof` of an unscoped enum without an explicit base is the implementation's choice (4 on mainstream desktop compilers), since it only has to pick a type wide enough for the enumerators; write the base out when the size matters.
C++20 allows `using enum Status;` inside a function or class to shed the qualification where it becomes noisy, and C++23 adds `std::to_underlying(e)` as a shorter spelling of `static_cast<std::underlying_type_t<Status>>(e)`.
Common mistakes
Writing `std::cout << Status::Ok;` for a scoped enum: there is no operator<< for it and it will not decay to int, so the build fails with a wall of overload-resolution errors; print `static_cast<int>(Status::Ok)` or a switch-based name function.
Relying on unscoped enums in one scope: `enum Color { Red };` beside `enum Signal { Red };` is a redefinition error, and where the names do differ, `someColor == someSignal` still compiles because both sides become ints, silently reporting equality between unrelated things.
Casting an arbitrary integer into an unscoped enum that has no explicit base, such as `static_cast<Weekday>(9)` when Weekday lists 0 to 6: the value is outside the enum's range and the result is unspecified, so a switch over it matches no case and the bug only shows up at run time.
Try it yourself
Change, predict, then run
Define `enum class LogLevel : std::uint8_t { Trace, Debug, Info, Warn, Error };`, then write `const char* name(LogLevel)` as a switch with no default label and `bool passes(LogLevel msg, LogLevel min)` that compares the two enum values directly. Print the name of all five levels and whether Debug passes a minimum of Info.
Open the C++ workspaceCheck your understanding
Given `enum Fruit { Apple, Pear };` and `enum class Veg { Carrot, Pea };`, the line `if (Apple == 0)` compiles but `if (Veg::Pea == 1)` does not. What is the actual reason?
- Unscoped enumerators convert implicitly to an integer type, so the comparison has a common type; a scoped enum has no such conversion, so no built-in == applies.
- Veg has no underlying type until you write `enum class Veg : int`, so its values cannot be compared with anything.
- Apple happens to be 0 and comparisons against 0 are always allowed; comparing against 1 would need a cast for either kind of enum.
- operator== between a scoped enum and an int is declared deleted in the standard library.
Show answer
The difference is conversion, not comparison: Apple converts to int, so the built-in == for ints is viable, while Veg::Pea refuses to convert and leaves no candidate operator. Option 2 is the tempting one but wrong, because a scoped enum always has an underlying type (int by default); writing it out changes nothing, and the comparison still needs static_cast<int>(Veg::Pea) == 1.