C++ Part I · Module 11 · Classes and Encapsulation
Designing a Class for Engineering Work
Every class in this module was built to isolate one idea: a counter to show that objects are independent, a window to show that per-member setters cannot maintain a relational invariant, a field with two members because two is the smallest number that can relate to each other. Real descriptions do not arrive that way. They arrive as a paragraph of prose from a datasheet or a specification, with units and ranges and exceptions mixed into the sentences, several of the real constraints implied rather than stated, and some facts that are true of today's hardware and will not be true of the next revision. The design work is deciding which of those facts belong in the type as a promise, which belong in the interface as operations, and which are nobody's business but the caller's — and that decision comes before any keyword. This chapter does one such description from end to end, on a register field, and the resulting type was compiled and then deliberately handed every state it is supposed to refuse, so the five refusals in the output are measurements rather than intentions. It also contains one result that justifies the whole exercise better than any argument: a shift that the invariant makes safe, where the one value the invariant permits that the shift cannot handle produced a silently wrong answer with no warning at all.
Intermediate19 min readC++Part IFundamentalsDesign
Part I · Module 11 · Chapter 11.9 · Designing a Class for Engineering Work
1. What This Chapter Is
No new syntax. Everything appeared in Chapters 11.1 to 11.8.
A procedure, then a worked example. The same shape as Chapter 6.9 for function interfaces and Chapter 9.11 for data representation, and it completes that set: Chapter 9.11 chose the representation, and this chapter adds the promise.
And the result is compiled and exercised. The refusals in §6 were produced by running the type, not by intending them.
2. The Procedure
Six steps, and the first three happen before you write any code.
1. Write the description down in prose. Whatever the datasheet or specification says, in sentences.
2. Separate the facts into three piles. This is the actual work:
- Invariant — must always be true, and the type should refuse to exist otherwise.
- Interface — things a caller will want to do.
- Neither — true today, nobody's business, or belonging to a different type.
3. Write the invariant as a boolean expression. Chapter 11.5 §2's test: if you cannot, it is not yet an invariant.
4. Decide mutability. Immutable if possible — Chapter 11.5 §5's first preference.
5. Design the operations, not the accessors. Then ask Chapter 11.5's reachability question: can every valid state be reached from every other?
6. Mark every observer const. Chapter 11.7 §3 measured what happens otherwise: the type becomes unusable in a const& parameter.
3. The Description
From a real register map, as prose:
A register field has a name. It occupies a contiguous run of bits within a 32-bit register, given by an offset and a width. It has an access mode: read-only, read-write, or write-one-to-clear. Reading the field means extracting those bits from a register value; writing it means replacing those bits, which is not permitted for a read-only field. A field's value cannot be wider than the field.
4. Sorting the Facts
Working through it sentence by sentence.
| Fact from the prose | Pile | Why |
|---|---|---|
| has a name | invariant (non-empty) | an unnamed field is not identifiable |
| contiguous run of bits | invariant, implied | offset + width describes exactly one run |
| within a 32-bit register | invariant | offset + width <= 32 |
| offset and width | invariant | offset >= 0, width > 0 |
| access mode is one of three | the type of the member | Chapter 9.9 — make it unrepresentable |
| extract bits | interface | an operation |
| replace bits | interface | an operation |
| not permitted for read-only | interface, checked | a property of the operation |
| value cannot be wider than the field | interface, checked | a property of the argument, not of the field |
Three decisions worth drawing out, because they are the ones that are easy to get wrong.
The access mode is not an invariant — it is a type. Chapter 9.9 measured enum class refusing three operations that a plain enum permits silently, which makes an invalid mode unrepresentable rather than merely rejected. Chapter 11.5 §2 said to prefer that whenever it is available. So no check is needed for it at all.
"Not permitted for a read-only field" belongs to the operation, not to the object. A read-only field is perfectly valid; what is invalid is writing it. That distinction decides where the check goes — in insert, not in the constructor.
And "the value cannot be wider than the field" is about an argument. It is not a statement about the field's state, so it is not part of the invariant; it is a precondition of insert.
The invariant, as an expression:
!name.empty() && offset >= 0 && width > 0 && offset + width <= 32Mutability: nothing in the description suggests a field's position changes once known. So it is immutable — a validating constructor and const observers, and no mutators at all. Chapter 11.5 §5's strongest option, and it makes the reachability question of step 5 vacuous.
5. The Type
enum class Access : std::uint8_t { ReadOnly, ReadWrite, WriteOneToClear };
class RegisterField {
public:
static constexpr int kRegisterWidth = 32; // 11.8, C++17: no definition needed
RegisterField(std::string name, int offset, int width, Access access)
: name_(std::move(name)), offset_(offset), width_(width), access_(access)
{
if (name_.empty()) throw std::invalid_argument("name empty");
if (offset_ < 0) throw std::invalid_argument("offset negative");
if (width_ <= 0) throw std::invalid_argument("width not positive");
if (offset_ + width_ > kRegisterWidth) throw std::invalid_argument("field exceeds register");
}
// 11.7: every observer is const
const std::string& name() const { return name_; }
int offset() const { return offset_; }
int width() const { return width_; }
Access access() const { return access_; }
int last() const { return offset_ + width_ - 1; }
bool writable() const { return access_ != Access::ReadOnly; }
std::uint32_t mask() const
{
// width_ is in [1, 32] by the invariant, so this is defined — see §7
return (width_ == 32) ? 0xFFFFFFFFu
: ((1u << width_) - 1u) << offset_;
}
std::uint32_t extract(std::uint32_t reg) const { return (reg & mask()) >> offset_; }
std::uint32_t insert(std::uint32_t reg, std::uint32_t value) const
{
if (!writable()) throw std::logic_error("field is read-only");
if (value > (mask() >> offset_)) throw std::invalid_argument("value too wide for field");
return (reg & ~mask()) | ((value << offset_) & mask());
}
private:
std::string name_;
int offset_;
int width_;
Access access_;
};Each decision traced to its chapter:
class, data private — Chapter 11.4 §4's convention, since the type owns an invariant.
A validating constructor, no default — Chapter 11.5 §3 and §6; there is no valid default register field, and Chapter 11.1 §6 measured the type correctly refusing one.
enum class Access : std::uint8_t — Chapter 9.9 §7 for the explicit underlying type.
static constexpr kRegisterWidth — Chapter 11.8 §6: a constant describing the type, needing no out-of-class definition in C++17.
Every observer const — Chapter 11.7 §3, without which no caller could hold it by const&.
No mutators — Chapter 11.5 §5's first preference.
And mask(), extract(), insert() are members because they need the private data and are what the type is for — Chapter 11.3 §7's test. last() could be a free function written against offset() and width(); it is a member because it is fundamental to the concept, which is the fourth of Chapter 11.3 §7's tests.
(The : name_(std::move(name)), ... form is a member initializer list, Chapter 12.3's subject, and std::move is Chapter 13.9's. Both are used here because the alternative — assigning in the body — is worse, and both are named rather than explained.)
6. It Works, and It Refuses
Measured, 0 warnings:
ENABLE offset 0 width 1 last 0 mask 0x00000001 writable yes
MODE offset 4 width 3 last 6 mask 0x00000070 writable yes
STATUS offset 16 width 8 last 23 mask 0x00FF0000 writable no
after ENABLE=1, MODE=5: reg = 0x00000051
extract ENABLE = 1 extract MODE = 5
things the type refuses:
RegisterField("",0,1,RW) REJECTED: name empty
RegisterField("X",30,8,RW) REJECTED: field exceeds register
RegisterField("X",0,0,RW) REJECTED: width not positive
stat.insert(reg, 1) [RO] REJECTED: field is read-only
mode.insert(reg, 9) [3 bits] REJECTED: value too wide for field
sizeof(RegisterField) = 40
every observer callable on a const ref: ENABLE offset 0 mask 0x00000001The masks are right, and worth checking by hand rather than trusting: MODE is 3 bits at offset 4, so 0b111 << 4 = 0x70. ✓ STATUS is 8 bits at offset 16, so 0xFF << 16 = 0x00FF0000. ✓
The composed value is right. 0x51 is 0b0101_0001: bit 0 is 1, which is ENABLE; bits 6 to 4 are 0b101 = 5, which is MODE. ✓ And extract recovered both.
Five refusals, each naming its reason — three at construction, two at the operation. The last two are the §4 decisions paying off: read-only-ness and argument width are checked where they belong.
sizeof is 40 — mostly the std::string (24 bytes, measured in Chapter 9.5), plus two ints, the one-byte Access, and padding per Chapter 9.8.
And every observer is callable through a const RegisterField&, which Chapter 11.7 §3 measured as the difference between a usable type and an unusable one.
7. The Invariant Earns Its Keep Twice
Validation is the obvious benefit. Here is the less obvious one, and it is the best argument in the chapter.
mask() shifts by width_. A shift by the full width of the type is undefined behaviour — Chapter 4.8 measured this. Re-measured here with a value the compiler cannot fold:
1u << 32 produced 0x00000001No warning, and the answer is wrong — it produced 1 rather than anything sensible. Under UBSan:
runtime error: shift exponent 32 is too large for 32-bit type 'unsigned int'So mask() is only safe because width_ is known to be in [1, 32], and the one permitted value the shift cannot handle — exactly 32 — is why the branch is there:
width 32 mask = 0xFFFFFFFF (0 diagnostics)Two things follow.
The invariant is what makes the implementation correct, not just the inputs. Without width > 0 && offset + width <= 32, mask() would have to defend itself on every call, or be wrong. Chapter 11.5 §7's "valid whenever control leaves the type's code" is the same idea seen from inside: a member function may assume the invariant holds, which is what makes the code short.
And the boundary case still needs thought. The invariant permits width == 32, and the obvious expression breaks on exactly that value. An invariant narrows what you must handle; it does not excuse you from checking the edges of what it allows. This is the Chapter 4.8 material arriving as a design constraint rather than as an operator lesson.
8. What the Type Does Not Yet Say
Being honest about what is deferred, because a finished-looking type with an unanswered question is how bugs arrive.
Copying. RegisterField is copyable by default and that is correct here, because it owns no resource — the std::string member handles its own copying, per Chapter 9.5. Chapter 10.9's ownership question has a clean answer: the field owns its name and nothing else. A type that held a raw pointer would be entirely different, and Chapter 13.4 — Shallow Copy, Deep Copy and the Double Free That Follows — is that case.
Comparison and printing. Two fields cannot yet be compared or streamed. Chapter 15.4 and Chapter 15.5 are the operators, and Chapter 9.8 §9's warning applies: compare member by member, never with memcmp, because padding bytes are not part of the value — measured there as "all four members equal? true, memcmp says equal? false".
A register containing fields. The natural next type, holding a run of RegisterField objects with an invariant of its own: no two fields overlap. That needs Chapter 19.2's std::vector, and it is where Chapter 27.7's register modelling begins.
And the three alternatives not chosen, named so the choice is visible: bit-fields, which Chapter 9.8 §9 deferred and which describe layout rather than a field's identity; std::optional for an absent field, Chapter 9.11 §7; and raw shifts and masks at every call site, which is what this type exists to replace.
9. The Other Two Types
The frozen description of this chapter names three examples. The procedure applied briefly to the other two.
A transaction — an address, data, a direction, a length.
- Invariant: the length matches the data actually present; the address is aligned to the length if the protocol requires it; the direction is one of a fixed set (an
enum class, again not a check). - Mutability: immutable. A transaction is a record of something; mutating one after creation is almost always a modelling error — if a field must change, that is a new transaction.
- Interface: observers, plus whatever formats or compares it. The data is likely a
std::vector(Chapter 19.2), so Chapter 10.9's ownership question is answered by the member. - The trap: adding a
setDatabecause one test needs to tweak a byte. That reintroduces Chapter 11.5 §4's problem, since length and data must agree.
A buffer of samples — a run of values with a capacity.
- Invariant:
size <= capacity, and the storage is valid whenever capacity is non-zero. - Mutability: necessarily mutable — this one has state that changes.
- Interface:
push,clear,size,capacity, and element access with bothconstand non-constoverloads per Chapter 11.7 §5. NotsetSize, which is Chapter 11.5 §4 exactly. - The real difference: this type may own a resource, which makes it the one case of the three where Module 10's whole argument applies. Chapter 10.9's question — who releases this, and when — must be answered in the type, and Chapter 10.8 measured what happens when the release depends on reaching a statement. In practice the answer is to hold a
std::vectorand inherit its correctness, which is Chapter 13.11's Rule of Zero.
That last point is the useful generalisation: the first two types are value-like and the third is resource-like, and resource-like types need Modules 12 to 14 before they can be written safely.
10. What to Actually Do
Write the description in prose first, then sort the facts into invariant, interface, and neither.
Trace every clause of the invariant to a sentence. If you cannot, delete it.
Write the invariant as one boolean expression, in a comment above the private data.
Move constraints into member types where you can — enum class beats a check.
Default to immutable. Add mutation only where the description requires it.
Expose operations, not members, and ask Chapter 11.5's reachability question when there are mutators.
Mark every observer const, and provide both overloads for element access.
Use static constexpr for constants describing the type.
Let a member function assume the invariant — that is what makes it short — but check the edges of what the invariant permits.
Exercise every refusal in a test.
And decide whether the type is value-like or resource-like. The second kind needs Modules 12 to 14.
11. Mistakes Worth Making Once
Writing the class before sorting the facts. You get members, not an invariant.
Including a constraint you cannot trace to a sentence. Over-design; the call sites will fight it.
No operation that can fail. Then there is no invariant — use a struct.
Putting an argument's precondition in the invariant. Value width is about the parameter, not the field.
Checking a constraint that a type could make unrepresentable. Use enum class.
Adding a mutator for one test's convenience. Chapter 11.5 §4's deadlock, reintroduced.
Assuming the invariant covers the edges. width == 32 is permitted and 1u << 32 produced 1 with no warning.
Comparing with memcmp. Chapter 9.8 §9 measured padding making identical objects differ.
Shipping an invariant that was never handed an invalid value.
Treating a resource-owning type as a value type. Chapter 13.4's double free.
12. Interview Relevance
"Design a class for a register field." A very common exercise. What is being assessed is the order of your reasoning: the strong answer states the invariant before writing any code, explains why the access mode is an enum class rather than a validated int, makes the type immutable, and marks the observers const. Producing a getter-and-setter class is the weak answer even if it compiles.
"How do you decide what the invariant is?" Trace each candidate to a sentence in the requirement. If it has no sentence, it is over-design; if a sentence has no clause, you have missed one.
"Why is the access mode an enum class rather than an int you validate?" Because it makes an invalid mode unrepresentable instead of merely rejected — no check to forget. The measured three refusals from Chapter 9.9 are the supporting detail.
"Where would you check that a value fits the field?" In the write operation, because it is a precondition of the argument rather than a property of the object. Candidates who put it in the constructor have not separated the two.
"What would change if the type owned a buffer?" Everything about copying — it becomes Module 13's problem, and the practical answer is to hold a std::vector and keep the Rule of Zero.
13. What You Should Be Able to Say Now
- Sort the description's facts into invariant, interface, and neither — before writing code.
- Every clause of the invariant must trace to a sentence in the description.
- Prefer narrowing a member's type to checking it —
enum class Accessmeans no invalid mode exists. - A precondition on an argument is not an invariant — value width belongs in
insert. - Default to immutable: a validating constructor and
constobservers, no mutators. - Measured: the type refused five invalid situations — three at construction, two at the operation, each naming its reason.
- Measured: masks
0x00000001,0x00000070,0x00FF0000, and composedreg = 0x00000051— all verified by hand. sizeof40, mostly thestd::string's 24 bytes plus padding.- Every observer is
const, so the type is usable through aconst&(Chapter 11.7 §3). static constexpr kRegisterWidthneeds no out-of-class definition in C++17 (Chapter 11.8 §6).- A member function may assume the invariant — which is what lets
mask()shift safely. - Measured:
1u << 32produced 0x00000001 with no warning, and UBSan called itshift exponent 32 is too large— so thewidth == 32branch is required. - An invariant narrows what you must handle but does not excuse the edges of what it permits.
- Copyability is correct here because the type owns no resource; a resource-owning type is Module 13's problem.
- Value-like types can be written now; resource-like types need Modules 12 to 14.
14. Exercises
Exercise 1 — sort the facts. From the §3 description, reproduce the three-pile table from memory and justify the three entries that are not invariants.
Exercise 2 — a date. Write the prose description, sort the facts, and give the invariant as one expression. Say whether it is mutable.
Exercise 3 — the edge. Explain why mask() needs the width_ == 32 branch even though the invariant permits that width, citing the measurement.
Exercise 4 — where does the check go? For a RegisterField, say where each belongs and why: (a) offset is non-negative (b) the register is 32 bits (c) a write value fits the field (d) the field is writable (e) the name is non-empty.
Exercise 5 — over-design. A colleague adds "width must be a power of two" to the invariant. Say how you would decide whether it belongs, and what the cost is if it does not.
Exercise 6 — a FIFO. Design a fixed-capacity sample buffer: invariant, mutability, operations. Say which element-access overloads you provide and why both.
Exercise 7 — the transaction trap. Explain why setData on a transaction is dangerous, referring to Chapter 11.5 §4.
Exercise 8 — value or resource? Classify each and say which modules you need: a register field, a transaction holding a std::vector, a buffer holding a raw new[] pointer, a parsed command line.
15. Summary
- Sort the facts into invariant, interface and neither, and trace every invariant clause to a sentence.
- Prefer narrowing a type to adding a check —
enum classmakes an invalid access mode unrepresentable. - A precondition on an argument is not an invariant.
- Default to immutable: validating constructor,
constobservers, no mutators. - Measured: five refusals, three at construction and two at the operation; masks and the composed
0x51verified by hand;sizeof40. - Every observer
const, or the type is unusable byconst&. - A member function may assume the invariant — which is what makes
mask()short and correct. - Measured:
1u << 32gave 1 with no warning, so the permittedwidth == 32needs its own branch. - An invariant narrows what you handle; it does not cover the edges it permits.
- Copyability is right for a type owning no resource; resource-like types need Modules 12 to 14.
- The module in one line: find the sentences, turn them into one expression, refuse violations, expose operations.
The type in §5 is as good as this curriculum can currently make it, and it has a conspicuous piece of scaffolding in it. The constructor does the work that matters — it refuses four invalid descriptions — and it was introduced as an instrument in Chapter 11.1 and used without explanation in every chapter since, including the member initializer list in §5 that was named and waved past. That is a substantial debt, because construction is where an invariant is established and therefore where most of a class's correctness is decided. The next module is constructors properly: what construction actually does and in what order, why initializing a member in the body is not the same as initializing it in the list, what happens when you write no constructor at all and what the compiler writes for you, and — the chapter this whole module has been building towards — tying a resource to an object's lifetime, which is the answer Chapter 10.9 named and could not yet deliver.
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.
