5 Enums, constexpr, and Compile-Time Programming
Catching mistakes at compile time is always better than catching them at run time. A program that fails to compile is annoying but harmless. A program that compiles and then crashes in production is a disaster. C++ gives you several tools to move work from run time to compile time: scoped enumerations give type-safe named constants, constexpr and consteval let you compute values during compilation, static_assert checks conditions before the program ever runs, and type aliases make complex types readable. In this chapter you will learn how to use these features to write code that is safer, faster, and clearer.
5.1 Scoped Enumerations: enum class
Suppose you want a variable that names the day of the week. You could use an int and pretend that 0 means Monday, 1 means Tuesday, and so on, but every reader would have to remember that mapping — and nothing stops a stray 42 from sneaking in.
An enumeration lets you create a new type whose values are a fixed list of names:
enum class Day {
Monday, Tuesday, Wednesday, Thursday,
Friday, Saturday, Sunday
};
Day today = Day::Saturday;Each name (Monday, Tuesday, …) is called an enumerator. Behind the scenes the compiler assigns each one an integer (starting at 0 by default), but the variable’s type is Day, not int. That gives you two safety nets:
- You cannot assign a random integer to a
Day—Day d = 99;is an error. Dayvalues do not silently mix with other types —today + 1is rejected unless you explicitly convert.
The class keyword in enum class is what gives you that safety. A plain enum (the older C-style form) leaks its enumerator names into the surrounding scope and lets them implicitly convert to int:
enum Color { Red, Green, Blue }; // C-style: Red, Green, Blue are loose names
int x = Red; // works, Red == 0
enum class Mood { Happy, Sad, Angry }; // scoped: must say Mood::Happy
int y = Mood::Happy; // ERROR
int z = static_cast<int>(Mood::Happy); // OK with explicit castSince C++23, <utility> provides std::to_underlying as the idiomatic way to convert an enum to its underlying integer type:
template<class Enum>
constexpr std::underlying_type_t<Enum> to_underlying(Enum e) noexcept;It does the same job as the static_cast above, but it always yields the enum’s actual underlying type, so it cannot silently truncate the value.
Tip: Default to enum class for new code. Reach for plain enum only when you are wrapping legacy C headers that use it, or when the values genuinely behave like loose constants.
You can give the underlying integer type explicitly (here std::int16_t from <cstdint>), and you can pick the starting value:
enum class Status : std::int16_t {
Ok = 200,
NotFound = 404,
Error = 500,
};That makes Status exactly two bytes and pins specific numeric codes to each name — handy when the values mean something to the outside world (HTTP status codes here).
5.1.1 using enum (C++20)
If you get tired of writing the enum name repeatedly, C++20 lets you bring enumerators into scope:
#include <iostream>
enum class Direction { North, South, East, West };
void navigate(Direction d) {
using enum Direction; // bring all enumerators into scope
switch (d) {
case North: std::cout << "Going north\n"; break;
case South: std::cout << "Going south\n"; break;
case East: std::cout << "Going east\n"; break;
case West: std::cout << "Going west\n"; break;
}
}
int main() {
navigate(Direction::North);
return 0;
}Going north
Tip: Use using enum only in limited scopes (like inside a function or switch statement) to avoid polluting the outer namespace — that would defeat the purpose of scoped enums.
5.2 constexpr
A constexpr variable or function can be evaluated at compile time. The compiler computes the result and bakes it into the binary, so there is no run-time cost.
5.2.1 constexpr Variables
constexpr int max_tracks = 100;
constexpr double pi = 3.14159265358979;A constexpr variable must be initialized with a value the compiler can compute. It is implicitly const.
5.2.2 constexpr Functions
A constexpr function can be evaluated at compile time if all its arguments are compile-time constants. If called with run-time values, it runs at run time like a normal function:
#include <iostream>
constexpr int factorial(int n) {
int result = 1;
for (int i = 2; i <= n; ++i) {
result *= i;
}
return result;
}
int main() {
constexpr int f5 = factorial(5); // computed at compile time
std::cout << f5 << "\n"; // 120
int n = 6;
int f6 = factorial(n); // computed at run time (n is not constexpr)
std::cout << f6 << "\n"; // 720
return 0;
}120
720constexpr functions can use loops, conditionals, and local variables. The restrictions are that everything must be evaluable at compile time: no I/O, no undefined behavior, and a careful rule on dynamic allocation. Since C++20, new and delete (and the standard containers built on them, like std::vector) can run inside a constexpr evaluation, but only as transient allocations: every new made during the evaluation must be matched by a delete before the evaluation finishes. Memory allocated at compile time cannot survive into the running program.
5.2.3 if constexpr
if constexpr evaluates a condition at compile time and discards the unused branch entirely. This is especially useful in templates:
#include <iostream>
#include <type_traits>
template<typename T>
void describe(T value) {
if constexpr (std::is_integral_v<T>) {
std::cout << value << " is an integer\n";
} else if constexpr (std::is_floating_point_v<T>) {
std::cout << value << " is a float\n";
} else {
std::cout << value << " is something else\n";
}
}
int main() {
describe(42);
describe(3.14);
describe("Complicated");
return 0;
}42 is an integer
3.14 is a float
Complicated is something elseUnlike a regular if, the discarded branch does not need to compile for the given type. This is what makes if constexpr essential for template metaprogramming.
5.3 consteval
consteval (C++20) is stricter than constexpr. A consteval function must be evaluated at compile time. If you try to call it with run-time values, the compiler rejects the code:
consteval int square(int n) {
return n * n;
}
constexpr int x = square(5); // OK: compile time
// int y = 6; square(y); // error: y is not a compile-time constantUse consteval when a function only makes sense at compile time — like computing array sizes, lookup table entries, or hash values for compile-time string matching.
Tip: Use constexpr when a function can run at compile time. Use consteval when it must run at compile time.
5.4 constinit
constinit (C++20) ensures that a variable with static storage duration (globals, file-scope variables, static locals) is initialized at compile time, avoiding the “static initialization order fiasco”:
constinit int global_max = 100; // guaranteed compile-time initializationUnlike constexpr, constinit does not make the variable const — you can modify it after initialization:
constinit int counter = 0; // initialized at compile time
void increment() {
counter++; // OK: counter is not const
}
Wut: constinit only affects initialization, not the entire lifetime of the variable. A constinit variable is mutable after initialization (unlike constexpr, which is always const).
5.5 static_assert
static_assert checks a condition at compile time. If the condition is false, compilation fails with the message you provide:
static_assert(sizeof(int) >= 4, "int must be at least 4 bytes");
static_assert(sizeof(void*) == 8, "this code assumes 64-bit pointers");Since C++17 the message is optional — static_assert(sizeof(int) >= 4); works too, and the compiler reports the failed condition itself.
It is useful for documenting and enforcing assumptions about the platform, and for checking template parameters:
template<typename T>
class NumericBuffer {
static_assert(std::is_arithmetic_v<T>, "T must be a numeric type");
// ...
};
NumericBuffer<int> ok; // compiles
// NumericBuffer<std::string> bad; // error: T must be a numeric typestatic_assert has zero run-time cost — it exists only during compilation.
5.6 Type Aliases
Type aliases give a new name to an existing type, making complex types readable and easier to change later.
5.6.1 using vs. typedef
C++ has two ways to create type aliases. The modern using syntax is preferred:
// Modern (preferred)
using Playlist = std::vector<std::string>;
using SongMap = std::map<std::string, int>;
// Old-style typedef (still works, same effect)
typedef std::vector<std::string> Playlist;
typedef std::map<std::string, int> SongMap;using is easier to read, especially for function pointer types:
// using
using Callback = void(*)(int);
// typedef --- harder to parse
typedef void(*Callback)(int);5.6.2 Alias Templates
using can also create templated type aliases, which typedef cannot:
template<typename T>
using Vec = std::vector<T>;
Vec<int> numbers = {1, 2, 3};
Vec<std::string> words = {"Estoy", "aqui"};This is especially useful for simplifying nested template types:
template<typename K, typename V>
using HashMap = std::unordered_map<K, V>;
HashMap<std::string, int> scores; 5.7 Bit Manipulation (<bit>)
The <bit> header (C++20) provides fast, well-defined operations on the bits of integer types. Before <bit>, you reached for compiler intrinsics or hand-rolled loops — both were either non-portable or slower than the hardware instructions actually available.
The everyday utilities:
int std::popcount (UInt x); // count of 1-bits
int std::countl_zero (UInt x); // leading zeros
int std::countr_zero (UInt x); // trailing zeros
bool std::has_single_bit(UInt x); // true if x is exactly a power of 2
UInt std::bit_ceil (UInt x); // smallest power of 2 >= x
UInt std::bit_floor (UInt x); // largest power of 2 <= x
T std::bit_cast<T>(const U& x); // reinterpret bit pattern as T#include <bit>
#include <cstdint>
#include <iostream>
int main() {
std::uint32_t x = 0b1011'0010;
std::cout << "popcount: " << std::popcount(x) << "\n"; // 4
std::cout << "trailing 0s: " << std::countr_zero(x) << "\n"; // 1
std::cout << "is pow2: " << std::has_single_bit(x) << "\n"; // 0
std::cout << "is pow2(8): " << std::has_single_bit(8u) << "\n"; // 1
std::cout << "bit_ceil(13):" << std::bit_ceil(13u) << "\n"; // 16
return 0;
}std::bit_cast<T> is the safe replacement for reinterpret_cast when you need to view a value’s bit pattern as a different type. It is constexpr, requires the source and target types to be the same size and trivially copyable, and unlike reinterpret_cast it does not invoke undefined behavior:
float f = 1.0f;
std::uint32_t bits = std::bit_cast<std::uint32_t>(f); // 0x3F800000Use this when you need to inspect the bit pattern of a float, send raw bytes over a wire, or implement a hash that mixes the bits of multiple values.
Tip: Prefer the <bit> functions over hand-rolled bit-twiddling loops. They state your intent clearly, and when you compile for a CPU that has the instruction (e.g. -march=x86-64-v2 or -mpopcnt on x86-64), std::popcount becomes a single popcnt instruction. With default flags the compiler may emit a library call instead, and modern compilers can sometimes recognize a hand-written counting loop too — but the named function is always the clearer choice.
5.8 Attributes
Attributes are square-bracketed annotations the compiler reads to do extra checking, generate warnings, or produce better code. You met [[nodiscard]] in Gorgo Starting C++; the rest of the standard catalog is short and worth knowing.
[[nodiscard]] // warn if return value is ignored
[[maybe_unused]] // suppress "unused" warnings
[[deprecated("reason")]] // warn at every use
[[fallthrough]] // I meant the missing break in a switch
[[likely]] [[unlikely]] // hint to the optimizer's code layout (C++20)
[[noreturn]] // this function never returns[[maybe_unused]] is the answer to “I have a parameter I sometimes need (debug builds, an interface), and I am tired of seeing the warning”:
void log_event([[maybe_unused]] int priority, const std::string& msg) {
#ifndef NDEBUG
std::cerr << "[" << priority << "] " << msg << "\n";
#else
std::cout << msg << "\n"; // priority unused in release builds
#endif
}[[deprecated]] is how you ease an API rewrite — old callers still compile, but they get a warning that points at the replacement:
[[deprecated("use connect_async instead")]]
void connect(int fd);[[fallthrough]] is the C++17 way to silence the “missing break” warning in a switch when you actually wanted to fall through:
switch (mode) {
case 1:
setup();
[[fallthrough]];
case 2:
run();
break;
}[[likely]] and [[unlikely]] (C++20) hint to the optimizer which branch you expect at runtime — handy in hot inner loops, almost never necessary elsewhere:
if (x > 0) [[likely]] {
// common path
} else {
handle_error();
}[[noreturn]] documents that a function does not return (e.g., it throws, calls std::exit, or runs forever). The compiler uses this to skip “missing return” warnings in callers and to apply better dead-code elimination.
Tip: Default to [[nodiscard]] on factory functions and on functions that return error codes or std::expected. A caller who ignores the return value almost always has a bug; the warning catches it at compile time.
Wut: Attributes are hints in most cases. A compiler is allowed to ignore an unrecognized attribute — which is why standard attributes like [[likely]] and the older GCC-specific __attribute__((hot)) can both compile, even though only one of them is part of the language.
5.9 Try It: Compile-Time Playground
Here is a program that exercises the compile-time features from this chapter. Type it in, compile with g++ -std=c++23, and experiment:
#include <array>
#include <iostream>
#include <type_traits>
enum class Note { C = 0, D = 2, E = 4, F = 5, G = 7, A = 9, B = 11 };
constexpr int note_to_midi(Note n, int octave) {
return (octave + 1) * 12 + static_cast<int>(n);
}
consteval int bpm_to_ms(int bpm) {
return 60'000 / bpm;
}
template<typename T>
using Grid = std::array<std::array<T, 4>, 4>;
int main() {
// constexpr
constexpr int middle_c = note_to_midi(Note::C, 4);
std::cout << "Middle C MIDI: " << middle_c << "\n";
// consteval --- must be compile time
constexpr int beat_ms = bpm_to_ms(120);
std::cout << "120 BPM = " << beat_ms << " ms per beat\n";
// static_assert
static_assert(note_to_midi(Note::C, 4) == 60, "Middle C should be MIDI 60");
static_assert(bpm_to_ms(120) == 500, "120 BPM should be 500 ms");
// enum class with using enum
using enum Note;
constexpr auto a440 = note_to_midi(A, 4);
std::cout << "A440 MIDI: " << a440 << "\n";
// Type alias template
Grid<int> pattern = {{
{1, 0, 1, 0},
{0, 1, 0, 1},
{1, 0, 1, 0},
{0, 1, 0, 1}
}};
std::cout << "Pattern[0][2]: " << pattern[0][2] << "\n";
return 0;
}Middle C MIDI: 60
120 BPM = 500 ms per beat
A440 MIDI: 69
Pattern[0][2]: 1Try changing the bpm_to_ms call to use a non-constexpr variable and see the error. Add more notes and experiment with static_assert to verify your MIDI calculations.
5.10 Key Points
enum classcreates scoped enumerations with no implicit conversion tointand no name leakage. Usestatic_castfor explicit conversion.- You can specify the underlying type:
enum class Foo : uint8_t. using enum(C++20) brings enumerators into scope within a limited region.constexprvariables are compile-time constants.constexprfunctions can run at compile time or run time depending on their arguments.consteval(C++20) functions must run at compile time — calling them with run-time values is a compile error.constinit(C++20) guarantees compile-time initialization for static-duration variables without making themconst.if constexprevaluates conditions at compile time and discards the unused branch, which is essential for templates.static_assertchecks conditions at compile time with zero run-time cost.- Type aliases with
usingare preferred overtypedef.usingsupports alias templates;typedefdoes not.
5.11 Exercises
Think about it: Why does
enum classrequirestatic_castto convert toint, when the oldenumconverted implicitly? What bugs does this prevent?What does this print?
enum class Suit : int { Hearts = 0, Diamonds, Clubs, Spades }; int x = static_cast<int>(Suit::Spades); std::cout << x << "\n";Where is the bug?
enum class Priority { Low, Medium, High }; void handle(Priority p) { if (p == 2) { std::cout << "High priority!\n"; } }Calculation: What is the value of
result?constexpr int power(int base, int exp) { int result = 1; for (int i = 0; i < exp; ++i) { result *= base; } return result; } constexpr int result = power(2, 10);Think about it: What is the practical difference between
constexprandconsteval? When would you use one over the other?Where is the bug?
consteval int compute(int x) { return x * x; } int main() { int n; std::cin >> n; int result = compute(n); std::cout << result << "\n"; return 0; }What does this print?
template<typename T> void check(T value) { if constexpr (std::is_integral_v<T>) { std::cout << value * 2 << "\n"; } else { std::cout << value << "\n"; } } check(5); check(3.14);Think about it: Why does
constinitexist as a separate keyword fromconstexpr? What problem does it solve thatconstexprdoes not?Where is the bug?
using StringPair = std::pair<std::string, std::string>; StringPair get_pair() { return {"Hola", "mundo"}; } auto [a, b] = get_pair(); std::cout << a << " " << b << "\n";(Trick question — is there actually a bug?)
Write a program that defines a
constexprfunction to convert Fahrenheit to Celsius ((f - 32) * 5 / 9.0). Usestatic_assertto verify that212F is100.0C and32F is0.0C. Then define anenum class Season { Spring, Summer, Fall, Winter }and aconstexprfunction that returns a typical temperature for each season. Print all four seasons and their temperatures.What does this print?
#include <bit> #include <cstdint> #include <iostream> int main() { std::uint32_t x = 0xF0; std::cout << std::popcount(x) << " " << std::countl_zero(x) << " " << std::countr_zero(x) << " " << std::has_single_bit(x) << "\n"; return 0; }Walk through each value before you compile.
Where is the bug?
[[nodiscard]] int parse(const std::string& s); int main() { parse("123"); // (a) int n = parse("456"); // (b) return n; }Which line does the compiler flag, and what is the right way to silence the warning if you genuinely do not need the result?
Think about it:
[[likely]]and[[unlikely]]are hints. Why does the language not provide a way to force one branch order over another? What information does the compiler have that you might not?