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.
Scoped Enumerations: enum class
You may have seen traditional C-style enums in older code:
enum Color { Red, Green, Blue };
enum TrafficLight { Red, Yellow, Green }; // error: Red and Green already defined!The names leak into the surrounding scope and collide. They also implicitly convert to int, which can cause subtle bugs.
C++11 introduced scoped enumerations (enum class) to fix both problems:
#include <iostream>
enum class Color { Red, Green, Blue };
enum class TrafficLight { Red, Yellow, Green };
int main()
{
Color c = Color::Blue;
TrafficLight t = TrafficLight::Red;
// int x = c; // error: no implicit conversion to int
int x = static_cast<int>(c); // OK: explicit conversion gives 2
if (t == TrafficLight::Red) {
std::cout << "Stop!\n";
}
return 0;
}Stop!Each enumerator is scoped to its enum, so Color::Red and TrafficLight::Red do not collide. There is no implicit conversion to int — you must use static_cast if you need the numeric value.
Underlying Type
By default, the underlying type of an enum class is int. You can change it:
enum class Status : uint8_t { OK = 0, Error = 1, Pending = 2 };This is useful when memory matters (embedded systems, network protocols) or when you need to match an external format.
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.
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:
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.
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 dynamic allocation (mostly), and no undefined behavior.
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.
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.
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).
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");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.
Type Aliases
Type aliases give a new name to an existing type, making complex types readable and easier to change later.
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);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;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.
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.
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.