Module 4 — Concepts and Explanations¶
Read this with a compiler open. This module is not about a keyword; it is about a property your classes either have or do not, and the programs show both — including the one where the class looks correct and is not.
1. The uncomfortable demonstration¶
Every member of this class is private. The compiler will refuse to let anything outside touch them. And a caller can still break it in one line.
class Member {
public:
void setBorrowedCount(int count) { borrowedCount_ = count; } // ← this line
private:
std::string name_;
int borrowedCount_ = 0; // the rule: 0 <= borrowedCount_ <= kLimit
};
The class's rule is now false, and every function that trusts borrowedCount_ produces
nonsense. examples/module-04/01-broken-encapsulation/main.cpp is this class, with the
breakage executed and the nonsense printed.
What went wrong is not that a setter exists. It is that the setter exposed the representation — a number — instead of the capability the system actually has ("borrow an item"). Whoever calls it must know the rule, and nothing makes them remember. That is the whole module in one sentence:
Making data private is not encapsulation. A rule that a caller can break by calling a public function is not enforced; it is merely hidden in a smaller font.
2. What encapsulation actually is¶
Encapsulation is the practice of making invalid states impossible to reach, and it has three parts that must all be present:
| Part | What it means | What happens without it |
|---|---|---|
| Data is private | No caller can name the storage | Any caller can corrupt it |
| Every public operation preserves the invariant | The rules live inside the type | The rule exists in the callers' heads |
| The interface expresses what the system does | Operations, not fields | Callers reconstruct the rules themselves |
The invariant is the heart of it. An invariant is a statement that must be true of every valid object at every moment a caller can observe it:
INVARIANT: 0 <= borrowed_.size() <= kLimit, and every title in borrowed_ is a title
this member is currently holding.
Two properties make an invariant worth writing down:
- It is established at construction. A
Memberwith four items out cannot be created, because the constructor will not let it exist. (Module 3.) - It is preserved by every public operation. There is no way to reach a state where it is false, because each operation either re-establishes it or refuses to run.
An invariant that is documented in a comment but breakable from outside is not an invariant. It is a wish.
3. Design in this order: invariant, interface, representation¶
The order most beginners use — private fields first, then getters and setters as they are needed — produces the class in §1. The order that produces working encapsulation is:
- State the invariant, in one sentence, if the type has one.
- Write the operations the rest of the program needs, as if the type worked
perfectly. These are the system's verbs:
borrow,returnItem,renew,applyRaise. - Choose the representation that those operations turn out to need.
examples/module-04/02-enforcing-invariants/main.cpp is the same class as §1, designed in
this order. The difference is not stylistic:
bool borrow(const std::string& itemTitle) {
if (itemCount() >= kLimit) {
return false; // refuse, and change nothing
}
borrowed_.push_back(itemTitle);
return true;
}
// The two lines that used to compile now do not even parse:
// alice.setBorrowedCount(-2);
// error: 'class Member' has no member named 'setBorrowedCount'
A refused operation must change nothing. That is what makes an invariant reliable: callers can attempt an operation, be told no, and know that the object is exactly as it was. The alternative — half-applied operations — is how a program reaches a state nobody designed.
4. Capabilities, not accessors¶
The most useful habit in this module: a public member should express what the system does, not what it stores.
| The caller wanted | The field-shaped answer | The capability-shaped answer |
|---|---|---|
| to show a book's page count | int pages() const |
bool isLongRead() const |
| to mark a book as lent | void setOnLoan(bool) |
bool lend() — and it refuses if already lent |
| to know a member's outstanding items | int borrowedCount() const |
bool canBorrowMore() const |
| to add a loan to a catalogue | std::vector<Loan>& loans() |
bool record(const Loan&) |
| to check an account's balance | long long balance() const |
bool canAfford(long long paisa) const |
The test for any proposed public member:
What would a caller do with this? If they would compare, validate or combine it to reach a decision, the decision belongs in the class.
Is a getter ever right? Yes — for information that is genuinely read-only and has no
rule attached: Book::id(), Loan::daysAllowed(). What is wrong is the automatic
getter/setter pair per field, written before asking what the caller needs.
Is a setter ever right? Rarely, and the honest exceptions are narrow: a mutable field
with no invariant at all, where the system really does mean "replace this value with that
one" — a std::string note; on an order, say. The test: can a caller, having called this
setter, put the object into a state the other functions do not expect? If yes, replace
the setter with the operation.
5. Four ways to leak the representation¶
Private members do not leak. Public signatures do, and the leaks are quiet. All four of these compile, and only one of them produces an error:
1. Handing out a mutable reference to internal storage. The classic, and the silent one:
The caller now has a mutable alias to the object's state. Every rule you wrote is
optional. The fix is to return a copy, or a const& — or better, to offer the
operation (addItem(title)) so that no reference escapes at all.
2. Returning iterators or pointers into the representation.
std::vector<std::string>::iterator begin(); // the same leak, in a different costume
const Book* findById(int id); // mutable pointer to an internal Book
Both are the same mistake: the caller holds a handle whose lifetime and mutation they do not control.
3. Making the storage type part of the interface. Any signature that names
std::vector, std::array or a capacity makes that choice permanent. begin()/end(),
size_t capacity(), T* data() — each one says "you may depend on how I store this".
4. A const getter that returns a reference the caller keeps. Subtly different from
(1):
No one can write through this reference — so far, so good. But a caller may keep it, and
if the object is destroyed or modified, the reference dangles or goes stale. Returning by
value (std::vector<std::string> titles() const) looks wasteful and is the safe default.
examples/module-04/02-enforcing-invariants/main.cpp does exactly that, with the reason in
a comment.
Is a const& return ever right? Yes — for a member that is never modified after
construction, where the caller's reference cannot go stale in a surprising way. The rule is
not "never"; it is "say which one you meant, and why".
6. const-correctness, and the one keyword that bends it¶
Module 2 introduced const member functions. Encapsulation is why they matter:
const-correctness is what lets other code hold your object without being able to change
it.
For this to be written at all, every member function the function needs must be const.
And there is a second use that matters more than it looks: it distinguishes reading from
changing in the type's own interface. A class where half the members are const and half
are not is a class whose interface tells you which is which.
Overloading on const is the mechanism behind the standard containers, and it is worth
seeing once:
const std::string& at(std::size_t index) const; // read-only access
std::string& at(std::size_t index); // writable access
The compiler picks whichever is legal for the object it was called on. That is how
v[0] = 5; fails on a const std::vector<int> while std::cout << v[0]; works.
Throwing const away. If a const function tries to hand out mutable access, the
compiler refuses:
error: binding reference of type 'std::vector<std::__cxx11::basic_string<char> >&' to
'const std::vector<std::__cxx11::basic_string<char> >' discards qualifiers
That message is the compiler protecting the invariant, and the answer is never the
const_cast that makes it go away.
mutable — the legitimate exception. It allows one member to be modified inside a
const member function:
class Report {
public:
std::string text() const {
if (!built_) { // ← requires built_ to be mutable
cache_ = buildSlowly();
built_ = true;
}
return cache_;
}
private:
mutable std::string cache_;
mutable bool built_ = false;
};
The justification is narrow and specific: the member is not part of the object's
observable state. A cache, a memoised result, a lock, a counter of calls for
diagnostics. If you cannot say "the caller cannot observe this change and it does not
affect any answer", mutable is the wrong tool and the function should not be const.
7. The test that decides whether it worked¶
Here is the question this module keeps coming back to, and it is testable:
If I changed the private representation completely, would any correct caller need to change?
If the answer is yes, the representation leaked: some public signature, or some returned pointer, told the caller how the object is stored.
examples/module-04/04-representation-swap/main.cpp answers it by doing it. One public
interface, two completely different private representations — a std::vector<std::pair<…>>
and two parallel arrays with a count — selected at compile time:
g++ -std=c++17 -Wall -Wextra main.cpp -o inventory_v1
g++ -std=c++17 -Wall -Wextra -DINVENTORY_VERSION=2 main.cpp -o inventory_v2
./inventory_v1 > out1.txt && ./inventory_v2 > out2.txt && diff out1.txt out2.txt
The diff is silent. Not one line of the calling code changed, and it could not have:
- no operation returns a container, a pointer or an iterator;
- no signature names
std::vector, a size type or a capacity as a storage detail; report()returns astd::string— a value — rather than a container of internal state;- the capacity is part of the contract in both versions, so it is not an artefact of the array representation that the caller could observe.
That last point is worth pausing on. If only version 2 had a capacity limit, a caller could tell them apart, and the "representation" would have leaked through behaviour rather than through a signature. Any difference a caller can observe is part of your interface, whether or not you wrote it down.
8. friend: what it costs¶
friend grants one specific function or class access to your private members:
class Book {
public:
friend std::ostream& operator<<(std::ostream& out, const Book& book);
private:
int id_;
std::string title_;
};
Used this way it is normal and idiomatic: operator<< (Module 5) has no other way to reach
the members, and it is your own function, in your own header.
Used any other way, it is a coupling decision with a cost: the friend is now part of your implementation. It can break your invariant, it must be re-checked whenever the representation changes, and it cannot be reasoned about from the interface alone. Before adding one, ask:
- Does the operation belong in the class? Usually it does — and then it needs no
friendat all. - Could a public query answer the question instead? Often a
bool isLongRead() constreplaces both the getter and the friend. - Is it only for a formatter? Fine — and keep the friend list to formatters and comparison operators, which is what it is for.
Hiding a design problem behind friend is the same move as hiding one behind
const_cast: the keyword makes the compiler stop complaining, and the coupling stays.
9. Cohesion and coupling: the two words for "good design"¶
Cohesion is how single-mindedly a class does one job. Coupling is how much one part of a program must know about another. The aim is high cohesion and low coupling, and both can be argued about concretely.
| Symptom | Which word | What it means in practice |
|---|---|---|
| A class whose one-sentence description needs "and" | low cohesion | it is two types glued together |
| A class that knows about another type's storage | high coupling | change one, and the other breaks |
| A function that takes five parameters to do one thing | high coupling | the data wants to be a type |
| A class you can test on its own | high cohesion, low coupling | the goal |
Cohesion and coupling are the raw material of the SOLID principles in Module 15. You do not need their names yet — you need the sentence "a class knows too much about another class" to be one you can say and defend.
10. The interface checklist¶
A well-designed interface is easy to use correctly and hard to use incorrectly. Five questions, applied to any class you write:
- Can a caller reach a state the invariant forbids? Try it: write the offending line. If it compiles, the invariant is not enforced. (This is the attack test.)
- Can a caller forget to check something? If an operation can fail, is failing the
obvious outcome — a
bool, an exception, or a refused no-op? Silence is the enemy here. - Does any signature reveal the storage? Containers, pointers, iterators, capacities, sizes. Every one of them is a commitment.
- Is every public member justified by a caller's need? Say the caller's sentence out loud: "this needs to know whether the item is out so that…". If the sentence ends nowhere, the member goes.
- Can a caller misuse it without noticing? The mechanism for preventing that is
explicit(Module 3), a narrower type, or an operation instead of a setter.
And one habit that improves every interface you write: design the error messages.
If a caller passes something invalid, what do they see? "Returns false" is fine when the
caller can do something about it; a silent correction is never fine — as
examples/module-02/05-bank-account/main.cpp admits in its own constructor, which quietly
turns a negative opening balance into zero.
Checkpoint¶
Answer these before the worked examples. If an answer is not immediate, re-read the section beside it.
- Give the definition of encapsulation from this module, and say why "all members are private and every field has a getter and setter" does not satisfy it. (§1–2.)
- What is an invariant? Name the two properties an invariant must have to be worth writing down. (§2.)
- Predict: which of these four compiles, and which leaks the representation? (§5. Two of them leak, in different ways, and one is the recommended default.)
- Rewrite
void setDueDate(Date date)as a capability, and say what the class now refuses to allow. (§4.) - Why is
mutablesometimes correct and usually wrong? Give the test. (§6.) - State the representation-swap test. Then say what in
examples/module-04/04-representation-swap/main.cppwould have made it fail. (§7.) - Name the two costs of using
friendfor something other thanoperator<<. (§8.) - A caller asks for
int borrowedCount() constbecause they want to display "3 of 3 items out". What would you offer instead, and why is it better? (§4 and §10.)
Next: Worked examples — five programs and a design, each with a prediction prompt. · All modules