Skip to content

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
};
Member alice("Alice");
alice.setBorrowedCount(-2);      // compiles. No error. No warning.

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 Member with 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:

  1. State the invariant, in one sentence, if the type has one.
  2. 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.
  3. 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:

std::vector<std::string>& items() { return items_; }     // ← leaks, no error
basket.items().push_back("sneaked in behind the invariant");   // compiles

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):

const std::vector<std::string>& titles() const { return titles_; }   // compiles

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.

void printSummary(const Basket& basket);      // cheap, and cannot modify

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 a std::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:

  1. Does the operation belong in the class? Usually it does — and then it needs no friend at all.
  2. Could a public query answer the question instead? Often a bool isLongRead() const replaces both the getter and the friend.
  3. 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:

  1. 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.)
  2. 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.
  3. Does any signature reveal the storage? Containers, pointers, iterators, capacities, sizes. Every one of them is a commitment.
  4. 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.
  5. 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.

  1. 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.)
  2. What is an invariant? Name the two properties an invariant must have to be worth writing down. (§2.)
  3. Predict: which of these four compiles, and which leaks the representation?
    std::vector<std::string>& items();                       // (a)
    const std::vector<std::string>& items() const;           // (b)
    std::vector<std::string> items() const;                  // (c)
    bool addItem(const std::string& title);                  // (d)
    
    (§5. Two of them leak, in different ways, and one is the recommended default.)
  4. Rewrite void setDueDate(Date date) as a capability, and say what the class now refuses to allow. (§4.)
  5. Why is mutable sometimes correct and usually wrong? Give the test. (§6.)
  6. State the representation-swap test. Then say what in examples/module-04/04-representation-swap/main.cpp would have made it fail. (§7.)
  7. Name the two costs of using friend for something other than operator<<. (§8.)
  8. A caller asks for int borrowedCount() const because 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