C++ const vs constexpr and the auto Keyword Explained
In C++, const means a value cannot change after initialisation and constexpr means it is known at compile time. How auto deduces types, and when it hides a bug.
- Course: C++ study plan
- Module: Fundamental types
- Kind: Lesson
- Reading time: 14 min
- Runtime: Clang 18 · C++20
What is the difference between const and constexpr in C++?
In C++, const promises a variable will not change after initialisation, but its value may come from run time, even from input. constexpr promises the value is known while compiling, so it can be used as an array bound, a template argument or in static_assert. Every constexpr variable is also const; the reverse is not true.
Lesson
Three keywords decide how much the compiler knows about a variable. const promises the value will not change after initialisation; constexpr promises it is known while compiling; auto asks the compiler to work the type out from the initialiser. Used well, each removes a class of mistakes — an accidental assignment, a run-time computation that should have been folded, a silently wrong numeric type. Used carelessly, each hides something. This lesson gives the rules, including the deduction rules for auto that most people learn by tripping over them.
const variables
const int limit = 100;
limit = 200; // error: assignment of read-only variable
const int rounds = readCount(); // fine: const is about after initialisation, not about compile time
const std::vector<int> table{1, 2, 3};
table.push_back(4); // error: only const member functions may be called (Module 8)
A const object must be initialised and cannot be assigned afterwards. The initial value may come from anywhere, including input. The habit worth forming is const by default: declare every local const unless it changes, so that the ones that do change are the ones a reader looks at. It also turns a whole family of typos into compile errors, and it lets the compiler keep the value in a register without ever checking memory again.
const references
int x = 5;
const int& view = x; // an alias that cannot be used to modify x
const double& d = 2.5; // binds to a temporary and extends its lifetime to the reference's scope
int& bad = 2.5; // error: a non-const lvalue reference cannot bind to a temporary
A const T& binds to anything — a variable, a literal, the result of a function call — without copying, which is why it is the default parameter type for anything bigger than a register (Module 4). A plain T& binds only to something that already has a name.
constexpr variables
constexpr int kMaxUsers = 1'000;
constexpr double kPi = 3.141592653589793;
int buckets[kMaxUsers / 10]; // a constant expression is required here
static_assert(kMaxUsers % 10 == 0);
constexpr int fromInput = readCount(); // error: not a constant expression
constexpr on a variable means "initialised by a constant expression and usable wherever the compiler needs a number": array bounds, template arguments, static_assert, case labels. Every constexpr variable is also const; the reverse is not true.
constexpr functions
constexpr long long factorial(int n) {
long long result = 1;
for (int i = 2; i <= n; ++i) result *= i;
return result;
}
static_assert(factorial(20) == 2432902008176640000LL); // evaluated by the compiler
int n;
std::cin >> n;
std::cout << factorial(n) << '\n'; // evaluated at run time, same code
A constexpr function is an ordinary function that the compiler is allowed to evaluate during compilation when every argument is a constant and the result is needed in a constant expression. Otherwise it is called at run time like any other. C++20 permits loops, local variables, if, and even std::vector and std::string inside the compile-time evaluation (as long as they are gone by the end of it); it forbids undefined behaviour, so a constexpr call that overflows is a compile error rather than a silent wrap — a useful side effect. consteval (C++20) marks a function that must be evaluated at compile time, and constinit a variable that must be initialised then; Module 16 goes deeper.
const | constexpr | |
|---|---|---|
| Value known at | run time is fine | compile time, always |
| Can be initialised from input | yes | no |
Usable as an array bound or in static_assert | only if the initialiser was a constant | yes |
| Applies to functions | (member functions: Module 8) | yes — "may run at compile time" |
auto: the deduction rules
auto uses the same rules as template argument deduction (Module 12), which come down to four cases:
const int cx = 10;
int x = 5;
auto a = cx; // int — a COPY; the reference-ness and top-level const of the initialiser are dropped
a = 11; // fine, a is a mutable int
auto& r = cx; // const int& — auto& keeps the reference and therefore the const
const auto& cr = x; // const int& — binds to anything, never copies, never modifies
auto* p = &x; // int* — say the star when you mean a pointer
Rule 1 is the one that matters: auto x = expr; is always a copy. That is safe for an int and expensive for a std::vector, and it is the reason the range-for idiom is for (const auto& item : items) when reading and for (auto& item : items) when modifying — for (auto item : items) item *= 2; doubles a copy and leaves the container untouched.
Literals fix the deduced type, so the literal's suffix is the type annotation:
auto i = 0; // int
auto u = 0u; // unsigned
auto l = 0L; // long
auto ll = 0LL; // long long
auto d = 0.0; // double
auto f = 0.0f; // float
auto c = 'c'; // char
auto s = "text"; // const char* — NOT std::string
auto big = 10'000'000'000; // long: the literal did not fit an int
auto n = v.size(); // std::size_t — the right type without spelling it
auto list = {1, 2, 3}; deduces std::initializer_list<int>, which is almost never wanted; write std::vector<int>.
decltype in brief
decltype(expr) names the type of an expression without evaluating it. decltype(x) for a variable is its declared type; decltype((x)), with the extra parentheses, is the type of the expression x, an lvalue, so it is int&. It matters in templates (Module 12) and in decltype(auto) (Module 16); in everyday code you will meet it in decltype(v)::value_type and similar spellings.
When auto helps, and when it hides
auto earns its place when the type is obvious from the right-hand side (auto p = std::make_unique<Widget>()), when only the compiler can name it (a lambda, an iterator over a nested map), and when it prevents a silent conversion (auto n = v.size() is std::size_t; int n = v.size() narrows). It hides when the numeric type is the point:
auto total = 0; // int — overflows at 2 billion; write 0LL or long long total = 0;
auto mean = sum / count; // integer division if both are integers; nobody can see it
auto name = "Ada"; // const char*; name.size() does not compile
The working rule: auto for types that are obvious or unnameable; the explicit type for numbers whose width or signedness matters, and for anything a reader would otherwise have to look up.
Pitfalls
auto x = someConstRef;makes a mutable copy — surprising when the copy is a large container.auto& r = 5;does not compile;const auto& r = 5;does.for (auto e : v) e = 0;modifies nothing.auto sum = 0;then summing millions of values: it is anint.constexpr int n = readInt();— a run-time value can beconst, neverconstexpr.
Key takeaways
constis a promise not to modify after initialisation; the value may come from run time. Make localsconstby default.const T&binds to anything, including temporaries, without a copy;T&needs an lvalue.constexprvariables are compile-time constants;constexprfunctions run at compile time when their arguments are constants and at run time otherwise, andstatic_assertproves it.auto x = exprcopies and drops references and top-levelconst;auto&andconst auto&keep them; literal suffixes choose the numeric type.- Use
autowhen the type is obvious or unnameable; spell the type when a number's width or signedness matters.
Common questions
What does the auto keyword do in C++?
auto makes the compiler deduce a variable's type from its initialiser: auto n = v.size(); is std::size_t and auto d = 0.0; is double. It follows the rules of template argument deduction, so plain auto always makes a copy and drops references and top-level const.
What is a constexpr function in C++?
A constexpr function is an ordinary function the compiler may evaluate during compilation when every argument is a constant and the result is needed in a constant expression; otherwise it runs at run time like any other. A compile-time call that would overflow is a compile error. consteval (C++20) marks a function that must run at compile time.
What is the difference between auto, auto& and const auto&?
auto x = expr; makes a copy and drops references and top-level const. auto& binds a reference and keeps the const, so modifying through it changes the original. const auto& binds to anything, including temporaries, without copying — the idiom for reading elements in a range-based for loop.
Why does auto s = "text"; not give a std::string?
A string literal is an array of const char, and auto deduces the pointer it decays to, const char*, so s.size() does not compile. Write std::string s = "text";, or use the s suffix from std::string_literals: auto s = "text"s;.
When should you not use auto in C++?
Spell the type when a number's width or signedness matters. auto total = 0; is an int that overflows past about 2.1 billion, and auto mean = sum / count; hides an integer division. Use auto when the type is obvious from the right-hand side or cannot be named, such as a lambda or an iterator.
Exercises
constexpr power and primes
Implement two constexpr functions: long long power(long long base, int exponent) (exponent >= 0; the result fits in a long long) and bool isPrime(long long n) (trial division while d * d <= n; n >= 0). Then uncomment the two static_assert lines in the starter — they only compile if the functions really are usable at compile time — and answer queries: read q, then q lines that are either pow <base> <exponent> (print the value) or prime <n> (print yes or no).
Input: q, then q queries. Output: q lines.
Example: input 3 / pow 2 10 / prime 97 / prime 1 →
1024
yes
noDeduced accumulators
Read an integer n (at least 1), then n integers in int range. Print sum=<exact sum> (it may exceed an int), int_mean=<sum / n in integer division>, mean=<sum / n with two decimals> and max=<largest value>.
Declare the accumulators with auto and let the initialiser fix the type: auto sum = 0LL; (a long long — auto sum = 0; would be an int and overflow) and auto best = std::numeric_limits<int>::min();. Make the derived values const auto, and cast to double for the real mean — sum / n with two integers is integer division no matter what auto deduces.
Input: n, then n integers. Output: four lines.
Example: input 4 1 2 3 4 →
sum=10
int_mean=2
mean=2.50
max=4