Inheritance and Polymorphism
Inheritance lets several classes share one set of code, and virtual functions let one piece of code drive many kinds of hardware. Both are useful in firmware, and one of them has a cost. This volume shows that cost in the machine code, builds a driver interface with it, and ends with CRTP, which gets the same sharing with no cost at all.
- How a derived class builds on a base class, and the order their parts are made in
- What virtual does, what the vtable and the hidden pointer cost, and how override protects you
- How to write an interface for a driver, and swap the hardware for a test port
- When a virtual call is wasted, and how final lets the compiler remove it
- CRTP: polymorphism decided while compiling, with no vtable
- Volumes 02 and 03 of this course: classes, constructors and destructors.
4.1 Inheritance
Inheritance builds a new class from an existing one. The new class gets all of the old one's members and adds its own, so code that every sensor needs is written once.
Every sensor in a system has a few things in common: a name, and a count of how many readings it has taken. In C you would copy those fields into each sensor's struct, or keep a common struct as the first member. In C++, a base class holds what they share, and each derived class adds its own part.
// inherit.cpp - what every sensor shares goes in a base class
#include <cstdint>
#include <cstdio>
class Sensor {
public:
explicit Sensor(const char *name) : name_(name) {}
const char *name() const { return name_; }
uint32_t readings() const { return readings_; }
protected:
void count_reading() { readings_ = readings_ + 1; } // for derived classes to call
private:
const char *name_;
uint32_t readings_ = 0;
};
class Thermometer : public Sensor {
public:
Thermometer() : Sensor("thermometer") {}
int16_t read_celsius() {
count_reading();
return 25;
}
};
class Barometer : public Sensor {
public:
Barometer() : Sensor("barometer") {}
uint16_t read_hpa() {
count_reading();
return 1013;
}
};
int main() {
Thermometer t;
Barometer b;
t.read_celsius();
int16_t celsius = t.read_celsius();
uint16_t hpa = b.read_hpa();
std::printf("%s: %d C, readings so far %u\n", t.name(), celsius, static_cast<unsigned>(t.readings()));
std::printf("%s: %u hPa, readings so far %u\n", b.name(), static_cast<unsigned>(hpa),
static_cast<unsigned>(b.readings()));
std::printf("sizes: Sensor %zu, Thermometer %zu, Barometer %zu bytes\n", sizeof(Sensor),
sizeof(Thermometer), sizeof(Barometer));
return 0;
}
thermometer: 25 C, readings so far 2
barometer: 1013 hPa, readings so far 1
sizes: Sensor 16, Thermometer 16, Barometer 16 bytes
The line class Thermometer : public Sensor says "a Thermometer is a Sensor, with more". So a Thermometer
has name() and readings() without writing them again. Its constructor passes the name up to the
Sensor constructor, in its member initialiser list: : Sensor("thermometer").
The word protected is new. A protected member, like count_reading(), may be used
by the class and by classes derived from it, but not by other code. The data itself stays private: even a
derived class must go through count_reading() to change the count.
All three classes are 16 bytes. A derived object is its base part plus its own data, and these two add functions but no data. Figure 4.1 shows what each object holds.
The parts are made in a fixed order: the base part first, then the derived part. Destruction runs the other way, as with the objects in a block in Volume 02:
// order.cpp - the base part is built first and destroyed last
#include <cstdio>
class Sensor {
public:
Sensor() { std::printf("Sensor part: constructed\n"); }
~Sensor() { std::printf("Sensor part: destroyed\n"); }
};
class Thermometer : public Sensor {
public:
Thermometer() { std::printf("Thermometer part: constructed\n"); }
~Thermometer() { std::printf("Thermometer part: destroyed\n"); }
};
int main() {
{
Thermometer t;
std::printf("using the thermometer\n");
}
return 0;
}
Sensor part: constructed
Thermometer part: constructed
using the thermometer
Thermometer part: destroyed
Sensor part: destroyed
A derived class does not get past the base class's private. If Thermometer tries to change readings_
itself, the compiler stops it, just as it would stop any other code:
// private_base.cpp - a derived class reaching for the base class's private data
#include <cstdint>
class Sensor {
public:
uint32_t readings() const { return readings_; }
private:
uint32_t readings_ = 0;
};
class Thermometer : public Sensor {
public:
int read_celsius() {
readings_ = readings_ + 1;
return 25;
}
};
private_base.cpp: In member function 'int Thermometer::read_celsius()':
private_base.cpp:15:9: error: 'uint32_t Sensor::readings_' is private within this context
private_base.cpp:9:14: note: declared private here
Using inheritance where one thing merely has another. A UART driver has a receive buffer; it is not a kind
of buffer. Deriving Uart from RingBuffer would give it push() and pop() for anyone to call. Give it
a buffer member instead. This is called composition, and it is the right choice
far more often than inheritance.
In inherit.cpp, how does Thermometer add to the reading count, when readings_ is private in Sensor?
Show the answer
Answer: C. Private members are closed to derived classes too, as private_base.cpp shows. The protected function count_reading() is the door Sensor leaves open for them, and the output shows the count reaching 2.
4.2 Virtual functions and the vtable cost
A virtual function lets a call through a base-class reference run the version belonging to the object's real class. The price is a hidden pointer in every object, a table of addresses per class, and a call the compiler cannot see through.
The problem virtual solves
Write a function that takes any Sensor, and call read() through it. Without virtual, the compiler
decides which read() to call from the type it can see - Sensor - and never looks at the real object:
// static_binding.cpp - without virtual, a Sensor reference calls Sensor's read()
#include <cstdio>
class Sensor {
public:
int read() const { return 0; } // "no reading"
};
class Thermometer : public Sensor {
public:
int read() const { return 25; }
};
static void report(const Sensor &s) { std::printf("report() sees %d\n", s.read()); }
int main() {
Thermometer t;
std::printf("t.read() gives %d\n", t.read());
report(t);
return 0;
}
t.read() gives 25
report() sees 0
Now mark read() as virtual in the base class, and mark each replacement with
override:
// virtual.cpp - with virtual, the object's real class decides which read() runs
#include <cstdio>
class Sensor {
public:
virtual ~Sensor() = default;
virtual int read() const { return 0; }
};
class Thermometer : public Sensor {
public:
int read() const override { return 25; }
};
class Barometer : public Sensor {
public:
int read() const override { return 1013; }
};
class Plain {
public:
int read() const { return 0; }
};
static void report(const Sensor &s) { std::printf("report() sees %d\n", s.read()); }
int main() {
Thermometer t;
Barometer b;
report(t);
report(b);
Sensor *all[] = {&t, &b}; // one list, two kinds of sensor
int total = 0;
for (Sensor *s : all) {
total += s->read();
}
std::printf("total of all sensors: %d\n", total);
std::printf("a class with no data: %zu byte without virtual, %zu bytes with it\n", sizeof(Plain),
sizeof(Sensor));
return 0;
}
report() sees 25
report() sees 1013
total of all sensors: 1038
a class with no data: 1 byte without virtual, 8 bytes with it
The same report() now reads each sensor properly, and one list can hold both kinds. Choosing the function
while the program runs, from the object's real class, is called
dynamic dispatch. One piece of code serving many types is
polymorphism.
How it works, and what it costs
The last line shows the first cost. A class with no data at all grew from 1 byte to 8, because every object of a class with virtual functions carries a hidden pointer, the vptr. It points at the class's vtable, a table holding the address of each virtual function. The size harness looked inside one:
vtable for Thermometer: 40 bytes
+0 0
+8 0
+16 Thermometer::~Thermometer()
+24 Thermometer::~Thermometer()
+32 Thermometer::read()
the object thermometer is 8 bytes, and holds: vtable for Thermometer+0x10
The first two words are bookkeeping the compiler needs. With RTTI switched off they hold nothing. Then come
the addresses of the virtual functions: two entries for the destructor, which the compiler keeps in two
forms, and one for read(). The object itself holds only the address of entry +16. And here is what a
virtual call compiles to:
read_any(Sensor const&): 6 bytes
mov (%rdi),%rax
jmp *0x10(%rax)
The first instruction loads the hidden pointer out of the object. The second jumps through the entry 16
bytes further on, +32, which is read(). Figure 4.2 follows those two steps.
So a virtual function costs three things, all measured here. There is a hidden pointer in every object: 8 bytes on this PC, 4 on a 32-bit chip. There is a vtable per class, 40 bytes each for these small classes. And there is an indirect call, which the compiler cannot inline. None of that matters for a driver called a few times a second. All of it can matter in a loop that runs a million times, which is what the fourth sub-module is about.
Two safety nets
The keyword override checks that the function really does replace a virtual one. Forget the const, and
the "replacement" would be a different function that nothing calls through the base. With override, the
mistake stops the build:
// override_typo.cpp - override catches a function that does not match
class Sensor {
public:
virtual ~Sensor() = default;
virtual int read() const { return 0; }
};
class Thermometer : public Sensor {
public:
int read() override { return 25; } // const forgotten
};
override_typo.cpp:10:9: error: 'int Thermometer::read()' marked 'override', but does not override
Without override, the course's -Woverloaded-virtual flag still catches it, from the other side. It warns
that the base version has been hidden, which is called name hiding:
// hidden.cpp - the same slip without override, caught by -Woverloaded-virtual
class Sensor {
public:
virtual ~Sensor() = default;
virtual int read() const { return 0; }
};
class Thermometer : public Sensor {
public:
int read() { return 25; } // const forgotten, and no override
};
hidden.cpp:5:17: error: 'virtual int Sensor::read() const' was hidden [-Werror=overloaded-virtual=]
hidden.cpp:10:9: note: by 'int Thermometer::read()'
The second net is the destructor. Every class with virtual functions here has virtual ~Sensor() = default;.
If an object were ever destroyed through a pointer to its base class, a destructor that is not virtual would
run only the base part's clean-up. The course's -Wnon-virtual-dtor flag insists on it:
// no_virtual_dtor.cpp - virtual functions, but a destructor that is not virtual
class Sensor {
public:
virtual int read() const { return 0; }
};
no_virtual_dtor.cpp:2:7: error: 'class Sensor' has virtual functions and accessible non-virtual destructor [-Werror=non-virtual-dtor]
Mark every replacement override, and give every class with virtual functions a virtual destructor. Both
cost nothing, and both turn silent bugs into build errors.
What does the hidden pointer inside an object with virtual functions point at?
Show the answer
Answer: B. The harness showed the object thermometer holding "vtable for Thermometer+0x10". There is one vtable per class, in read-only data, and every Thermometer's hidden pointer points into it. A virtual call reads the pointer, then jumps through the table.
4.3 Interfaces for drivers
An interface is an abstract class with only pure virtual functions and no data. Driver code written against the interface works with any hardware behind it - including a fake one for tests.
A logger needs somewhere to send its bytes. On the board that is a UART. In a test on your PC it should be somewhere a test can look. If the logger is written against an interface, it never needs to know which:
// interface.cpp - a driver interface, two ports behind it, and code that uses either
#include <cstddef>
#include <cstdint>
#include <cstdio>
// The interface: what any serial port can do. It has no data, and no code of its own.
class SerialPort {
public:
virtual ~SerialPort() = default;
virtual void write(uint8_t byte) = 0;
};
// Code that needs a serial port, but does not care which one.
class Logger {
public:
explicit Logger(SerialPort &port) : port_(port) {}
void log(const char *text) {
for (; *text != '\0'; text++) {
port_.write(static_cast<uint8_t>(*text));
}
port_.write('\n');
}
private:
SerialPort &port_;
};
// On a chip this would wait for the UART's transmit register and store the byte there.
// On a PC it prints the byte, so the output can be seen.
class Uart1 : public SerialPort {
public:
void write(uint8_t byte) override { std::putchar(byte); }
};
// A port for tests: it keeps every byte it is given, so a test can check them.
class CapturePort : public SerialPort {
public:
void write(uint8_t byte) override {
if (count_ < sizeof bytes_) {
bytes_[count_] = byte;
count_++;
}
}
size_t count() const { return count_; }
uint8_t byte(size_t i) const { return bytes_[i]; }
private:
uint8_t bytes_[16] = {};
size_t count_ = 0;
};
int main() {
Uart1 uart;
Logger boot_log(uart);
boot_log.log("boot ok");
CapturePort capture;
Logger test_log(capture);
test_log.log("hi");
std::printf("the capture port holds %zu bytes:", capture.count());
for (size_t i = 0; i < capture.count(); i++) {
std::printf(" %02X", static_cast<unsigned>(capture.byte(i)));
}
std::printf("\n");
return 0;
}
boot ok
the capture port holds 3 bytes: 68 69 0A
The = 0 after write makes it a pure virtual function: it has no
body, and every class derived from SerialPort must supply one. A class with a pure virtual function is an
abstract class. No object of it can be made on its own:
// abstract.cpp - an interface cannot be made on its own
#include <cstdint>
class SerialPort {
public:
virtual ~SerialPort() = default;
virtual void write(uint8_t byte) = 0;
};
void setup() {
SerialPort port;
}
abstract.cpp: In function 'void setup()':
abstract.cpp:11:16: error: cannot declare variable 'port' to be of abstract type 'SerialPort'
abstract.cpp:4:7: note: because the following virtual functions are pure within 'SerialPort':
abstract.cpp:7:18: note: 'virtual void SerialPort::write(uint8_t)'
The same check protects you when an interface grows. Add a read() to SerialPort, forget to write it in
one of the ports, and that port cannot be made any more - the compiler lists what is missing:
// missing_function.cpp - a port that forgot one of the interface's functions
#include <cstdint>
class SerialPort {
public:
virtual ~SerialPort() = default;
virtual void write(uint8_t byte) = 0;
virtual bool read(uint8_t &byte) = 0;
};
class Uart2 : public SerialPort {
public:
void write(uint8_t byte) override { (void)byte; }
};
Uart2 uart2;
missing_function.cpp:16:7: error: cannot declare variable 'uart2' to be of abstract type 'Uart2'
missing_function.cpp:11:7: note: because the following virtual functions are pure within 'Uart2':
missing_function.cpp:8:18: note: 'virtual bool SerialPort::read(uint8_t&)'
This is the start of a driver layer. Volume 08 builds a whole one on interfaces like SerialPort, and
Volume 09 uses ports like CapturePort to test firmware on a PC.
An interface is a promise with no work behind it: "anything that is a SerialPort can take a byte". The classes behind it keep the promise, each in its own way, and the code in front of it relies only on the promise.
Why can Logger send bytes to both the UART and the capture port without any change to its code?
Show the answer
Answer: A. Logger only knows the interface. Each call to port_.write() goes through the vtable of the real object, so the UART prints the byte and the capture port stores it. Logger never asks which is which.
4.4 When to avoid virtual
A virtual call is only worth its cost when the choice really is made while the program runs. When the type is known while compiling, a direct call is cheaper, and it lets the compiler do the work in advance.
The harness compiled the same one-line job four ways. Here is each function's machine code:
read_any(Sensor const&): 6 bytes
mov (%rdi),%rax
jmp *0x10(%rax)
read_final(FinalThermometer const&): 6 bytes
mov $0x19,%eax
ret
read_plain(PlainThermometer const&): 6 bytes
mov $0x19,%eax
ret
read_crtp(CrtpThermometer const&): 6 bytes
mov $0x19,%eax
ret
The byte counts are equal, but the work is not. The virtual version is only the first half of the job: it
jumps into the real read(), which then has to run. The other three have already done all of it. The
number 0x19 is 25, so each one simply returns the answer. The compiler could see which read() would run, so it put the
function's body straight into the caller, and then worked out the result.
The second one is worth a closer look, because it still uses a virtual function:
struct FinalThermometer final : Sensor {
int read() const override { return 25; }
};
int read_final(const FinalThermometer &t) { return t.read(); }
The keyword final says no class may be derived from FinalThermometer. So a
FinalThermometer reference can only ever mean a FinalThermometer, and the compiler turned the virtual
call back into a direct one. That is called devirtualisation.
When to use virtual, and when not
| Situation | Choose |
|---|---|
| Several kinds of object in one list, chosen while running | Virtual functions |
| Hardware swapped for a fake in tests, and speed does not matter | An interface with virtual functions |
| Exactly one implementation in each build | A plain class, no virtual |
| Shared code, types all known while compiling, and speed matters | CRTP, in the next sub-module |
| A virtual class you know the exact type of | Mark it final |
Making every function virtual "in case". Each one adds an entry to the vtable and a hidden pointer to every
object, and blocks the compiler from inlining it. In an interrupt handler or a tight loop that is time you
can measure. Add virtual only where a caller really needs to choose while running.
Why did read_final compile to "return 25" when read is a virtual function?
Show the answer
Answer: D. With final, a FinalThermometer reference cannot refer to anything but a FinalThermometer. The compiler could therefore call FinalThermometer::read directly, put its body inline and return 25 - the same machine code as the plain version.
4.5 CRTP: static polymorphism
CRTP shares code through a base class that is told the derived class while compiling. There is no vtable and no hidden pointer, and every call is direct. That is static polymorphism.
The name stands for "curiously recurring template pattern", and the curious part is the first line of each
sensor below: a class inherits from a template of itself. Templates are Volume 05's subject; for now, read
SensorBase<Thermometer> as "a SensorBase that knows it is really part of a Thermometer".
// crtp.cpp - shared code in a base class, with the choice made while compiling
#include <cstdint>
#include <cstdio>
// The base is a template: Derived is filled in by each class that inherits from it.
template <typename Derived>
class SensorBase {
public:
int32_t read_scaled() const {
const Derived &self = static_cast<const Derived &>(*this); // "I am really a Derived"
return self.read_raw() * self.scale();
}
};
class Thermometer : public SensorBase<Thermometer> {
public:
int32_t read_raw() const { return 25; }
int32_t scale() const { return 100; } // hundredths of a degree
};
class Barometer : public SensorBase<Barometer> {
public:
int32_t read_raw() const { return 1013; }
int32_t scale() const { return 10; } // tenths of a hectopascal
};
// Works with any class built on SensorBase - checked while compiling, not while running.
template <typename Derived>
static void report(const char *name, const SensorBase<Derived> &s) {
std::printf("%s: %d\n", name, static_cast<int>(s.read_scaled()));
}
int main() {
Thermometer t;
Barometer b;
report("thermometer, in hundredths of a degree", t);
report("barometer, in tenths of a hectopascal", b);
std::printf("a Thermometer is %zu byte: no hidden vtable pointer\n", sizeof t);
return 0;
}
thermometer, in hundredths of a degree: 2500
barometer, in tenths of a hectopascal: 10130
a Thermometer is 1 byte: no hidden vtable pointer
The shared code, read_scaled(), lives once in the base. When it runs, it turns *this into the derived
class with a static_cast. That is safe here, because a SensorBase<Thermometer> only ever exists as part
of a Thermometer. From then on every call is an ordinary, direct call. The earlier machine code showed the
result: read_crtp compiled to "return 25", the same as the plain class.
What CRTP gives up
| Virtual functions | CRTP | |
|---|---|---|
| Hidden pointer per object | Yes | No |
| vtable per class | Yes | No |
| Calls can be inlined | Only if devirtualised | Yes |
| Mixed types in one list | Yes | No: each SensorBase<X> is a different type |
| Code size | One copy of shared code | One copy of shared code per derived class |
The fourth row is the real trade. With CRTP there is no common base type, so an array of "any sensor" is not possible. Use CRTP when the types are all known while compiling and speed matters; use virtual functions when the choice truly happens while the program runs.
In crtp.cpp, how does SensorBase::read_scaled() reach the derived class's read_raw()?
Show the answer
Answer: C. The base knows Derived from its template argument, so static_cast turns *this into a Derived reference while compiling. The call to read_raw() is then an ordinary direct call, with no vtable and no hidden pointer - a Thermometer is just 1 byte.
What you learned
- A derived class gets its base class's members; its base part is made first and destroyed last.
- Protected members are open to derived classes, private ones are not, and composition often beats inheritance.
- Virtual functions pick the version from the object's real class, through a hidden pointer and a vtable.
- In the measurement, the hidden pointer was 8 bytes, each vtable 40 bytes, and every call indirect.
- Use
overrideand a virtual destructor: the course flags turn both slips into errors. - An interface is an abstract class of pure virtual functions, and it lets tests swap the hardware.
- Use
final, a plain class or CRTP when the type is known while compiling.
Key words from this volume
Every word below has a plain-English entry in the glossary.
- Inheritance
- Base class
- Derived class
- protected
- Composition
- Virtual function
- override
- Dynamic dispatch
- Polymorphism
- vptr
- vtable
- Name hiding
- Interface
- Pure virtual function
- Abstract class
- final
- Devirtualisation
- CRTP
- Static polymorphism
Practice
Add a sensor
Here is an interface with one sensor behind it, and a function that reports any list of sensors. Add a
Hygrometer that reads 40 and gives its unit as "% humidity". What has to change in report_all()?
class Sensor {
public:
virtual ~Sensor() = default;
virtual int read() const = 0;
virtual const char *unit() const = 0;
};
static void report_all(const Sensor *const sensors[], int count) {
for (int i = 0; i < count; i++) {
std::printf("%d %s\n", sensors[i]->read(), sensors[i]->unit());
}
}
Show the solution
class Hygrometer : public Sensor {
public:
int read() const override { return 40; }
const char *unit() const override { return "% humidity"; }
};
Nothing in report_all() changes - that is the point of the interface. The course's test put a thermometer
and a hygrometer in one list:
25 C
40 % humidity
Which function runs?
What does this program print?
// practice_predict.cpp - practice: which function runs?
#include <cstdio>
class Sensor {
public:
virtual ~Sensor() = default;
virtual int read() const { return 1; }
int id() const { return 10; } // not virtual
};
class Thermometer : public Sensor {
public:
int read() const override { return 2; }
int id() const { return 20; } // hides Sensor::id(), but does not override it
};
int main() {
Thermometer t;
const Sensor &s = t;
std::printf("through t: read %d, id %d\n", t.read(), t.id());
std::printf("through s: read %d, id %d\n", s.read(), s.id());
return 0;
}
Show the solution
through t: read 2, id 20
through s: read 2, id 10
The virtual read() follows the real object, so both lines give 2. The plain id() follows the type the
compiler can see: through t it is a Thermometer, giving 20, and through s it is a Sensor, giving 10.
The course flags do not complain here, because -Woverloaded-virtual only watches virtual functions.
Virtual or CRTP?
Choose virtual functions or CRTP for each, and say why.
- A data logger that writes to whichever storage the user picks in a menu: SD card or internal flash.
- A motor-control loop running at 20 kHz, built for one motor driver chip per product.
- A test that swaps the real SPI bus for a fake that records transfers.
Show the solution
- Virtual functions. The choice is made while the program runs, from the user's menu.
- CRTP, or simply a plain class. The type is fixed for each build, and at 20 kHz an indirect call that cannot be inlined is a cost worth avoiding.
- Either works. An interface with virtual functions is simplest, and the indirect call does not matter in a test. CRTP, or passing the bus type as a template parameter, avoids the cost in the real build too.
Interview corner
What does a virtual call cost?
"What is a vtable, and what does a virtual function cost on a microcontroller?"
Show the solution
"Each class with virtual functions gets a vtable: a read-only table of function addresses. Each object gets a hidden pointer to it - 4 bytes on a 32-bit chip. A virtual call loads that pointer, then jumps through the table, so it is indirect and cannot be inlined unless the compiler can prove the type, for example with final. On a PC, I measured a 40-byte vtable for a small class and a two-instruction call sequence. It is fine for drivers, but I avoid it in tight loops and interrupt handlers."
Why a virtual destructor?
"Why should a base class with virtual functions have a virtual destructor?"
Show the solution
"If an object is ever destroyed through a pointer to its base class, a non-virtual destructor would run only the base part's clean-up, and the derived part's resources would leak. That is undefined behaviour. In firmware objects are rarely destroyed that way, but the fix costs nothing, and -Wnon-virtual-dtor enforces it in the build."
What is CRTP?
"What is CRTP, and when would you use it in firmware?"
Show the solution
"The curiously recurring template pattern: a class inherits from a template of itself, like Thermometer deriving from SensorBase of Thermometer. The base can then cast itself to the derived type and call it directly. You get shared code without a vtable or a hidden pointer, and the calls can be inlined. I use it when all the types are known while compiling and speed matters. The price is that there is no common base type, so mixed objects cannot share one list."