C++ / CLASSES AND OBJECT LIFECYCLE
const member functions and const-correctness
Mark read-only members const, overload on constness, and use mutable for caches, so your objects still work when callers hold them by const reference.
What you will learn
- Add trailing const to every member function that does not change observable state
- Predict which at() overload a const object selects versus a non-const one
- Explain why a const member function can still mutate through a pointer member
- Use mutable for cached fields instead of const_cast inside a const function
Understanding const member functions and const-correctness
The const that follows a member function's parameter list applies to the object the function is called on. Inside such a function this has type const TextBuffer*, so every non-static data member is seen as const: you cannot assign to text_, and you cannot call append() on yourself either, because append() makes no such promise. The compiler checks this syntactically inside the body and never inspects whether the function 'really' changes anything, which is why the qualifier is a contract you declare rather than a property that gets inferred.
That contract is what makes const references usable. A function taking const TextBuffer& gets a cheap, non-owning view of the object, but it can only call members marked const, so a size() that forgot its const is invisible to it. Const-correctness therefore only works bottom-up: one unmarked getter deep inside a class forces every caller above it to take a non-const reference, and mutability leaks upward through the whole call graph. The habit that prevents this is to write the const version first and drop const only where the function genuinely writes.
What const does not do is go deep. Applying it to a member makes that member top-level const, so Counter* counter_ becomes Counter* const counter_: you cannot re-point it, but ++counter_->value compiles happily inside a const member function. Going the other way, mutable exempts a member so caches, memo flags and mutexes can be written from const functions while the observable state stays put. And because trailing const is part of the function type, at(i) and at(i) const can be separate overloads, with the choice made from the static type of the object expression.
<cstddef>
<iostream>
<string>
<utility>
class TextBuffer {
public:
explicit TextBuffer(std::string text) : text_(std::move(text)) {}
// Read-only: promises not to change the observable state of *this.
std::size_t size() const { return text_.size(); }
const std::string& text() const { return text_; }
// Overloaded on constness: read-only copy vs writable reference.
char at(std::size_t i) const { return text_[i]; }
char& at(std::size_t i) { return text_[i]; }
void append(const std::string& s) { text_ += s; }
private:
std::string text_;
};
void describe(const TextBuffer& b) {
// Only const members are callable through a const reference.
std::cout << "size=" << b.size() << " first=" << b.at(0) << '\n';
}
int main() {
TextBuffer b{"hello"};
b.at(0) = 'H'; // non-const overload returns char&
b.append(", world");
describe(b);
const TextBuffer frozen{"fixed"};
std::cout << frozen.text() << " has " << frozen.size() << " chars\n";
// frozen.append("!"); // error: append() is not const
// frozen.at(0) = 'F'; // error: const overload returns char by value
}
Trailing const is a promise about *this that the compiler enforces by treating every member as const inside the body, and it is exactly the promise callers rely on when they hold the object by const reference.
Worked examples
mutable for a cached result
A const member function recomputes and stores a cached word count without breaking its promise about observable state.
<cstddef>
<iostream>
<string>
<utility>
class Document {
public:
explicit Document(std::string body) : body_(std::move(body)) {}
std::size_t wordCount() const {
if (!countValid_) {
std::cout << "(scanning)\n";
count_ = body_.empty() ? 0 : 1;
for (char c : body_) {
if (c == ' ') ++count_;
}
countValid_ = true;
}
return count_;
}
void append(const std::string& more) {
body_ += more;
countValid_ = false;
}
private:
std::string body_;
mutable std::size_t count_ = 0;
mutable bool countValid_ = false;
};
int main() {
Document d{"the quick brown fox"};
std::cout << d.wordCount() << '\n';
std::cout << d.wordCount() << '\n';
d.append(" jumps over");
std::cout << d.wordCount() << '\n';
}
Example explained
Line 1count_ and countValid_ are mutable, so they remain assignable even though wordCount() is const.
Line 2The first call prints (scanning); the second reuses the cache and prints nothing extra.
Line 3append() is non-const and clears countValid_, which is why the third call scans again.
Line 4Every caller still sees a consistent number, so const here describes observable state, not the object's bytes.
const stops at the pointer
A const member function mutates the object a pointer member points at, because const only applies at the top level.
<iostream>
struct Counter {
int value = 0;
};
class Gauge {
public:
explicit Gauge(Counter* c) : counter_(c) {}
void bump() const { ++counter_->value; } // allowed
int reading() const { return counter_->value; }
// void rebind(Counter* c) const { counter_ = c; }
// error: inside a const member, counter_ is Counter* const
private:
Counter* counter_;
};
int main() {
Counter c;
const Gauge g{&c};
g.bump();
g.bump();
std::cout << "g.reading() = " << g.reading() << '\n';
std::cout << "c.value = " << c.value << '\n';
}
Example explained
Line 1g is a const object, yet g.bump() compiles because bump() is declared const.
Line 2Inside bump(), counter_ has type Counter* const, so ++counter_->value touches the pointee, not the pointer.
Line 3The commented rebind() would fail: assigning to counter_ itself changes a member of *this.
Line 4c.value shows the mutation is real, so a const object gives no immutability guarantee for what it points to.
which overload runs
Overload resolution between const and non-const members uses the static type of the object expression, not how the object was created.
<iostream>
class Handle {
public:
void touch() { std::cout << "non-const overload\n"; }
void touch() const { std::cout << "const overload\n"; }
};
int main() {
Handle h;
const Handle frozen{};
const Handle& alias = h; // const view of a non-const object
h.touch();
frozen.touch();
alias.touch();
}
Example explained
Line 1touch() and touch() const are two distinct functions because trailing const is part of the function type.
Line 2h.touch() selects the non-const version since h is a non-const lvalue.
Line 3alias names the same object as h, but the const overload runs: the reference's type decides.
Line 4So adding const to a member function can change which code executes, not merely whether the call compiles.
Important notes
Trailing const is part of the function type, so at(std::size_t) and at(std::size_t) const are legal overloads; two members differing only in return type are not.
const does not imply thread-safe, but the standard library assumes const members of one object can be called from several threads at once, so a mutable cache needs a mutable mutex to guard it.
Common mistakes
Leaving const off a getter, then fixing the resulting error by making the caller's parameter non-const; the mutability spreads outward instead of the class being repaired.
Assuming a const member function protects what pointer or reference members refer to, so int& value() const { return *p_; } compiles and hands out write access through a const reference.
Casting away constness with const_cast<Document*>(this) to update a cache instead of declaring the field mutable; if the object really is const, writing to it is undefined behaviour rather than a compile error.
Try it yourself
Change, predict, then run
Write a Samples class holding a std::vector<double> with a non-const average(), then add void report(const Samples&) that calls it and fix the compile error by qualifying average() const. Next add a const/non-const pair of at(std::size_t) that each print which overload ran, and call them through both a Samples and a const Samples&.
Open the C++ workspaceCheck your understanding
A class stores int* data_ and declares void zero() const { *data_ = 0; }. Why does this compile even when called on a const object?
- const applies only to members the function names directly, and zero() names *data_ rather than data_.
- The restriction is checked at the call site, so zero() is only restricted when the object is const.
- Trailing const makes each member top-level const inside the body, so data_ becomes int* const while the pointee's type is untouched.
- Dereferencing a pointer strips qualifiers, so *data_ is a non-const lvalue no matter where it appears.
Show answer
Inside a const member function every non-static member gains top-level const: int* becomes int* const, so data_ = other; is rejected while *data_ = 0; is fine because const was never applied to the pointed-to int. The tempting option about the call site is wrong because the check happens in the function body and is identical whether the caller's object is const or not; that is also why marking a function const cannot promise anything about memory it merely points to.