C++ const Correctness
const correctness is a discipline in C++ where you use the const keyword to mark data, pointers, references, and member functions that should never modify the object they touch. It isn’t just a style preference: the compiler enforces it at compile time, catching whole categories of bugs before your program ever runs. It also documents intent directly in a function’s signature, so callers and teammates know exactly what a piece of code will and will not change.
Overview / How it works
At its core, const tells the compiler “treat this as read-only.” When you write const int x = 5;, the compiler records that x may never be assigned to again, and any attempt to do so — x = 6; — becomes a compile error, not a runtime surprise. This applies to any type: primitives, class objects, pointers, and references.
Pointers are where const gets more nuanced, because a pointer has two things that can be “locked”: the pointer itself (the address it holds) and the data it points to. The rule of thumb is to read the declaration right-to-left from the variable name:
const int* p(equivalentlyint const* p) — a pointer to a const int. You can repointpto a different address, but you cannot modify the value throughp.int* const p— a const pointer to a non-const int. You can modify the value throughp, but you cannot makeppoint somewhere else.const int* const p— a const pointer to a const int. Neither the address nor the value can change throughp.
References behave more simply: a reference itself is never reseatable, so const only ever describes what it refers to. const string& name means “a reference to a string I promise not to modify,” which is the standard way to pass large objects into functions without paying for a copy.
For member functions, appending const after the parameter list — double getBalance() const — promises that calling this method will not modify any non-mutable member of the object. Internally, the compiler implements this by changing the type of the implicit this pointer from ClassName* to const ClassName* inside that function body, so any attempt to write to a member (other than one marked mutable) fails to compile. This also means only const-qualified member functions can be called on a const object or through a const reference/pointer to that object — calling a non-const method on a const object is a compile error, because the compiler cannot guarantee it won’t mutate the object.
The mutable keyword is the escape hatch: it marks a data member as always modifiable, even inside a const member function. It’s used for state that isn’t part of an object’s logical value — caches, lazily-computed results, access counters, or mutex locks used purely for thread-safety bookkeeping.
Finally, it’s worth distinguishing const from constexpr. const means “cannot be modified after initialization,” but the initial value doesn’t have to be known until run time (const int n = readFromFile(); is legal). constexpr is stronger: it demands the value be computable at compile time, which additionally allows it to be used for array sizes, template arguments, and other compile-time contexts. Every constexpr variable is implicitly const, but not every const variable is constexpr.
Syntax
const type name = value; // const variable
type const name = value; // identical meaning, less common style
const type* ptr; // pointer to const data
type* const ptr; // const pointer to mutable data
const type* const ptr; // const pointer to const data
void func(const type& param); // pass by const reference (no copy, read-only)
returnType method(params) const { // const member function
// 'this' is treated as: const ClassName* const this
}
| Declaration | Meaning |
|---|---|
const int x; |
x cannot be reassigned |
const int* p; |
*p cannot be modified through p; p can be repointed |
int* const p; |
p cannot be repointed; *p can be modified |
const int& r = x; |
r is a read-only alias for x |
void f() const |
f() promises not to modify the object’s non-mutable members |
mutable int cache; |
cache can be modified even inside const methods |
Examples
Example 1: const pointers vs. pointers to const
#include <iostream>
using namespace std;
int main() {
int value = 10;
int other = 20;
const int* ptrToConst = &value; // pointer to const int
int* const constPtr = &value; // const pointer
const int* const constPtrToConst = &value; // both const
// *ptrToConst = 99; // ERROR: cannot modify through pointer to const
ptrToConst = &other; // OK: the pointer itself can be reassigned
cout << "ptrToConst points to: " << *ptrToConst << endl;
*constPtr = 99; // OK: value can be modified through this pointer
// constPtr = &other; // ERROR: cannot repoint a const pointer
cout << "constPtr points to: " << *constPtr << endl;
cout << "constPtrToConst points to: " << *constPtrToConst << endl;
return 0;
}
Output:
ptrToConst points to: 20
constPtr points to: 99
constPtrToConst points to: 99
Here ptrToConst starts by pointing at value, but since only the pointed-to data is protected (not the pointer), it is freely repointed to other, so it prints 20. constPtr can never be repointed, but it can write through itself, so *constPtr = 99; changes value to 99. Since constPtrToConst still points at value, it also reports 99.
Example 2: const member functions and mutable
#include <iostream>
#include <string>
using namespace std;
class BankAccount {
private:
string owner;
double balance;
mutable int accessCount; // can change even in const methods
public:
BankAccount(string ownerName, double startingBalance)
: owner(ownerName), balance(startingBalance), accessCount(0) {}
double getBalance() const {
accessCount++; // legal because accessCount is mutable
return balance;
}
void deposit(double amount) {
balance += amount;
}
int getAccessCount() const {
return accessCount;
}
void printSummary() const {
cout << owner << "'s balance: $" << getBalance() << endl;
}
};
int main() {
const BankAccount account("Alice", 500.0);
account.printSummary();
account.printSummary();
// account.deposit(100.0); // ERROR: deposit() is not const
cout << "Balance was accessed " << account.getAccessCount() << " times" << endl;
return 0;
}
Output:
Alice's balance: $500
Alice's balance: $500
Balance was accessed 2 times
account is declared const, so only const-qualified methods (getBalance, getAccessCount, printSummary) can be called on it; deposit() is correctly rejected by the compiler if uncommented. Yet accessCount still increments each time getBalance() runs, because mutable exempts it from the const promise — a common pattern for bookkeeping data that isn’t part of an object’s observable value.
Example 3: const references and const-based overload resolution
#include <iostream>
#include <vector>
using namespace std;
void printVector(const vector<int>& v) {
// v is a reference to a const vector: no copy is made, and v cannot be modified
cout << "[ ";
for (int x : v) {
cout << x << " ";
}
cout << "]" << endl;
}
class Widget {
public:
void describe() const {
cout << "const describe() called" << endl;
}
void describe() {
cout << "non-const describe() called" << endl;
}
};
int main() {
vector<int> numbers = {1, 2, 3, 4, 5};
printVector(numbers);
Widget w;
const Widget cw;
w.describe(); // calls the non-const overload
cw.describe(); // calls the const overload
return 0;
}
Output:
[ 1 2 3 4 5 ]
non-const describe() called
const describe() called
printVector takes its argument by const reference, so passing a five-element vector costs nothing beyond a pointer — no copy of the underlying array is made. The Widget class shows that C++ allows two overloads of the same method that differ only in const-ness; the compiler picks whichever matches the const-qualification of the object it’s called on.
Under the hood
const checking happens entirely at compile time and generally costs nothing at run time — a const int local variable is stored exactly like a non-const one; the compiler simply refuses to compile any code path that would write to it. For a const member function, the compiler internally adjusts the type of the implicit this pointer to const ClassName*, which is why writing to a non-mutable member inside that function fails the same way writing through any const T* would fail elsewhere.
Because the check is purely a compiler-side promise, it is possible to break it using const_cast, which strips (or adds) const/volatile qualification from a pointer or reference. Using const_cast to gain write access to an object that was originally declared const is undefined behavior — the compiler may have placed that object in read-only memory, or optimized around the assumption that it never changes. const_cast is only safe when the underlying object is not actually const (for example, when a function signature over-promises constness on data you know is really mutable).
Common Mistakes
Mistake 1: modifying a member inside a const method
Marking a method const is a promise to the compiler, and the compiler checks it — writing to an ordinary member from within a const method simply won’t compile.
class Counter {
int count;
public:
Counter() : count(0) {}
void increment() const {
count++; // ERROR: cannot modify count in a const member function
}
};
The fix is to be honest about the method’s effect: if it changes state, it cannot be const.
#include <iostream>
using namespace std;
class Counter {
int count;
public:
Counter() : count(0) {}
void increment() { // no longer const, since it modifies state
count++;
}
int getCount() const {
return count;
}
};
int main() {
Counter c;
c.increment();
c.increment();
c.increment();
cout << "Count: " << c.getCount() << endl;
return 0;
}
Output:
Count: 3
Mistake 2: passing large objects by value instead of const reference
This version compiles and runs correctly, but it silently copies the entire string on every call — wasteful for large or frequently-called functions.
#include <iostream>
#include <string>
using namespace std;
void printLength(string s) { // copies the entire string every call
cout << "Length: " << s.length() << endl;
}
int main() {
string longText(10000, 'x');
printLength(longText);
return 0;
}
Output:
Length: 10000
Taking the parameter as const string& gives identical behavior with no copy, and the const also documents that the function won’t modify the caller’s string:
#include <iostream>
#include <string>
using namespace std;
void printLength(const string& s) { // no copy, and promises not to modify s
cout << "Length: " << s.length() << endl;
}
int main() {
string longText(10000, 'x');
printLength(longText);
return 0;
}
Output:
Length: 10000
The output is identical, but the corrected version avoids allocating and copying 10,000 characters on every call — the kind of hidden cost const-correct code sidesteps by default.
Best Practices
- Pass class-type parameters (strings, vectors, custom objects) by
const&unless the function needs to modify or take ownership of them; pass small primitives (int,double,bool) by value. - Mark every member function
constif it doesn’t change observable state — this lets it be called on const objects and through const references, which matters a lot once other code depends on your class. - Reach for
mutableonly for true bookkeeping data (caches, counters, mutexes) — never as a shortcut to bypass real const violations. - Prefer
constexproverconstfor values that are genuinely known at compile time (array sizes, mathematical constants); it enables more optimizations and compile-time contexts. - Avoid
const_castexcept to remove constness added artificially by a poorly-designed API on data you know is actually mutable — never use it to write through a pointer to a truly const object. - Design outward from the caller: if a function or method has no business modifying its argument or object, marking it
constisn’t optional polish — it’s the API telling the truth about what it does.
Practice Exercises
- Write a
Rectangleclass with privatewidthandheightmembers, a constructor, anarea()method that should be callable on a const object, and ascale(double factor)method that modifies the dimensions. Mark each method with the correct const-qualification and verify your reasoning by declaring aconst Rectangleand checking which calls compile. - Given
void process(std::vector<int> data), rewrite the signature so a 100,000-element vector is never copied, while still preventingprocessfrom modifying the caller’s vector. Confirm your version compiles and produces the same output as before. - Declare
const int* p,int* const p2, andconst int* const p3, all pointing at the sameintvariable. For each, write one line of code that is legal and one line (commented out) that would fail to compile, and explain in a comment why each failing line fails.
Summary
constmarks data as read-only and is enforced by the compiler at compile time, at essentially zero runtime cost.- For pointers, read declarations right-to-left:
const int*is a pointer to const data;int* constis a const pointer to mutable data. - References are never reseatable, so
conston a reference only ever protects the referred-to data — this is the standard way to pass large objects cheaply and safely. - A
constmember function promises not to modify non-mutable members, and only such methods may be called on const objects or through const references/pointers. mutableexempts specific members (caches, counters) from that promise; use it sparingly and honestly.const_castcan strip constness, but using it to modify an object that was originally declared const is undefined behavior.- Prefer
constexprover plainconstwhen a value is truly known at compile time.
