Skip to content
VLSI Mentor

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 prosePileWhy
has a nameinvariant (non-empty)an unnamed field is not identifiable
contiguous run of bitsinvariant, impliedoffset + width describes exactly one run
within a 32-bit registerinvariantoffset + width <= 32
offset and widthinvariantoffset >= 0, width > 0
access mode is one of threethe type of the memberChapter 9.9 — make it unrepresentable
extract bitsinterfacean operation
replace bitsinterfacean operation
not permitted for read-onlyinterface, checkeda property of the operation
value cannot be wider than the fieldinterface, checkeda 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    !name.empty() && offset >= 0 && width > 0 && offset + width <= 32

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

Azvya Education Pvt. Ltd.VLSI Mentor
design.cpp — 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 0x00000001

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
1u << 32 produced 0x00000001

No warning, and the answer is wrong — it produced 1 rather than anything sensible. Under UBSan:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 setData because 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 both const and non-const overloads per Chapter 11.7 §5. Not setSize, 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::vector and 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

  1. Sort the description's facts into invariant, interface, and neither — before writing code.
  2. Every clause of the invariant must trace to a sentence in the description.
  3. Prefer narrowing a member's type to checking it — enum class Access means no invalid mode exists.
  4. A precondition on an argument is not an invariant — value width belongs in insert.
  5. Default to immutable: a validating constructor and const observers, no mutators.
  6. Measured: the type refused five invalid situations — three at construction, two at the operation, each naming its reason.
  7. Measured: masks 0x00000001, 0x00000070, 0x00FF0000, and composed reg = 0x00000051 — all verified by hand.
  8. sizeof 40, mostly the std::string's 24 bytes plus padding.
  9. Every observer is const, so the type is usable through a const& (Chapter 11.7 §3).
  10. static constexpr kRegisterWidth needs no out-of-class definition in C++17 (Chapter 11.8 §6).
  11. A member function may assume the invariant — which is what lets mask() shift safely.
  12. Measured: 1u << 32 produced 0x00000001 with no warning, and UBSan called it shift exponent 32 is too large — so the width == 32 branch is required.
  13. An invariant narrows what you must handle but does not excuse the edges of what it permits.
  14. Copyability is correct here because the type owns no resource; a resource-owning type is Module 13's problem.
  15. 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 class makes an invalid access mode unrepresentable.
  • A precondition on an argument is not an invariant.
  • Default to immutable: validating constructor, const observers, no mutators.
  • Measured: five refusals, three at construction and two at the operation; masks and the composed 0x51 verified by hand; sizeof 40.
  • Every observer const, or the type is unusable by const&.
  • A member function may assume the invariant — which is what makes mask() short and correct.
  • Measured: 1u << 32 gave 1 with no warning, so the permitted width == 32 needs 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.