C++ Part I · Module 11 · Classes and Encapsulation
Encapsulation and the Class Invariant
The mechanism is finished and it is entirely possible to use all of it and gain nothing. A type whose data is private and whose interface is a setter for each member permits exactly what Chapter 11.1 measured a bare struct permitting, and the usual objection to that design — that a setter lets callers break the invariant — turns out to be the wrong criticism, because a setter can check. This chapter measures what actually goes wrong, and it is more interesting than the standard complaint. Given an invariant that relates two members rather than constraining each one separately, two setters that each validate correctly and independently will refuse to perform a transition between two perfectly valid states, in either order, leaving the object stuck where it started. The target was legal, the setters were right, and the interface made it unreachable. That measurement is the argument of the chapter, because it shows the unit of mutation is wrong rather than the checking: an object's invariant is a statement about its state as a whole, so the operations that change it have to be whole operations too. The rest follows — where checking must happen so that no legal sequence of calls can defeat it, what it means for an object to be valid at every moment a caller could observe it, and the honest boundary of what private actually prevents.
Intermediate18 min readC++Part IFundamentalsClasses
Part I · Module 11 · Chapter 11.5 · Encapsulation and the Class Invariant
1. Mechanism Is Not Discipline
Chapter 11.4 finished the mechanism: data can be made unreachable from outside, and only the type's own functions may touch it. Chapter 11.3's callout then described a type that uses all of it and achieves nothing — private members, a getter and a setter for each, and the same permissiveness Chapter 11.1 §2 measured in a bare struct.
So the mechanism is necessary and not sufficient. What is missing is the invariant itself, stated well and enforced in the right place.
2. Stating an Invariant Precisely
An invariant is a statement about an object's data that is true for the whole of its lifetime.
The practical test of a well-stated invariant is simple: can it be written as code that returns true or false? If not, it is a feeling rather than an invariant.
| Vague | Checkable |
|---|---|
| "the field is sensible" | offset >= 0 && width > 0 && offset + width <= 32 |
| "the buffer is valid" | size <= capacity && (capacity == 0 || data != nullptr) |
| "the window is reasonable" | lo <= hi && hi - lo <= 8 |
| "the name is set" | !name.empty() |
Two observations that shape everything after.
Most real invariants relate members to each other. offset + width <= 32 is about both members; so is size <= capacity. A constraint on a single member alone can often be moved into its type instead — which is what Chapter 9.9's enum class did, making an invalid access mode unrepresentable rather than merely rejected. Prefer that when it is available.
And the relational ones are where interfaces go wrong, which is §4.
3. Three Places It Must Hold
An invariant that holds sometimes is not an invariant. There are exactly three moments to cover:
At construction. The object must not come into existence invalid. Chapter 11.1 §5 measured this: four invalid constructions refused, each with a reason.
After every operation that mutates. Chapter 11.1 §2 measured a.width = -7 breaking a valid struct, and Chapter 11.1 §5's widen() refusing to pass 32.
And there must be no way to reach the data directly. Chapter 11.4 §3's measurement — including that a private member's address cannot be taken from outside.
Miss any one and the other two are decoration. Covering all three is what Chapter 11.1 called the promise, and Chapter 11.1 §5 measured its cost at zero bytes: sizeof(Field) was 8 either way.
4. Why Checking Setters Are Still Not Enough
Here is the measurement that makes this chapter necessary.
An invariant relating two members: lo <= hi, and the span hi - lo must not exceed 8. Two setters, each of which checks the invariant correctly and completely:
class Window {
public:
Window(int lo, int hi) : lo_(lo), hi_(hi) {}
bool setLo(int v) { if (v > hi_ || hi_ - v > 8) return false; lo_ = v; return true; }
bool setHi(int v) { if (v < lo_ || v - lo_ > 8) return false; hi_ = v; return true; }
// the alternative
bool moveTo(int lo, int hi)
{ if (lo > hi || hi - lo > 8) return false; lo_ = lo; hi_ = hi; return true; }
private:
int lo_, hi_;
};The task: move the window from [0, 8] to [20, 28]. Both are valid states — each satisfies lo <= hi and has a span of exactly 8.
Measured:
goal: move the window from [0,8] to [20,28] -- both are VALID states
with per-member setters:
setLo(20) -> REFUSED (state now [0,8])
setHi(28) -> REFUSED (state now [0,8])
...and in the other order:
setHi(28) -> REFUSED (state now [0,8])
setLo(20) -> REFUSED (state now [0,8])
with one whole-object operation:
moveTo(20,28) -> ok (state now [20,28])Neither order works. setLo(20) is refused because 20 is above the current hi of 8. setHi(28) is refused because that would make the span 28. The object is stuck at [0, 8], and a valid state is unreachable.
This is not the usual complaint about setters. The setters here are correct: neither one ever permits an invalid state, which is exactly what they were asked to do. The failure is in the interface, and it is precise:
An invariant is a statement about the object's state as a whole, so the unit of mutation must be the whole object too.
Reaching [20, 28] from [0, 8] requires passing through a state that is invalid — either [20, 8] or [0, 28] — and a per-member setter that refuses invalid states must therefore refuse the transition. The only way out is an operation that changes both members together, with the check applied once, to the result. moveTo does exactly that and succeeds.
5. Operations, Not Setters
The rule that follows, and it is the chapter's practical content:
Expose the operations the caller actually wants, not the members you happen to store.
Three forms, in order of preference.
No mutation at all. If the state never needs to change after construction, provide a validating constructor and const observers. The invariant is established once and cannot subsequently be broken — the strongest guarantee available, and it costs nothing. Most value-like engineering types want this: a field descriptor, a transaction record, a parsed header.
Named whole-object operations. moveTo(lo, hi), widen(), resize(n), advance(cycles) — named for the caller's intent, validating the resulting state once. Chapter 11.1 §5's widen() is one; moveTo is the fix for §4.
A setter only where a member is genuinely independent. If changing one member cannot affect the invariant, a setter is honest. setName(std::string) on a type whose only rule is !name.empty() is fine — the rule involves one member, so one member is the right unit.
The diagnostic question for any proposed setter: is this member's validity independent of every other member? If yes, a setter is fine. If no, §4 is what you are building.
6. The Enforcement, Measured
Chapter 11.1 §5's Field, with its invariant offset >= 0 && width > 0 && offset + width <= 32:
construction attempts:
Field(8, 4) ACCEPTED (offset 8 width 4)
Field(40, 100) REJECTED: field exceeds 32 bits
Field(-1, 0) REJECTED: offset negative
Field(0, 0) REJECTED: width not positive
Field(30, 4) REJECTED: field exceeds 32 bits
Field(28, 4) ACCEPTED (offset 28 width 4)
mutation attempts on a valid Field(28, 4):
widen() REJECTED: cannot widen past 32
sizeof(Field) = 8And from outside, at compile time:
| Attempt | Result |
|---|---|
f.width_ = 100; | error: 'width_' is a private member of 'Field' |
int* p = &f.width_; | error: 'width_' is a private member of 'Field' |
Field h; | error: no matching constructor for initialization of 'Field' |
Field g{40, 100}; | compiles — and throws at runtime |
All three of §3's places are covered: construction refuses, widen() refuses, and the data is unreachable. The Field h; row is worth noticing — there is no valid default state, so the type refuses to have one, and Chapter 9.8 §3's zero-initialization never gets a chance to produce {0, 0}.
On how to refuse. The examples throw, which needs Module 22 — Error Handling and Diagnostics, whose Chapter 22.2 is throw, try and catch — and is the right choice when the caller might reasonably pass bad data (parsed input, a config file). The alternatives are returning a bool as moveTo does, which suits an operation that can routinely fail; and assert, which suits a condition that indicates a programmer error rather than bad data, and which belongs with Module 25's material. What is never acceptable is proceeding with an invalid object.
(The constructor remains an instrument borrowed from Chapter 12.2, as in Chapter 11.1 §5.)
7. Valid at Every Observable Moment
A refinement that matters for anything non-trivial.
The invariant does not have to hold at every instant. It has to hold at every instant a caller could observe. Inside moveTo, between the two assignments, the object is briefly inconsistent — lo_ has been updated and hi_ has not — and that is fine, because no other code can see it.
So the precise statement is:
The invariant must hold whenever control leaves the type's own code.
That is a weaker and more useful requirement, and it is what makes whole-object operations possible at all. It also tells you where the real risks are — any point where your own code hands control outward while the object is half-updated:
Calling something that might throw partway through a multi-step update. If it throws, the object is left inconsistent and visible. Chapter 22.5's exception safety guarantees are this problem properly.
Returning a non-const reference or pointer to internal data. This hands a caller direct write access and defeats §3's third requirement entirely:
int& widthRef() { return width_; } // the invariant is now unenforceableThat one line undoes everything, and it is a common accident. Chapter 9.6 §5 measured the lifetime version of the same hazard with string_view.
And calling a user-supplied callback mid-update, which can call back into the object.
The matching positive requirement is that observers must be callable on a const object — otherwise a caller who holds a const Field& (which Chapter 6.5 established as the default way to pass anything non-trivial) cannot read it at all. That is Chapter 11.7's subject, and it is why that chapter is also rated Critical.
8. What Encapsulation Is Not
Chapter 11.1 §8 and Chapter 11.4 §8 both measured the limit; here it is in this chapter's terms. A memcpy through the object's bytes changed a private width_ from 4 to 100 with no diagnostic at all.
So private is not a security boundary, and it is worth being clear about who the invariant is actually for:
The audience is a colleague, not an attacker. The realistic failure is a reasonable person writing a.width = -7 because they did not know the rule, or writing setLo(20) and getting false back and not knowing why. An invariant enforced by the type answers both: the first does not compile, and the second is replaced by an operation that works.
And the audience includes you in six months, which is the case that actually pays. Chapter 10.8 measured the general version of this: three leaks introduced by edits to a correct function, because the knowledge needed lived in a comment rather than in a type.
Two further things it is not:
Not information hiding for its own sake. Making a member private that has no invariant gains nothing — Chapter 11.1 §9 says to use a struct.
Not a performance cost. Measured, repeatedly: sizeof identical, member functions 0 bytes.
9. What to Actually Do
Write the invariant as a boolean expression before writing the interface, and put it in a comment above the private data.
Check it at construction, and refuse — throw, return false, or assert, but never proceed.
Make all data private so there is no route around the checking (Chapter 11.4).
Prefer immutability, then named whole-object operations, then setters only for genuinely independent members.
Ask the reachability question: can every valid state be reached from every other through the operations you provide? §4's measurement is what happens when the answer is no.
Never return a non-const reference or pointer to internal data. It defeats the whole mechanism in one line.
Expect no default constructor when there is no valid default — that is correct, not awkward.
Be careful where control leaves your code mid-update — a throw, a callback, a returned reference.
And remember the audience is a colleague, so the invariant's job is to make the rule knowable, not to resist attack.
10. Mistakes Worth Making Once
Private data with a setter per member. Chapter 11.1 §2's struct with extra typing.
Assuming a checking setter is sufficient. Measured: both orders refused, and a valid state was unreachable.
Weakening the invariant to make the setters work. The interface was wrong, not the rule.
Checking only in the constructor. a.width = -7 and widen() are the counterexamples.
Returning int& to a member. One line, and the invariant is unenforceable.
Stating an invariant you cannot write as an expression. It will drift into several inconsistent notions of validity.
Adding a validity flag so a default-constructed object can exist. Every operation must then check it; prefer no such state.
Believing private protects the invariant absolutely. A memcpy broke it with no diagnostic.
Expecting it to cost something. sizeof identical, measured repeatedly.
11. Interview Relevance
"What is a class invariant?" A statement about the object's data that holds for its whole lifetime — checked at construction, preserved by every mutating operation, and unreachable from outside. Naming all three places is the complete answer.
"What is wrong with getters and setters for every member?" The strong answer is §4's, not the usual one. A setter can check; the real failure is that an invariant relating two members makes some valid transitions impossible one member at a time — and the measured example of two refusals in both orders is memorable and specific.
"Where must the invariant hold?" Whenever control leaves the type's own code — not at every instant, which is what makes multi-step operations possible.
"When should a class have no default constructor?" When there is no valid default state. The follow-up worth volunteering is that adding a validity flag instead is worse, because every operation must then check it.
"Does encapsulation cost anything at runtime?" No — identical sizeof, and member functions add nothing.
"Is private a security feature?" No. A compile-time barrier against accident; the audience is a colleague.
12. What You Should Be Able to Say Now
- An invariant is a statement about the data that holds for the object's whole lifetime, and a good one can be written as a boolean expression.
- Most real invariants relate members to each other; a single-member rule often belongs in that member's type (Chapter 9.9).
- It must hold in three places: at construction, after every mutation, and there must be no direct route to the data.
- Measured: a checking setter is not enough.
setLo(20)andsetHi(28)were both REFUSED in both orders, leaving[0,8]stuck with[20,28]unreachable. - Both states were valid — the failure was the interface, not the checking.
- So the unit of mutation must be the whole object:
moveTo(20,28)succeeded. - Prefer immutability, then named operations, then setters only for independent members.
- Measured enforcement: four invalid constructions and one invalid
widen()refused;sizeof8, unchanged. - No valid default means no default constructor —
Field h;correctly fails. - The invariant must hold whenever control leaves the type's code, not at every instant.
- Returning a non-
constreference to a member defeats everything in one line. - Observers must be
const-callable or aconst Field&is unusable — Chapter 11.7. privateis not security — amemcpybroke it silently; the audience is a colleague.- Ask the reachability question for every pair of valid states.
13. Exercises
Exercise 1 — state them. Write a checkable boolean invariant for: (a) a fixed-capacity queue (b) a date (c) a string_view-like type (d) a register field in a 64-bit register (e) a fraction in lowest terms.
Exercise 2 — predict the deadlock. For the Window with span ≤ 8, say whether [0,8] → [4,12] is reachable with the per-member setters, and give the order if so.
Exercise 3 — fix the interface. A Buffer has setSize(int) and setCapacity(int), each checking size <= capacity. Give a transition between two valid states that neither order achieves, and the operation that fixes it.
Exercise 4 — the three places. For a Counter with invariant 0 <= n <= limit, say what must happen at construction, in bump(), and about the data's accessibility.
Exercise 5 — find the leak in the wall. Identify the member that breaks encapsulation and say why: int size() const, int& at(int), const int& at(int) const, int* data(), void clear().
Exercise 6 — no default. Argue why a RegisterField should have no default constructor, and say what a validity flag would cost.
Exercise 7 — observable moments. Give three places where control leaves your own code mid-update, and say what each risks.
Exercise 8 — reachability review. You are reviewing a class with four setters and one invariant relating three members. Write the two questions you would ask.
14. Summary
- An invariant is a lifetime-long statement about the data, and it must be writable as a boolean expression.
- Most are relational; single-member rules often belong in the member's type.
- Three places: construction, every mutation, and no direct route in.
- Measured: checking setters are not enough — both orders REFUSED,
[20,28]unreachable from[0,8]though both are valid. - The unit of mutation must be the whole object —
moveToworked. - Prefer immutability, then named operations; setters only for independent members.
- Measured enforcement on
Field: all invalid constructions andwiden()refused, atsizeof8 — free. - No valid default means no default constructor.
- The invariant must hold whenever control leaves your code; a returned non-
constreference defeats it. privateis a barrier against accident, not attack — the audience is a colleague.
Three chapters have now written const on member functions without accounting for it — on width(), on count(), on mean() — and Chapter 11.3 flagged the debt twice, saying it could not be explained until something else was in place. That something is the hidden argument from Chapter 11.3 §5: a member function receives the object it was called on without being handed a parameter, and const on the function is a statement about that argument rather than about anything in the parameter list. So the keyword has to wait for the argument to be named. The next chapter names it, and the measurements are more surprising than the concept — the thing every member function receives turns out not to be stored in the object at all, and a class holding a single four-byte integer produced a member-function pointer four times its own size.
Standards & specifications
- Governing standard
- ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)
Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C++ Programming curriculum.
