Const, constexpr, consteval, and constinit in Modern C++
C++ has several keywords that look related because they all start with const, but they solve different problems. const is about not modifying an object through a name or reference. constexpr is about values and functions that can participate in compile time evaluation. consteval forces a function call to happen at compile time. constinit checks how static or thread local objects are initialized.
These differences matter because compile time evaluation, immutability, and initialization order are not the same concept. Treating them as interchangeable can lead to confusing code, missed optimizations, or subtle startup bugs.
const: A Read Only View
const is the oldest and most widely used keyword in this group. For an object, it means the object cannot be modified through that name.
|
1 2 3 4 5 |
const int maxRetries = 5; // maxRetries = 8; // error |
This does not automatically mean the value is computed at compile time. The initialization may happen at runtime:
|
1 2 3 4 5 6 7 8 |
#include <string> std::string read_machine_name(); const std::string machineName = read_machine_name(); |
machineName cannot be changed after initialization, but the function call that produces it still happens at runtime.
const also appears on member functions:
|
1 2 3 4 5 6 7 8 9 10 11 12 |
class SensorReading { public: double volts() const { return millivolts_ / 1000.0; } private: int millivolts_ {}; }; |
Here const means volts() can be called on a const SensorReading and promises not to modify normal data members. This is separate from compile time evaluation.
Use const when the main rule is "this value should not be changed here." It improves correctness and communicates intent.
constexpr: Usable at Compile Time
constexpr asks for something stronger. A constexpr variable must be initialized by a constant expression, so it can be used where the compiler requires a compile time value.
|
1 2 3 4 5 6 7 |
#include <array> constexpr int channelCount = 8; std::array<int, channelCount> samples {}; |
A const int with a simple initializer can often work in similar places, but constexpr is clearer. It says the compile time property is intentional, not accidental.
constexpr can also be applied to functions. This means the function may be evaluated at compile time when called with compile time arguments.
|
1 2 3 4 5 6 7 8 |
constexpr int bytes_for_frames(int frameCount, int channels) { return frameCount * channels * 2; } static_assert(bytes_for_frames(64, 2) == 256); |
The same function can still run at runtime:
|
1 2 3 4 5 |
int requestedFrames = 128; int bufferBytes = bytes_for_frames(requestedFrames, 2); |
That dual behavior is the key idea. constexpr enables compile time evaluation, but it does not force every call to be compile time.
Modern C++ allows useful logic inside constexpr functions, including local variables, branches, and loops:
|
1 2 3 4 5 6 7 8 9 10 11 12 |
constexpr int checksum(const char* text) { int value = 0; for (int i = 0; text[i] != '\0'; ++i) { value = value * 31 + text[i]; } return value; } static_assert(checksum("adc") != 0); |
Use constexpr for constants, lookup sizes, small calculations, and utility functions that should work both at compile time and runtime.
consteval: Compile Time Only
consteval is stricter than constexpr. A consteval function is an immediate function. Every call must produce a compile time result.
|
1 2 3 4 5 6 7 8 |
consteval int protocol_tag(char a, char b, char c) { return (a << 16) | (b << 8) | c; } constexpr int spiTag = protocol_tag('S', 'P', 'I'); |
This fails if any input is only known at runtime:
|
1 2 3 4 5 |
char prefix = 'U'; // int tag = protocol_tag(prefix, 'A', 'R'); // error |
That makes consteval useful when runtime execution would be wrong or meaningless. Common examples include compile time validation, generated identifiers, fixed build time transforms, and small helpers that replace unsafe macros.
For example:
|
1 2 3 4 5 6 7 8 9 10 11 |
consteval int require_valid_pin(int pin) { if (pin < 0 || pin > 31) { throw "pin number is outside the supported range"; } return pin; } constexpr int ledPin = require_valid_pin(13); |
The function cannot be called with a value read from a configuration file or hardware register. That is the point. consteval tells readers and the compiler that the answer must be settled before the program runs.
Use consteval when runtime use should be impossible.
constinit: Safe Static Initialization
constinit is different from both const and constexpr. It does not mean immutable. It does not make a variable usable in constant expressions. It only requires constant initialization.
It applies to variables with static or thread local storage duration:
|
1 2 3 4 5 6 7 8 9 10 11 12 |
constexpr int default_timeout_ms() { return 250; } constinit int globalTimeoutMs = default_timeout_ms(); void set_timeout(int value) { globalTimeoutMs = value; // allowed } |
globalTimeoutMs is initialized before dynamic initialization begins, but it can still be modified later because it is not const.
Why is that useful? Static initialization order can be a problem when global objects in different translation units depend on each other. constinit gives a compile time check that a variable does not require dynamic initialization.
This will not compile:
|
1 2 3 4 5 6 |
int load_port_from_environment(); // constinit int servicePort = load_port_from_environment(); // error |
The initializer is not a constant initializer, so the compiler rejects it. That rejection is useful because it prevents a global variable from quietly joining the dynamic initialization phase.
Use constinit for global or thread_local state that must be ready during static initialization, but may still change later.
Combining the Keywords
Some combinations make sense, and some only add noise.
constexpr variables are already constant:
|
1 2 3 4 |
constexpr int maxPacketBytes = 1500; |
Writing const constexpr int is allowed, but redundant. Most code should prefer the shorter form.
constinit const can be reasonable for static storage when you want both constant initialization and immutability:
|
1 2 3 4 |
constinit const int firmwareMajor = 3; |
Still, constexpr is often better if the value should also be usable in constant expressions:
|
1 2 3 4 |
constexpr int firmwareMinor = 7; |
For member functions, const and constexpr mean different things and can appear together:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
struct RegisterField { int offset {}; int width {}; constexpr int mask() const { return ((1 << width) - 1) << offset; } }; static_assert(RegisterField{4, 3}.mask() == 0b01110000); |
constexpr says the function can be used during compile time evaluation. The trailing const says calling mask() does not modify the RegisterField object.
A Practical Selection Rule
Choose const when you want a value or member function to be non modifying.
Choose constexpr when a value or function should be available to compile time code, while still being usable at runtime when needed.
Choose consteval when a function must never run at runtime.
Choose constinit when a static or thread_local variable must be initialized during constant initialization, but does not necessarily need to be immutable.
The names are similar, but the guarantees are different. const protects against modification. constexpr enables constant expressions. consteval demands immediate compile time execution. constinit protects static initialization. Clear use of these keywords makes modern C++ code easier to reason about and helps the compiler catch mistakes earlier.