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 (equivalently int const* p) — a pointer to a const int. You can repoint p to a different address, but you cannot modify the value through p.
  • int* const p — a const pointer to a non-const int. You can modify the value through p, but you cannot make p point somewhere else.
  • const int* const p — a const pointer to a const int. Neither the address nor the value can change through p.

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 const if 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 mutable only for true bookkeeping data (caches, counters, mutexes) — never as a shortcut to bypass real const violations.
  • Prefer constexpr over const for values that are genuinely known at compile time (array sizes, mathematical constants); it enables more optimizations and compile-time contexts.
  • Avoid const_cast except 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 const isn’t optional polish — it’s the API telling the truth about what it does.

Practice Exercises

  • Write a Rectangle class with private width and height members, a constructor, an area() method that should be callable on a const object, and a scale(double factor) method that modifies the dimensions. Mark each method with the correct const-qualification and verify your reasoning by declaring a const Rectangle and 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 preventing process from modifying the caller’s vector. Confirm your version compiles and produces the same output as before.
  • Declare const int* p, int* const p2, and const int* const p3, all pointing at the same int variable. 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

  • const marks 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* const is a const pointer to mutable data.
  • References are never reseatable, so const on a reference only ever protects the referred-to data — this is the standard way to pass large objects cheaply and safely.
  • A const member function promises not to modify non-mutable members, and only such methods may be called on const objects or through const references/pointers.
  • mutable exempts specific members (caches, counters) from that promise; use it sparingly and honestly.
  • const_cast can strip constness, but using it to modify an object that was originally declared const is undefined behavior.
  • Prefer constexpr over plain const when a value is truly known at compile time.