C++ Part I · Module 11 · Classes and Encapsulation
Why Classes Exist: Invariants, Not Just Grouping
Two chapters have now arrived at the same missing piece from opposite directions. Chapter 9.11 was choosing how to represent engineering data and found that a struct cannot enforce a rule: nothing stops a field description claiming a hundred bits inside a thirty-two bit register, because an aggregate is transparent by design and transparency and enforcement are opposites. Chapter 10.9 was asking who is responsible for releasing a resource and found that the answer needs an object able to make a promise and keep it, since a pointer's type can carry no such thing. Both want a construct that does more than hold data side by side. The usual way this one is introduced — a class is a struct that can have functions in it — is true and almost entirely beside the point, because a struct can have functions in it too, and adding them changes nothing about what the type can guarantee. What a class adds is the ability to state something that must remain true about its own data and to make that statement unbreakable from outside. This chapter measures both halves of that claim: first the same struct from Chapter 9.7 accepting four separate nonsense states without a single compiler diagnostic, then the same two members as a class refusing every one of them — and the two types turning out to be exactly the same size, so the guarantee is free.
Foundation17 min readC++Part IFundamentalsClasses
Part I · Module 11 · Chapter 11.1 · Why Classes Exist: Invariants, Not Just Grouping
1. Two Chapters Asked for the Same Thing
Chapter 9.11 §9, choosing a representation, found the limit of a struct:
"Nothing in this module stops
Field{40, 100}from claiming a 100-bit field at offset 40 in a 32-bit register. Astructis an aggregate — Chapter 9.8 §2's definition is exactly that it has no constructor deciding how it is built… An aggregate is transparent by design, and transparency and enforcement are opposites."
Chapter 10.9 §7, naming ownership, needed something a pointer could not be:
an object that can hold the statement "I am responsible for this" and have the language keep it.
Both are asking for a type that promises something about its own data. That is what a class is for.
2. What Grouping Alone Buys You
Chapter 9.7's type, unchanged:
#include <cstdio>
struct Field { int offset; int width; };
int main()
{
Field a{8, 4}; // sensible: 4 bits at offset 8
Field b{40, 100}; // nonsense: 100 bits at offset 40 of a 32-bit register
Field c{-1, 0}; // nonsense: negative offset, zero width
Field d{}; // zero width
std::printf("a: offset %d width %d\n", a.offset, a.width);
std::printf("b: offset %d width %d <-- accepted\n", b.offset, b.width);
std::printf("c: offset %d width %d <-- accepted\n", c.offset, c.width);
std::printf("d: offset %d width %d <-- accepted\n", d.offset, d.width);
a.width = -7; // and a valid object can be broken afterwards
std::printf("a after a.width = -7: width %d\n", a.width);
return 0;
}Compiler diagnostics at the standing flags: 0. Measured output:
a: offset 8 width 4
b: offset 40 width 100 <-- accepted
c: offset -1 width 0 <-- accepted
d: offset 0 width 0 <-- accepted
a after a.width = -7: width -7Three nonsense objects created, and a fourth created correctly and then broken by an ordinary assignment. Nothing objected.
This is not a defect in struct. Chapter 9.7 argued for it and Chapter 9.8 explained the brace rules, and for data whose members are independent facts that is exactly right. The point is narrower: offset and width are not independent. A field at offset 28 cannot be 8 bits wide in a 32-bit register. That relationship exists in the problem, and there is nowhere in a struct to put it.
3. The Word for It
An invariant is something that must be true about an object's data for the whole of the object's lifetime.
For a register field:
offset >= 0width > 0offset + width <= 32
Three statements, and every one of §2's nonsense objects violates at least one.
Two things worth noticing about the definition.
"For the whole of the object's lifetime" uses Chapter 10.5's vocabulary deliberately. It is not enough to check at creation — a.width = -7 broke a valid object after the fact. An invariant has to survive every operation.
And an invariant is a relationship, usually. A rule about one member alone can sometimes be expressed in its type, which is what Chapter 9.9's enum class did for the access mode: three named values instead of four billion, so an invalid mode became unrepresentable. What enum class cannot express is offset + width <= 32, because that is a statement about two members together.
4. Why Functions Alone Do Not Fix It
The description "a class is a struct that can have functions" is the one most people meet first, and it fails for a simple reason: a struct can have functions too.
struct Field {
int offset;
int width;
int last() const { return offset + width - 1; } // perfectly legal
};Measured — three types, the same two int members, with and without functions:
sizeof(AsStruct) = 8
sizeof(AsClass) = 8
sizeof(WithFunctions) = 8 (10 member functions)
alignof: 4 / 4 / 4Ten member functions added zero bytes, and the class and the struct are identical in size and alignment. Adding behaviour changes nothing about what the type can guarantee: last() is a convenience, and the object it is called on may still hold offset 40 and width 100.
So functions are not the difference. The difference is that the data can be made unreachable from outside, so that the only way to create or change the object is through code that checks. Functions are how the checking is expressed; access control is what makes it unavoidable. That is the module blurb's sentence, and Chapter 11.4 is the mechanism.
5. The Same Type, Promising
Same two members. A constructor that checks, and the members made private.
class Field {
public:
Field(int offset, int width)
{
if (offset < 0) throw std::invalid_argument("offset negative");
if (width <= 0) throw std::invalid_argument("width not positive");
if (offset + width > 32) throw std::invalid_argument("field exceeds 32 bits");
offset_ = offset;
width_ = width;
}
int offset() const { return offset_; }
int width() const { return width_; }
int last() const { return offset_ + width_ - 1; }
void widen()
{
if (offset_ + width_ + 1 > 32) throw std::invalid_argument("cannot widen past 32");
++width_;
}
private:
int offset_;
int width_;
};Measured, handed the same values as §2 plus two boundary cases:
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) = 8 (same as the struct in 9.7)Four observations, in increasing order of importance.
Every nonsense state from §2 is now refused, each with a specific reason.
The boundary is right. Field(28, 4) occupies bits 28 to 31 and is accepted; Field(30, 4) would need bit 33 and is refused.
widen() is refused too, which is §3's "for the whole lifetime" clause honoured. The invariant is not just checked at birth.
And sizeof(Field) is 8 — identical to the struct. The promise costs zero bytes. There is no hidden flag, no validity bit, no bookkeeping. The guarantee lives entirely in which code is allowed to touch the members.
(The constructor here is an instrument borrowed from Chapter 12.2, and throw from Chapter 22.2. Neither is this chapter's subject; both are the shortest way to make the measurement.)
6. What "Unbreakable From Outside" Actually Means
Measured, one attempt at a time, from outside the class:
| Attempt | Result |
|---|---|
f.width_ = 100; | error: 'width_' is a private member of 'Field' |
f.offset_ = -1; | error: 'offset_' is a private member of 'Field' |
int* p = &f.width_; *p = 100; | error: 'width_' is a private member of 'Field' |
Field h; | error: no matching constructor for initialization of 'Field' |
f.width(); | compiles — it is the public interface |
The third row is the one worth pausing on. Chapter 8.2 established that taking an address gets you a pointer you can write through — and you cannot even take the address of a private member from outside, so the pointer route is closed at the first step rather than the last.
The fourth row matters too: there is no way to make a Field that has not been through the constructor. Chapter 9.8 §3 measured that P{} value-initializes an aggregate's members to zero; here, zero offset and zero width would violate the invariant, so the type refuses to be default-constructed at all.
And one case compiles, which deserves care:
Field g{40, 100}; // compilesThat is not a hole. Measured, it throws at runtime: bad field. The braces no longer mean what Chapter 9.8 described, because Field is no longer an aggregate — the brace form now calls the constructor. Measured with std::is_aggregate:
is_aggregate<Plain> = 1 (a struct with public members)
is_aggregate<WithCtor> = 0
is_aggregate<PrivateOnly> = 0 <-- private members alone disqualify itThe third line is the precise connection to Chapter 9.8 §2's definition, and it explains Chapter 9.10 §4's finding that a structured binding "cannot decompose private member": a type with private data is not an aggregate, so none of the aggregate machinery applies to it.
7. The Thesis
The module's organising sentence, now earned rather than asserted:
A class is not a struct with functions attached. It is a type that owns an invariant, and access control is the mechanism that keeps the invariant enforceable.
Each clause is a measurement:
Not functions — ten of them added 0 bytes and changed nothing a struct could not do.
Owns an invariant — the class refused four invalid states at construction and one invalid mutation, at 0 bytes of cost.
Access control is the mechanism — every outside route to the data was closed at compile time, including taking its address.
Everything else in this module follows. Chapter 11.4 is the mechanism in detail, Chapter 11.5 is the invariant as a discipline, Chapter 11.7 is how a const object remains usable, and Chapter 11.9 turns a real register description into such a type.
8. The Honest Limit
private is a compile-time barrier, not a memory one. Measured, by going through the object's bytes:
Field f(8, 4);
int bytes[2];
std::memcpy(bytes, &f, sizeof(f));
bytes[1] = 100;
std::memcpy(&f, bytes, sizeof(f));width() reports 4
after memcpy, width() reports 100 <-- invariant brokenThe invariant was broken and nothing warned about it. (The only diagnostic in that program was an unused-field warning about my test's offset_; the memcpy itself drew nothing.)
Two conclusions, and the second is the useful one.
Access control is not a security mechanism. It does not protect against code determined to circumvent it, and it was never intended to. Code that memcpys into another type's representation has left the language's rules behind — Chapter 10.5 §6's point about storage and objects being different things is what makes it possible.
It is a mechanism against accident, and that is the whole value. The bugs that actually occur are not malicious; they are a.width = -7 written in a hurry by someone who did not know the rule existed. §6 measured that route closed. Chapter 11.5 §7 returns to this with the useful framing: the audience for an invariant is a colleague, not an attacker.
9. What to Actually Do
Ask what must stay true about this data. If the answer is "nothing in particular", a struct is correct — Chapter 9.7 was not wrong.
Use a class when members are related, not merely when there are several of them.
Write the invariant down before writing the type, in the words of the problem.
Check it at construction and in every operation that mutates — widen() was refused for exactly this reason.
Make the data private so that checking cannot be bypassed by accident.
Prefer making an invalid state unrepresentable where a type can do it — Chapter 9.9's enum class beats any amount of checking.
Do not expect a class to cost anything. Measured: 8 bytes either way, ten member functions free.
And do not treat private as security. It stops accidents, which is what actually goes wrong.
10. Mistakes Worth Making Once
Using a struct for related members. Measured: four nonsense states, zero diagnostics.
Checking only at construction. a.width = -7 and widen() are the counterexamples.
Believing a class is "a struct with functions". A struct can have functions; ten of them cost 0 bytes and guarantee nothing.
Adding a getter and setter for every member. A setWidth that accepts anything is §2 with extra typing.
Expecting a class to be bigger or slower. Identical sizeof and alignof.
Assuming brace initialization still bypasses the constructor. Field g{40,100} compiles and throws — the type is no longer an aggregate.
Forgetting that private members alone disqualify a type from being an aggregate. Measured is_aggregate = 0, which is why Chapter 9.10's structured bindings refuse it.
Treating private as a security boundary. A memcpy broke the invariant with no diagnostic.
11. Interview Relevance
"What is the difference between a struct and a class in C++?" The literal answer is the default access level (Chapter 11.4 measures it), and that is the answer most candidates give. The better answer adds that the difference is conventional rather than technical — both can have functions, both can have access specifiers, and their size is identical — and that what matters is whether the type owns an invariant.
"Why make data private?" So that the only code able to create or modify the object is code that maintains the invariant. The strong version names a concrete invariant and the state it makes impossible.
"What is a class invariant?" Something that must be true about the object's data for its entire lifetime — checked at construction and preserved by every mutating operation.
"Does encapsulation cost performance?" No, and the measurement is the answer: identical sizeof, and member functions add nothing to the object.
"Is private a security feature?" No — a compile-time barrier against accident, not against determined circumvention. Candidates who claim otherwise are usually repeating something they have not tested.
12. What You Should Be Able to Say Now
- An invariant is something that must stay true about an object's data for its whole lifetime.
- Measured: a
structaccepted{40,100},{-1,0}and{}with 0 diagnostics, and a valid object was broken bya.width = -7. - That is not a flaw in
struct— it is right for independent members, and wrong when members are related. - "A class is a struct with functions" is a false distinction — a struct can have functions, and ten of them added 0 bytes.
- The real difference is that the data can be made unreachable, so the checking cannot be skipped.
- Measured: the class refused all four invalid constructions and an invalid
widen(), each with a reason. sizeofwas 8 either way — the invariant costs zero bytes.- Measured: every outside route was closed, including taking the address of a private member.
- No default construction is possible when zero-valued members would violate the invariant.
Field g{40,100}compiles and throws — a class with a constructor or private data is not an aggregate (is_aggregate= 0).- That is why Chapter 9.10's structured bindings cannot decompose a private member.
privateis a compile-time barrier only — amemcpybroke the invariant with no diagnostic.- Its value is against accident, which is what actually goes wrong.
- The module's one idea is the invariant; everything else is mechanism.
13. Exercises
Exercise 1 — struct or class? For each, say which and give the invariant if there is one: (a) a 2-D point (b) a register field with offset and width (c) an RGB colour (d) a date (e) a buffer pointer with a length (f) a min and max pair.
Exercise 2 — state the invariant. Write the invariant, in words, for: a date, a fixed-capacity queue, a std::string-like type, and a bit field in a 64-bit register.
Exercise 3 — the boundary. Explain why Field(28, 4) is accepted and Field(30, 4) is refused, and give the general condition.
Exercise 4 — the lifetime clause. A colleague checks the invariant only in the constructor. Give two measured ways from this chapter that the object still ends up invalid.
Exercise 5 — the false distinction. Rebut "a class is just a struct with functions" using two measurements.
Exercise 6 — why no default. Explain why Field h; fails to compile, and say what Chapter 9.8's value-initialization would have produced if it had been allowed.
Exercise 7 — the braces. Explain why Field g{40,100} compiles when f.width_ = 100 does not, and what is_aggregate has to do with it.
Exercise 8 — the limit. Given the memcpy result, state precisely what private does and does not protect against, and say who the real audience for an invariant is.
14. Summary
- An invariant is something that must stay true about an object's data for its whole lifetime.
- Measured: a
structaccepted four nonsense states with 0 diagnostics and allowed a valid object to be broken afterwards. - A
structis right for independent members; an invariant is usually a relationship between members. - "A class is a struct with functions" is false — measured, ten member functions cost 0 bytes.
- The difference is unreachable data, which makes the checking unavoidable.
- Measured: every invalid construction and an invalid mutation refused, with
sizeofidentical at 8 bytes — the promise is free. - Measured: no outside route in, not even taking a private member's address; and no default construction.
- A class with private data is not an aggregate (
is_aggregate= 0), which is why braces call the constructor and structured bindings are refused. privateis a compile-time barrier against accident, not security — amemcpybroke it silently.- The module has one idea — the invariant — and everything else is mechanism.
The argument is made and nothing has been built yet. Section 5's type was presented whole, as evidence, with its constructor and its private section and its accessors all arriving at once — which is the right way to show that the idea works and the wrong way to learn to write one. The next chapter starts from nothing and puts the pieces in place one at a time: how a class is declared, what the semicolon at the end is for, where the definition goes when a type is used by more than one file, and the distinction that causes more early confusion than any other — between the type you write once and the objects of it you create, each with its own members and its own address, which the chapter measures rather than asserts.
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.
