Const Correctness in C++: const Members, Pointers, References and When mutable Is Fine

Key takeaways

What const actually guarantees in C++ (and what it doesn't), how to read const pointer declarations, const member functions and overloads, the real compiler errors, why mutable caches need a lock, and the places where const hurts: returned values, data members and std::move.

What const correctness buys you

Const correctness means marking everything that shouldn’t change as const: variables, parameters, member functions and the pointees of pointers. It lets the compiler enforce your intent. The payoff is at interfaces. A function that takes const std::vector<int>& tells every caller “I only read this”, and the compiler holds the implementation to that promise:

void print(const std::vector<int>& data) {
    // data.push_back(42);
    // error: passing 'const std::vector<int>' as 'this' argument discards qualifiers [-fpermissive]
}

Const is contagious, and that’s the point. Once a function takes const T&, it can only call const member functions of T. So a missing const on one getter forces every caller up the chain to drop const too. A common failure in older code bases is exactly this: one class forgot to mark size() as const, and years later half the functions that touch it take non-const references “because it didn’t compile otherwise”. Retrofitting is painful, so add const from the first version of an interface.

This article is about const as a design tool: how to read it, where it stops protecting you, and where it gets in the way. If you are here because of a specific compiler message, Ten C++ const Errors and Their Fixes goes message by message, and C++ Clean Code Basics shows const alongside noexcept and [[nodiscard]] in API design.

All diagnostics below were reproduced with GCC 10.3, -std=c++20.


Reading const in declarations

const applies to the thing on its left. If nothing is on its left, it applies to the thing on its right.

DeclarationRepoint pointer?Write *p?Read as
const int* pYesNopointer to const int
int const* pYesNosame as above (“east const”)
int* const pNoYesconst pointer to int
const int* const pNoNoconst pointer to const int

Reading right to left works: int* const p is “p is a const pointer to int”.

int x = 10, y = 20;

const int* p1 = &x;   // *p1 = 1;  error: assignment of read-only location '* p1'
p1 = &y;              // OK

int* const p2 = &x;
*p2 = 1;              // OK
// p2 = &y;           // error: assignment of read-only variable 'p2'

const int* p doesn’t mean the int is immutable. It means you can’t modify it through p. The int can still change through another path, as the aliasing section shows.

A const variable must be initialized:

const int y;   // error: uninitialized 'const y' [-fpermissive]

And string literals are const char[N]. char* s = "abc"; has been ill-formed since C++11. GCC still accepts it with warning: ISO C++ forbids converting a string constant to 'char*' [-Wwrite-strings], and writing through s is undefined behavior. Treat that warning as an error.


const member functions

A trailing const makes this a pointer to const, so the function can be called on const objects and can’t modify non-mutable members:

class Point {
    int x_ = 0, y_ = 0;
public:
    int x() const { return x_; }
    void set_x(int v) { x_ = v; }
};

const Point p;
p.x();         // OK
// p.set_x(3); // error: passing 'const Point' as 'this' argument discards qualifiers [-fpermissive]

Inside a const member function, assigning a member is caught:

struct Bad { int x = 0; void func() const { x = 10; } };
// error: assignment of member 'Bad::x' in read-only object

The fix is almost always to drop const from the function, because it does modify state. Reaching for mutable to silence this error is the wrong reflex. mutable is for state that isn’t part of the object’s value (next section).

const overloads

Containers provide both versions so that a const container hands out read-only access:

class Text {
    std::vector<char> buf_{'h', 'i'};
public:
    const char& at(std::size_t i) const { return buf_.at(i); }
    char& at(std::size_t i) {
        // Reuse the const version: safe because *this is known to be non-const here
        return const_cast<char&>(std::as_const(*this).at(i));
    }
};

The const_cast in the non-const overload is one of the few legitimate uses. It avoids duplicating non-trivial logic, and it’s defined behavior because the object isn’t const. Don’t do it the other way around: calling the non-const version from the const one could modify a const object.

In C++23, “deducing this” replaces both overloads with one template:

template <class Self>
auto&& at(this Self&& self, std::size_t i) { return std::forward<Self>(self).buf_.at(i); }

This needs GCC 14, Clang 18 or a recent MSVC. I couldn’t test it with GCC 10.


const is shallow

const on an object applies to its members, not to what those members point to:

struct Widget {
    std::unique_ptr<int> p = std::make_unique<int>(1);
    void poke() const { *p = 42; }   // compiles: p is const, *p is not
};
const Widget w;
w.poke();   // *w.p is now 42

The same holds for raw pointers, std::shared_ptr, and reference members. If a class owns data through a pointer (the pimpl idiom is the classic case), the compiler won’t stop a const member function from modifying it. You have to enforce it yourself: return const T& from getters, or wrap the pointer in std::experimental::propagate_const where it’s available (libstdc++ ships it in <experimental/propagate_const>). With a propagate_const<std::unique_ptr<Impl>> member, the same poke() fails with assignment of member 'Impl::v' in read-only object.


const doesn’t mean “won’t change”

A const reference is a promise not to write through that reference. It isn’t a promise that the object stays unchanged:

void show_alias(const int& a, int& b) {
    int before = a;
    b = 5;
    std::printf("a was %d, now %d\n", before, a);
}

int x = 1;
show_alias(x, x);   // prints: a was 1, now 5

This is also why “const enables optimizations” is mostly a myth for parameters. Because of possible aliasing, the compiler has to assume that a const int* pointee can change whenever anything else is written. The real optimization wins come from objects that are defined const (the compiler may fold them or place them in read-only memory) and from constexpr. Use const on parameters for correctness. It won’t make the code faster.


mutable: caches, locks and counters

mutable members can be modified in const member functions. They’re appropriate when the member isn’t part of the object’s logical value:

UseWhy it’s fine
Memoized/lazy resultCallers observe the same value either way
std::mutexLocking isn’t observable state
Access counters, statsDiagnostic, not part of the value

Callers assume that const member functions are safe to call concurrently. The standard library guarantees this for its own types, and most code bases follow the same convention. So a mutable cache is a data race waiting to happen unless you synchronize it:

class Expensive {
    mutable std::mutex m_;
    mutable std::optional<std::string> cache_;
public:
    const std::string& value() const {
        std::lock_guard lk(m_);
        if (!cache_) cache_ = compute();   // compute() is expensive
        return *cache_;
    }
};

I’ve seen the unsynchronized version pass every single-threaded test and then crash under load. Two threads see !cache_ simultaneously and both assign. Nothing in the signature warns you, because the function is const. For a simple counter, mutable std::atomic<int> is enough. For a cache computed once, a std::once_flag with std::call_once also works. Note that a std::mutex member makes the class non-copyable, so you’ll need to write the copy operations yourself if you need them.


Where const hurts

Returning const values blocks moves.

const std::string name();   // don't
std::string s;
s = name();                 // copy-assigns: a const rvalue can't bind to string&&

I reproduced this with an instrumented type: assigning from a const T prvalue calls the copy assignment, while the same function returning plain T calls the move assignment. The old advice to return const T to prevent f() = x predates C++11 and now costs performance.

std::move on a const object copies.

const std::vector<int> v = build();
auto w = std::move(v);   // silently copies: const T&& binds to the copy constructor

No warning is emitted. If you plan to move from a local later, don’t declare it const.

const data members delete assignment.

struct Holder { const int id; };
Holder a{1}, b{2};
a = b;   // error: use of deleted function 'Holder& Holder::operator=(const Holder&)'

This makes the type unusable in many contexts: std::vector::erase and std::sort both need assignment. Prefer a private non-const member with a const getter.

Top-level const on by-value parameters is invisible to callers. void f(const int n) and void f(int n) declare the same function. It’s fine in the definition, as a local promise, but it adds noise in the header.


const_cast and legacy APIs

void legacy(char* s);       // doesn't actually write, but wasn't declared const
const char* msg = "hello";
// legacy(msg);             // error: invalid conversion from 'const char*' to 'char*' [-fpermissive]
legacy(const_cast<char*>(msg));   // OK only if legacy() truly never writes

Casting away const is defined behavior. Writing to an object that was defined const is undefined behavior, and that includes string literals. If you can’t be sure the API is read-only, pass a copy:

std::string tmp = msg;
legacy(tmp.data());   // C++17: non-const data()

See static_cast, dynamic_cast and const_cast for the other casts.


const, constexpr, consteval, constinit

KeywordMeaning
constCan’t be modified after initialization. The value may be computed at runtime.
constexpr variableconst, and must be initialized by a constant expression. Usable in templates and array bounds.
consteval function (C++20)Every call must be evaluated at compile time.
constinit variable (C++20)Static initialization is guaranteed, but the variable isn’t const.

For compile-time constants, prefer constexpr over const: constexpr int max_connections = 100;. See constexpr.


A practical order for adding const

  1. Getters and other non-mutating member functions. Their callers’ signatures depend on them, so do these first.
  2. Parameters of class type that are only read: const T& (or std::string_view / std::span<const T>).
  3. Locals that are computed once and never reassigned. Skip any you’ll later std::move from.
  4. Use cbegin()/cend() or std::as_const(x) when you need const iterators from a non-const container.

Don’t add const to return-by-value types, to data members of assignable types, or to by-value parameters in headers.