C++ Part I · Module 16 · Inheritance
Base and Derived Classes
The previous chapter argued about what a derivation asserts and asked you to take one mechanical fact on trust: that the base part sits inside the derived object rather than alongside it. That fact is worth seeing properly, because four later chapters depend on it. Construction order makes sense only once you know the base part is a region of the same object that has to be built before the rest of it. The conversion to a base reference being free — no copy, no adjustment, no bookkeeping — is a consequence of where the base part sits. And the by-value surprise that Module 17 returns to is a consequence of the same geometry read the other way. So this chapter declares a derivation properly and measures the result: the sizes, the position of every member, and the address relationship that makes the whole thing work, with one picture of a single object holding two parts. Then the half of the chapter that sets up the two chapters after it. A derived class can reach most of what its base has, but not all of it, and the boundary is not where people expect: the member a derived class cannot touch is private, the member it can is protected, and the difference between those two words turns out to be a design decision about who you are willing to let break your invariant rather than a detail about syntax.
Intermediate17 min readC++Part IFundamentalsInheritance
Part I · Module 16 · Chapter 16.2 · Base and Derived Classes
1. Declaring a Derivation
struct Transaction {
unsigned addr = 0;
unsigned data = 0;
};
struct WriteTransaction : Transaction {
unsigned mask = 0xF;
};: Transaction is the whole syntax. Three things about it worth stating before the measurements.
The access specifier is implicit here. struct D : B means struct D : public B; for a class the default is private. Chapter 16.3 §9 explains why that asymmetry exists and why you should always write public explicitly — this chapter writes struct for brevity in listings and means public throughout.
The base must be a complete type. A forward declaration is not enough, because the compiler has to know how big the base part is to lay out the derived object.
And a class may have several bases. Multiple inheritance is legal, rarely the right answer outside interface classes, and not this module's subject.
2. What the Object Becomes
A. sizes:
sizeof(Transaction) = 8
sizeof(WriteTransaction) = 12Eight bytes of base plus four of mask. Chapter 9.8 §5 established that sizeof is not always the sum of the members' sizes — padding can make it larger — and here the arithmetic happens to be exact because everything is 4-byte aligned.
The important measurement is not the size but the position:
C. member offsets, by pointer arithmetic (no offsetof):
addr @ 0 data @ 4 mask @ 8The base's members come first, at the offsets they would have in a standalone Transaction, and the derived member follows. Which gives the address relationship:
B. the base subobject shares the derived object's address:
&w == base subobject ? 1The same address. A WriteTransaction and its Transaction part begin at the same byte.
3. One Object, Two Parts
The word to resist is "two objects". There is one object of 12 bytes. The first 8 of those bytes are simultaneously the WriteTransaction's addr and data and a complete, usable Transaction — not a copy of one, not a view of one, but the thing itself, occupying storage that the derived object also occupies.
That is why §4's conversion costs nothing, and it is the picture to hold for Chapter 16.5's construction order: building a WriteTransaction means building the Transaction part first, in place, and then the rest.
4. The Conversion Is Free
static void observes(const Transaction& t) { std::printf(" observed addr=0x%X\n", t.addr); }
observes(w);D. a derived object is usable where a base is expected:
observed addr=0x40And from Chapter 16.1 §5:
&r == &w ? 1 (no copy; same object)A Transaction& bound to a WriteTransaction holds the same address. The conversion is not a conversion in any runtime sense — no copy, no arithmetic, no stored offset. The compiler changes the type of the expression and the pointer value is unchanged, which is a compile-time operation costing zero instructions.
This is worth knowing precisely because it is the thing people assume is expensive. Chapter 15.1 §4 and Chapter 14.2 §5 both measured abstractions costing nothing; this is another. Passing a derived object to a base-reference parameter is as cheap as passing it to its own type.
(That the base is at offset 0 is the common case rather than a guarantee. With multiple inheritance a second base cannot also be at offset 0, so the conversion to it does adjust the pointer — still a compile-time constant, still trivial, but not a no-op. Single public inheritance, which is what this module is about, gives the offset-0 case.)
5. Taken by Value, Only the Base Part Goes
The symmetric fact, from Chapter 16.1 §5:
B. by value -- only the base part is copied:
Transaction copy = w; sizeof(copy)=8 addr=0x40The §3 picture explains it exactly. A Transaction object is 8 bytes; there is no field in one that could hold a mask. Copy-initialising a Transaction from a WriteTransaction copies the base part and stops, because that is all a Transaction has room for.
So: by reference or pointer, the whole object travels; by value, a base object is constructed from the base part. Both are correct; the second surprises people, and Chapter 17.6 is the chapter on why it becomes dangerous once virtual functions exist. For now the rule from Chapter 6.5 suffices: take parameters of class type by const&.
6. A Base Pointer Does Not Index a Derived Array
One consequence of §3's picture is worth measuring because it is silent, and because it is the only way the free conversion of §4 can hurt you.
WriteTransaction a[3];
a[0].addr = 0x10; a[1].addr = 0x20; a[2].addr = 0x30;
Transaction* p = a; // legal: array decay, then derived-to-base
for (int i = 0; i < 3; ++i) std::printf(" p[%d].addr = 0x%X\n", i, p[i].addr);A. through the correct type:
a[0].addr = 0x10
a[1].addr = 0x20
a[2].addr = 0x30
B. through a BASE pointer -- strides by sizeof(Transaction)=8, not 12:
p[0].addr = 0x10
p[1].addr = 0xF
p[2].addr = 0x0
C. the byte offsets make it obvious:
&a[1] - &a[0] = 12 | &p[1] - (char*)&a[0] = 8
&a[2] - &a[0] = 24 | &p[2] - (char*)&a[0] = 16p[1].addr read 0xF, which is not an address at all — it is a[0].mask. The array's elements are 12 bytes apart; p[i] steps 8 bytes at a time, because pointer arithmetic strides by the pointer's type. After the first element, p[i] names bytes that straddle two array elements.
Chapter 8.4 §2 measured that pointer arithmetic scales by the pointed-to type, and Chapter 16.1 §4's conversion is what lets the two types disagree here. Both facts are correct and their combination is not.
The reason this one deserves a section is the diagnostics:
-Wall -Wextra -Wpedantic : 0 warnings
-Weverything : 11 warnings -- and I read them: C++98-compat,
old-style-cast, include paths. None about the stride.
AddressSanitizer : nothingASan is silent for a principled reason: the reads are not out of bounds. p[2] lands at byte 16 of a 36-byte array — inside the storage, just not at the start of an element. It is the wrong bytes, correctly addressed, which is exactly the category no bounds checker can represent.
So the rule is narrow and absolute: never index through a base pointer into an array of derived objects. If you need a sequence of objects handled polymorphically, hold pointers — std::vector<Transaction*> or, per Chapter 14.1 §9, std::vector<std::unique_ptr<Transaction>> — where every element is the same size whatever it points at.
7. Which Base Members a Derived Class Can Touch
Three access levels in the base, and a derived member function trying each:
struct Base {
public: int pub = 1;
protected: int prot = 2;
private: int priv = 3;
};
struct Derived : Base {
int sumAll() { return pub + prot + priv; }
};error: 'priv' is a private member of 'Base'
note: declared private herepub and prot are fine; priv is not. A derived class is not a friend of its base — it gets the public and protected interface and nothing more. The base's private members are in the object (they are part of the 8 bytes) but not reachable from derived code.
And protected is not public. From outside the hierarchy:
error: 'prot' is a protected member of 'Base'
note: declared protected hereSo the access table for a public base:
| In the base | Public code | Derived member functions |
|---|---|---|
public | yes | yes |
protected | no | yes |
private | no | no |
protected is exactly the row that exists for inheritance — accessible to derived classes and to nobody else. Chapter 11.4 introduced the specifiers; what protected is actually for, and why a protected data member is usually a mistake, is Chapter 16.4's subject, and §7's refusal is the measurement that sets the question up.
8. Reaching the Base Part Explicitly
Three ways, in order of how often you want them.
Just use the member. w.addr works; the name is found in the base and the compiler knows where the base part is.
Qualify it when a name is ambiguous or hidden:
E. and the base's members are reachable directly:
w.addr=0x40 w.Transaction::addr=0x40w.Transaction::addr names the base's member explicitly. This matters when the derived class declares something with the same name, which Chapter 16.6 measures — and inside a derived member function the form is Transaction::addr.
And convert to the base explicitly when you need the base's behaviour:
static_cast<Transaction&>(w) // a Transaction& referring to the base partNeeding this third form is usually a signal. Chapter 16.6 §7 shows the one legitimate use — calling a base version of a function the derived class has redeclared — and Chapter 16.8 §4 treats a scattering of such casts as a symptom.
9. What Deriving Costs the Layout
E. standard layout -- and what inheritance costs it:
is_standard_layout<Transaction> = 1
is_standard_layout<DerivedNoData> = 1 (base has data, derived adds NONE)
is_standard_layout<WriteTransaction> = 0 (data in BOTH -> not standard layout)Data members in both the base and the derived part cost you standard layout. The middle row is the exact boundary — deriving is not what does it; adding a data member to a derived class whose base already has one is.
Three consequences, all of which matter for work at a C boundary:
offsetof becomes conditionally-supported. I met this as a diagnostic rather than a prediction — my first attempt at measuring §2's offsets used offsetof and drew three -Winvalid-offsetof warnings, which is how the fact surfaced. The offsets in §2 came from pointer arithmetic instead.
Byte-wise copying across a boundary stops being guaranteed, which Chapter 9.8 §9 measured for a plain struct's padding — same concern, inheritance clause.
And a type meant to mirror a C struct no longer reliably does. For DPI and firmware work this is the practical reason to keep wire-format and register-map types as plain structs and put the behaviour somewhere else.
10. What to Actually Do
Write public explicitly — class D : public B, never relying on the default.
Think of one object with two parts, not two linked objects.
Pass by const& or pointer when the parameter is a base type.
Expect the base-reference conversion to be free — measured, the same address.
Use the base's public and protected members; do not expect its private ones.
Qualify with Base:: only when a name is hidden, and treat frequent static_cast to a base as a smell.
And keep wire-format and register-map types as plain structs if anything depends on their memory image.
11. Mistakes Worth Making Once
Picturing the base as a separate, linked object. It predicts an indirection, a copy, and a swappable base — all wrong.
Relying on class D : B's default. That is private inheritance, which means something quite different.
Expecting a derived class to reach the base's private members. Measured: 'priv' is a private member of 'Base'.
Thinking protected is nearly public. Measured: refused from outside the hierarchy.
Assuming the base-reference conversion copies something. Measured: the same address.
Taking a base parameter by value. Only the base part arrives — 8 bytes of a 12-byte object.
Using offsetof on a derived class with data in both parts. Not standard-layout; it warns.
memcpying such a type across a C boundary. The layout is no longer guaranteed.
Assuming the base is always at offset 0. True for single inheritance, not in general.
12. Interview Relevance
"What does a derived object contain?" A complete base subobject plus the derived members — one object, measured 8 bytes becoming 12, with the base part at offset 0.
"What does converting a derived reference to a base reference cost?" Nothing. Measured as the same address; it is a compile-time type change. The strong answer notes this holds for single inheritance and that a second base must be adjusted.
"Can a derived class access its base's private members?" No. It gets public and protected only — a derived class is not a friend of its base.
"So what is protected for?" Exactly this row: derived classes yes, everyone else no.
"What happens if you pass a derived object by value as a base?" A base object is constructed from the base part; the derived data has nowhere to go.
"Does inheritance change the layout guarantees?" Yes — data in both parts makes the type non-standard-layout, so offsetof and byte-wise copying stop being guaranteed. Rarely known, and directly relevant to DPI work.
13. What You Should Be Able to Say Now
: Baseis the whole syntax, andstructdefaults topublicwhileclassdefaults toprivate— so write it explicitly.- The base must be a complete type, because its size determines the derived layout.
- Measured:
sizeof8 → 12, withaddr @ 0, data @ 4, mask @ 8. - Measured: the base subobject shares the derived object's address.
- One object with two parts, not two linked objects.
- Measured: binding a
const Transaction&to aWriteTransactiongave the same address — the conversion is free. - That holds for single inheritance; a second base cannot also be at offset 0.
- Measured: by value, only the base part arrives —
sizeof(copy)=8. - Measured: a derived class can touch
publicandprotectedbase members;privis a private member of 'Base'`. - Measured:
protectedis refused from outside the hierarchy. - A derived class is not a friend of its base.
- Private base members are present in your object's bytes and not nameable from your code — presence and access are different questions.
w.Transaction::addrnames a base member explicitly, which matters when a name is hidden.- Measured: data in both parts gives
is_standard_layout = 0; adding no data members keeps it 1. - The standard-layout fact surfaced as three
-Winvalid-offsetofwarnings — I re-measured the offsets with pointer arithmetic.
14. Exercises
Exercise 1 — declare it. Write a Packet with two unsigned members and a TaggedPacket adding one, with the access specifier written explicitly.
Exercise 2 — predict the layout. Give both sizeof values and all three member offsets, then say what the address relationship is.
Exercise 3 — the picture. Explain in two sentences why the base-reference conversion costs nothing, referring to the layout.
Exercise 4 — by value. Say what Packet p = tagged; produces and why the tag has nowhere to go.
Exercise 5 — the access table. Fill in the three rows for public code and derived member functions, and give the diagnostic for the refused cases.
Exercise 6 — presence versus access. A base has a private int. Say whether it occupies space in a derived object and whether derived code can name it.
Exercise 7 — qualified access. Say when d.Base::value is necessary, and name the chapter that measures the situation.
Exercise 8 — layout cost. Classify three types as standard-layout or not, and explain what breaks for the one that is not.
Exercise 9 — the two-object model. Give three concrete predictions the "two linked objects" model makes, and say why each is wrong.
15. Summary
: Basedeclares a derivation; writepublicexplicitly, and the base must be complete.- Measured:
sizeof8 → 12, offsets 0, 4, 8, and the base subobject at the derived object's own address. - One object with two parts — the model that correctly predicts no indirection, no copy, and no swappable base.
- Measured: the base-reference conversion is free, the same address, a compile-time type change.
- Measured: by value only the base part travels —
sizeof(copy)=8. - Measured: derived code reaches
publicandprotected, notprivate— a derived class is not a friend of its base. - Private base members are present but not nameable — presence and access are separate questions.
- Measured: data in both parts costs standard layout, which surfaced as
-Winvalid-offsetofwarnings and matters at any C boundary.
The mechanism is now visible, and that makes the central question of the module answerable rather than abstract. Chapter 16.1 stated that public inheritance asserts substitutability, but stating a claim is not the same as having a way to check one — and the check is what decides every design in this module. The next chapter turns the claim into a test you can apply before writing : Base, and then applies it to the hierarchy that every textbook uses as the cautionary example, measuring what actually goes wrong. The measurement is more interesting than the usual telling: run twice, once with plain member functions and once with virtual ones, the same two classes fail in two different ways — one run breaks the derived class's own invariant while keeping the base's contract, and the other keeps the invariant and breaks the contract. Neither run is correct, which is the point: the problem was in the claim, and no mechanism fixes a false claim.
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.
