Const Correctness
Applying const to variables, references, pointers, and member functions to express and enforce immutability contracts throughout a C++ codebase.
Const Correctnesssince C++98Const correctness is the discipline of marking every entity—variable, parameter, reference, pointer, and member function—with const wherever mutation is neither intended nor required, turning accidental writes into compile-time errors.
Overview
const is a promise to both the compiler and to readers: this entity will not be modified through this handle. When applied consistently, mutation errors surface at compile time rather than as subtle runtime bugs, and they unlock important optimisations—the compiler can eliminate redundant loads, inline constant values, and avoid unnecessary copies.
Const correctness is not optional hygiene. Code that lacks it accumulates silent mutation paths, prevents objects from being passed to const-correct APIs, and erodes clarity about intent. Retrofitting it into an existing codebase is painful: a single missing const propagates upward through the call chain because a non-const reference cannot bind to a const object, forcing callers to change signatures too.
Syntax
Variables and objects
const int max_retries = 5;
const std::string prefix = "GET /"; // object is fully immutable after constructionPointers: two orthogonal axes
const int* p1 = &x; // pointer to const int — *p1 is read-only, p1 itself can change
int* const p2 = &x; // const pointer to int — p2 cannot be reseated, *p2 can change
const int* const p3 = &x; // both the pointer and the pointee are constRead right-to-left: const int* const = "const pointer to const int."
References
void inspect(const std::string& s); // const ref: no copy, no mutation
void modify(std::string& s); // non-const ref: mutation expectedA non-const reference cannot bind to a const object or a temporary—the compiler enforces this:
const std::string greeting = "hello";
std::string& bad = greeting; // error: drops const qualifier
const std::string& ok = greeting; // fine
const std::string& temp = get_name(); // fine: const ref extends lifetime of the temporaryConst member functions
class Rectangle {
public:
double area() const; // const member function: cannot modify *this
void scale(double factor); // non-const: may modify *this
private:
double width_, height_;
};The const qualifier goes after the parameter list. Placing it before would qualify only the return type, not the function itself.
Only const member functions may be called on a const object or through a const reference. When defined outside the class, the suffix must appear on the definition as well:
double Rectangle::area() const { // 'const' required here too
return width_ * height_;
}Examples
Passing large objects without copying
#include <vector>
double average(const std::vector<double>& data) {
if (data.empty()) return 0.0;
double sum = 0.0;
for (double v : data) sum += v;
return sum / static_cast<double>(data.size());
}const& avoids copying a potentially large container and documents that average will never alter the caller's data. For cheap scalar types (int, double, char), there is no benefit—prefer pass-by-value there.
Const and non-const overloads
A class often needs both a read path and a write path for the same accessor:
class Matrix {
public:
const double& operator()(int r, int c) const; // read path
double& operator()(int r, int c); // write path
private:
std::vector<double> data_;
int rows_, cols_;
};The compiler selects the const overload when the receiver is const-qualified. Implement the non-const overload in terms of the const one to avoid duplication (pre-C++23 idiom):
// C++17 and earlier: cast-based deduplication
double& Matrix::operator()(int r, int c) {
return const_cast<double&>(
static_cast<const Matrix&>(*this)(r, c)
);
}C++23 introduced deducing this, which eliminates this pattern entirely:
// C++23
auto& operator()(this auto& self, int r, int c) {
return self.data_[r * self.cols_ + c];
}mutable: controlled escape hatch
A member declared mutable may be modified inside a const member function. This is appropriate for bookkeeping state that is not part of the object's logical value:
#include <mutex>
#include <optional>
class ExpensiveValue {
public:
double get() const {
std::lock_guard lock(mutex_); // C++17 CTAD
if (!cached_) cached_ = compute();
return *cached_;
}
private:
double compute() const;
mutable std::mutex mutex_; // synchronisation — not domain state
mutable std::optional<double> cached_; // lazy cache — C++17
};Reserve mutable for caches, mutexes, and reference counts. Using it on domain state silently defeats the immutability guarantee.
Best Practices
Mark every non-mutating member function const immediately. If a function only reads *this, it must be const to be callable on const objects and const references. Adding const later cascades into callers.
Use const& for non-trivial function parameters. Apply it whenever the type is larger than two machine words and you do not need a local copy. Since C++11, pass-by-value is often preferable when you intend to own the data, because move semantics make it cheap for movable types.
Prefer constexpr over const for compile-time constants (C++11). constexpr int N = 1024; is guaranteed to be evaluated at compile time and can appear in template arguments and array sizes. const only guarantees the variable is not modified through that name.
constexpr std::size_t page_size = 4096; // C++11: compile-time constant
const std::size_t dynamic = get(); // runtime value, still immutableAvoid returning const T by value. const Widget foo() prevents move on the return value in C++11 and later, degrading performance without meaningful safety benefit. Return const T& only when lending access to an internal member.
Use std::as_const instead of manual casts (C++17). When you want a const reference to a non-const object without writing a cast, std::as_const(obj) is self-documenting and less error-prone.
#include <utility> // std::as_const — C++17
for (const auto& item : std::as_const(my_map)) { /* read-only traversal */ }Common Pitfalls
Writing through const_cast. Stripping the const qualifier with const_cast<T&> and then writing through the result is undefined behaviour when the original object was genuinely const. The only legitimate use is interoperating with old APIs that lack const-correct signatures but are known not to mutate.
mutable on semantic state. If a mutable member holds domain data—not a cache or synchronisation primitive—the object's logical value can change through a "const" function, breaking caller assumptions silently.
Unsynchronised mutable state in const member functions. The C++ standard library assumes that calling const member functions concurrently without external synchronisation is safe. If your const function accesses shared mutable state, you must protect it. A data race is undefined behaviour even when the function is const:
// UB: unsynchronised shared mutable state
class Counter {
public:
int next() const { return ++count_; } // data race if called concurrently
private:
mutable int count_ = 0;
};
// Correct: atomic access — C++11
class Counter {
public:
int next() const {
return count_.fetch_add(1, std::memory_order_relaxed);
}
private:
mutable std::atomic<int> count_{0}; // C++11
};Omitting const on the out-of-class definition. void Foo::bar() { ... } and void Foo::bar() const { ... } declare two different functions. If the class declaration says const and the definition does not, the compiler will report the non-const version as not declared in the class.
The mutable Keyword in Depth
What mutable means
mutable is a per-member specifier that opts a single data member out of the const-ness imposed on its containing object. When a member function is declared const, the implicit this pointer has type const T*, making every non-mutable member read-only. A mutable member remains writable through that same pointer.
The standard distinguishes two forms of constness:
- Physical (bitwise) constness: the object's bytes in memory do not change.
- Logical (semantic) constness: the object's externally observable value does not change.
mutable is the mechanism for expressing "this member participates in physical state but not logical state." Every legitimate use of mutable falls into one of three categories.
Legitimate use case 1 — lazy caching and memoization
When a computation is expensive and its result depends only on immutable logical state, caching the result inside a const object is appropriate. The cache is invisible to callers: they see the same return value whether or not it was computed on this call.
#include <optional>
#include <cmath>
class Circle {
public:
explicit Circle(double radius) : radius_(radius) {}
// Logically const: returns a value derived solely from radius_.
// Physically, it writes cached_area_ on the first call.
double area() const {
if (!cached_area_)
cached_area_ = M_PI * radius_ * radius_;
return *cached_area_;
}
private:
double radius_;
mutable std::optional<double> cached_area_; // C++17
};The cache must be invalidated or discarded whenever the logical state changes. If radius_ were settable, a setter would need to reset cached_area_.
Legitimate use case 2 — mutex and atomic synchronisation
The C++11 memory model requires that concurrent calls to const member functions on the same object are safe without external synchronisation. The standard library upholds this contract — if your class does not, it is broken for multithreaded use. A mutex is not domain state; its purpose is to protect access to domain state. Declaring it mutable lets const functions acquire it:
#include <mutex>
#include <optional>
class ThreadSafeCache {
public:
int get_value() const {
std::lock_guard<std::mutex> lock(mutex_); // OK: mutex_ is mutable
if (!value_)
value_ = expensive_computation();
return *value_;
}
private:
int expensive_computation() const;
mutable std::mutex mutex_; // synchronisation, not domain state
mutable std::optional<int> value_; // the cache it protects
};The "M&M rule" (Herb Sutter, GotW #6): mutable and mutex go together. If a member is mutable, it almost certainly requires a mutex or std::atomic for thread safety. If a member is a mutex, it almost certainly needs mutable.
Mutable on mutable-bit fields
A bit-field member may also be declared mutable:
struct Flags {
mutable unsigned int dirty : 1; // modifiable through const Flags
unsigned int version : 3; // read-only through const Flags
};This is niche but valid. The same logical/physical-state rationale applies.
Misuse patterns to avoid
Mutable on semantic state is the most common mistake. If a mutable member holds domain-observable data — an error code, a running total, a result that callers might see — the object's logical value can change through a "const" function. Callers and the compiler both believe the function preserves the object's value; the mutable member silently violates that promise.
Mutable as a const_cast substitute. Both mechanisms let you write inside a const function, but mutable is always safe because the member was never part of a const object's protected storage. const_cast is only safe when the underlying object was not originally const; writing through const_cast into a genuinely const object is undefined behaviour.
Shallow Const vs. Deep Const
The core problem
const propagates through value members automatically: a const MyClass obj makes every value-type member of obj const as well. Pointers are value types — so the pointer variable itself becomes const — but the pointed-to object does not:
struct Widget {
int* data_;
};
const Widget w{new int(42)};
// w.data_ = nullptr; // error: w.data_ is const (can't reseat the pointer)
*w.data_ = 99; // compiles fine: *data_ is not protected by Widget's constThis is called shallow const: const stops at the pointer boundary. The compiler has no way to know whether data_ is an owning pointer, a non-owning view, or something in between, so it applies the conservative (shallow) rule.
The same applies inside member functions:
class Buffer {
public:
void overwrite(int val) const {
*data_ = val; // compiles! data_ is const, *data_ is not
}
private:
int* data_;
};From the caller's perspective this function claims to be const. From the object's perspective it mutates the resource it owns. This is a correctness hole, not a safety feature.
Deep const via explicit accessor discipline
One approach is to return const pointers or references from const accessors and raw ones from non-const accessors, manually mirroring what you want:
class Buffer {
public:
const int* data() const { return data_; } // const path: read-only access
int* data() { return data_; } // non-const path: write access
private:
int* data_;
};This works but requires two accessor definitions for every pointer member and relies entirely on author discipline; the compiler does not enforce it on direct member access inside the class.
std::propagate_const — the mechanical solution (Library Fundamentals TS v2)
std::experimental::propagate_const (header <experimental/propagate_const>, proposed for the C++ standard in Library Fundamentals TS v2) is a thin wrapper around a pointer-like type that propagates const through it. When accessed through a const path, its operator* and operator-> return const-qualified access; through a non-const path, they return mutable access.
#include <experimental/propagate_const>
#include <memory>
using std::experimental::propagate_const;
class Pimpl {
public:
explicit Pimpl();
void read_only() const;
void mutating();
private:
struct Impl;
propagate_const<std::unique_ptr<Impl>> impl_;
};
// Inside the class:
void Pimpl::read_only() const {
// impl_ is accessed through a const Pimpl, so propagate_const
// makes operator-> return const Impl*, preventing non-const calls.
impl_->const_method_only(); // non-const Impl methods would be a compile error
}
void Pimpl::mutating() {
impl_->any_method(); // non-const path: full access
}propagate_const<T> is deliberately non-copyable (only movable) to prevent circumventing const safety by copying the wrapper. You can still obtain the raw underlying pointer with the free function std::experimental::get_underlying(p) when you genuinely need it, but that is an explicit escape hatch, not the default.
Availability note: propagate_const is in Library Fundamentals TS v2, not yet in the IS (as of C++23). GCC ships it under <experimental/propagate_const>; MSVC and libc++ have partial support. Check your implementation's headers before depending on it in portable code.
Const Overload Pairs and the DRY Problem
Why you need both overloads
A class that exposes access to an internal resource typically needs two versions of the same accessor:
class Registry {
public:
const Widget& get(int id) const; // called on const Registry
Widget& get(int id); // called on non-const Registry
private:
std::vector<Widget> widgets_;
};Both implementations are often identical, causing duplication. For a complex accessor — one that validates bounds, logs, or selects from nested structures — the duplication is a maintenance hazard.
The Scott Meyers const_cast trick (pre-C++23)
Scott Meyers (Effective C++, Item 3) describes a safe deduplication idiom: implement the non-const overload by casting away const from the result of the const overload:
// Implement the const version fully:
const Widget& Registry::get(int id) const {
// ... real bounds check and logic here ...
return widgets_[id];
}
// Non-const delegates to const, then strips the qualifier:
Widget& Registry::get(int id) {
return const_cast<Widget&>(
static_cast<const Registry&>(*this).get(id)
);
}The cast chain is correct because *this was not originally const — we only temporarily viewed it as const to reuse the const implementation. The inverse direction (implementing const in terms of non-const) is undefined behaviour and must be avoided.
This pattern eliminates the duplicated logic but adds two casts and requires careful explanation. It is the accepted idiom in C++17 and earlier.
C++23 deducing this — the clean solution
C++23 introduces explicit object parameters (P0847, "deducing this"). The first parameter of a member function may be declared with the this keyword, making the implicit *this explicit and enabling template deduction on it:
class Registry {
public:
// Single definition handles const and non-const callers:
template <typename Self>
auto& get(this Self&& self, int id) {
return self.widgets_[id];
}
private:
std::vector<Widget> widgets_;
};When called on a const Registry, Self deduces to const Registry& and the return type is const Widget&. When called on a non-const Registry, Self deduces to Registry& and the return is Widget&. A single definition, correct const propagation, zero casts.
For types with value-category-sensitive behaviour (e.g. operator* on a wrapper that should move when the wrapper is an rvalue), the same template handles all four cases — const lvalue, mutable lvalue, const rvalue, mutable rvalue — that previously required four overloads:
template <typename Self>
auto&& operator*(this Self&& self) {
return std::forward<Self>(self).value_;
}Deducing this is available in GCC 14, Clang 18, and MSVC 17.2 (VS 2022) with -std=c++23.
const in Function Signatures: Subtle Rules
Top-level const on by-value parameters is ignored in declarations
In a function declaration, top-level const on a by-value parameter is part of the definition but not of the function's type or signature. The following two declarations are identical from the compiler's perspective:
void process(int n); // declaration
void process(const int n); // same declaration — 'const' ignored at call siteThe distinction matters only in the function body. In the definition, const int n prevents accidental modification of the local parameter. This is occasionally useful for clarity in long functions but is never observable to callers.
The rule does not apply to pointers or references: const int* and int* are different types, as are const int& and int&.
Pointer const distinctions
The two axes of const on a pointer are independent and mean different things in signatures:
// 1. Pointer to const — callee cannot modify the pointed-to object
void read_data(const int* p); // *p is read-only; p may be reseated locally
// 2. Const pointer — callee cannot reseat the pointer (irrelevant at call site — value copy)
void store_ptr(int* const p); // p is a const local copy; *p is mutable
// 3. Const pointer to const — neither the pointer nor pointee may change
void audit(const int* const p); // strictest form; unusual in parameter listsFor output parameters and array/buffer APIs, const int* is the conventional signal that the buffer is read-only. int* const in a parameter list rarely adds value since the pointer is already a copy.
constexpr vs. const: Compile-Time vs. Runtime Immutability
const and constexpr both prevent mutation through a name, but they differ fundamentally in when the value is established:
| Feature | const | constexpr (variable) |
|---|---|---|
| Value established | At runtime (initializer may call functions, read input, etc.) | At compile time (initializer must be a constant expression) |
| Usable as template argument | No | Yes |
| Usable as array size (no VLA) | Only if the initialiser is a constant expression | Always |
Implies const? | — | Yes: constexpr variables are implicitly const |
int n = get_count(); // runtime value
const int a = n; // runtime const: immutable after init, not a constant expression
constexpr int b = 256; // compile-time constant: usable in template args, array sizes
std::array<int, b> buf; // OK: b is a constant expression
std::array<int, a> bad; // error: a is not a constant expression (n is runtime)constexpr implies const for variables: constexpr int k = 42; is exactly const int k = 42; plus the compile-time guarantee. There is never a reason to write const constexpr — it is redundant.
For functions, constexpr means "may be evaluated at compile time if all arguments are constant expressions." The function is not required to be evaluated at compile time; a constexpr function called with runtime arguments behaves exactly like a regular function. Use consteval (C++20) to mandate compile-time-only evaluation.
constexpr int square(int x) { return x * x; }
constexpr int ct = square(8); // evaluated at compile time: ct = 64
int rt = square(get_n()); // evaluated at runtime: perfectly validSee Also
constexpr— compile-time evaluation; introduced in C++11, extended significantly in C++14, C++17, and C++20mutablespecifier — per-member opt-out from const restrictions; use sparinglystd::as_const— returns a const reference to its argument without a cast (C++17,<utility>)- Deducing
this— eliminates the need for const/non-const overload pairs (C++23) std::atomic— thread-safe mutation formutablemembers in const functions (C++11)