Variables, const and constexpr are the three levels of commitment C++ offers for storing a value. A variable can change, a [[const]] value is fixed after initialisation, and a [[constexpr]] value is not merely fixed but computed by the compiler before the program runs. Choosing the right one of the three is the cheapest way to make a program easier to reason about.
Declaring a variable and initialising it
A variable declaration introduces a name and gives it a type. The initialiser sets its first value.
#include
int main() {
int guests = 4; // initialised with copy-initialisation
int bowls{6}; // braced initialisation
double factor = 2.5;
guests = guests + 1; // assignment changes the value later
bowls = bowls + 1;
std::cout << "guests: " << guests << "\n";
std::cout << "bowls: " << bowls << "\n";
std::cout << "factor: " << factor << "\n";
return 0;
}Two things are worth internalising here. First, a declaration without an initialiser leaves the variable holding whatever happened to be in memory, and reading it is — a classic source of "the value changes every time I run it" confusion. Second, the braced form undefined behaviourint bowls{6}; refuses to lose information: it will not compile if the value does not fit the type, which is a cheap guard against typos.
What const guarantees
The const qualifier promises that the object will not be modified through that name after initialisation. The compiler enforces the promise and diagnoses any attempt to break it.
#include
int main() {
const int max_guests = 12;
const double ratio = 1.0 / 3.0;
std::cout << "max guests: " << max_guests << "\n";
std::cout << "ratio: " << ratio << "\n";
return 0;
}The value is still decided at runtime, it simply cannot be changed afterwards. This matters more than it first appears: a const variable cannot be modified by accident, cannot be modified by a function that receives it, and the compiler can often keep it in a register instead of memory.
A const object must be initialised at the point of declaration, because there is no later opportunity to give it a value.
What constexpr guarantees
constexpr goes one step further. The initialiser must be a constant expression, which the compiler evaluates during translation. The result becomes part of the program image rather than something computed when the program starts.
#include
constexpr int servings_per_batch = 8;
constexpr double grams_per_serving = 120.0;
constexpr double total_grams = servings_per_batch * grams_per_serving;
int main() {
std::cout << "servings per batch: " << servings_per_batch << "\n";
std::cout << "total grams: " << total_grams << "\n";
return 0;
}The values are fixed at compile time and baked into the program:
servings per batch: 8
total grams: 960A constexpr function is one the compiler can evaluate at compile time. Marking a function constexpr does not force it to be evaluated early — it only grants permission — but when it is called with constant arguments the compiler will do it.
#include
constexpr int scale_recipe(int servings, int target) {
return servings * target / 4;
}
int main() {
constexpr int doubled = scale_recipe(4, 8);
int runtime_target = 6;
int runtime_result = scale_recipe(4, runtime_target);
std::cout << "doubled: " << doubled << "\n";
std::cout << "runtime: " << runtime_result << "\n";
return 0;
}The first call happens entirely inside the compiler; the second happens at runtime because its argument is not known in advance.
const, constexpr and plain variables
The three options differ in what they promise and in what they cost.
- A plain
intis the default. Use it when the value genuinely changes. constfixes the value but allows it to be computed at runtime. Use it for configuration, limits and anything a caller must not modify.constexprfixes the value and demands it be computable at compile time. Use it for magic numbers replaced by named constants, array bounds, switch case labels and template arguments.
There is one more category: const on a pointer. That is covered in the pointers module, but the short version is that const applies to whatever is on the left of the *, so int* const p is a constant pointer to a mutable integer and const int* p is a mutable pointer to a constant integer.
Naming conventions that make intent visible
C++ does not enforce a naming convention, but consistency pays off enormously. The widespread convention is:
snake_casefor variables and functions.PascalCaseorCamelCasefor types and classes.UPPER_SNAKE_CASEor akprefix for compile-time constants.
#include
int main() {
const int kMaxBatches = 6;
int batches_remaining = 6;
while (batches_remaining > 0) {
batches_remaining--;
}
std::cout << "batches made: " << kMaxBatches - batches_remaining << "\n";
std::cout << "batches left: " << batches_remaining << "\n";
return 0;
}When someone reads kMaxBatches they know it cannot change, which means they can trust every use of it. That is the whole point of the qualifier: not to prevent change, but to publish which values are fixed.
Scope and shadowing of variables
Variables live in the block where they are declared, and the same name can be reused in a nested block. The inner declaration shadows the outer one for the duration of the inner block.
#include
int main() {
int scale = 1;
{
int scale = 2;
std::cout << "inner scale: " << scale << "\n";
}
std::cout << "outer scale: " << scale << "\n";
return 0;
}The output shows the two independent values:
inner scale: 2
outer scale: 1Shadowing compiles and runs, but it is a frequent source of confusion in review. Prefer a distinct name when the two values mean different things.
Quick reference
- Always initialise. An uninitialised variable holds garbage.
- Braced initialisation
int x{5};rejects values that do not fit. constfixes a value after initialisation; the compiler enforces it.constexprrequires a constant expression and lets the compiler evaluate it early.- A
constexprfunction is not forced to run at compile time, only allowed to. conston a pointer applies to the pointer or the pointee depending on which side of the*it appears.- Use a consistent naming convention so that immutability is visible at a glance.
Try it yourself: take the recipe scaler and mark every value that should never change as [[const]], then mark the ones that can be worked out before the program starts as [[constexpr]]. Then deliberately try to assign to each of them and read the compiler's complaint — it tells you precisely which guarantee you were relying on.