What an interface is
In C++ there is no interface keyword. An interface is a convention: a class with
only pure virtual methods and a virtual destructor. No data members, no
implementation.
1struct ISensor {
2 virtual float read() = 0;
3 virtual bool ready() = 0;
4 virtual ~ISensor() = default;
5};
That’s it. Any class that inherits from ISensor and implements both methods is
a sensor, from the perspective of any code that depends on ISensor.
The purpose is to decouple the what from the how. Code that depends on ISensor
doesn’t know — and shouldn’t care — whether it’s talking to a BMP280 over I2C, an
NTC thermistor on ADC channel 3, or a sine wave generator in a unit test.
Designing contracts that hold
An interface is a contract between two sides of a codebase. A weak contract is useless; a lying contract is dangerous. Good interface design means asking:
What does every implementation guarantee?
1// Weak contract: leaves too much unspecified
2struct IStorage {
3 virtual void write(const void* data, size_t len) = 0;
4};
5// Does write() block? Return on error? Throw? Silently drop bytes?
6// The caller has to guess or read the implementation.
7
8// Stronger contract: document guarantees in the interface itself
9struct IStorage {
10 // Writes len bytes. Blocks until complete.
11 // Returns number of bytes written; returns 0 on error.
12 // Never throws.
13 virtual size_t write(const void* data, size_t len) noexcept = 0;
14};
noexcept is part of the contract — it tells the caller “you don’t need a
try/catch around this”. On embedded targets where exceptions are disabled, this
is also a compile-time guarantee.
The LSP check
Before finalising an interface, run the Liskov substitution check on every method: could a legitimate implementation refuse to implement this method? If yes, the interface is too broad.
1// IDisplay has setBrightness() — a UART debug display can't implement this
2// → split into IDisplay and IDimmableDisplay
If any implementation would have to write assert(false) or throw in a method
body, the interface needs surgery.
Pure virtual base vs abstract class with defaults
Two styles:
1// Pure interface — no implementation at all
2struct IFilter {
3 virtual float process(float x) = 0;
4 virtual void reset() = 0;
5 virtual ~IFilter() = default;
6};
7
8// Abstract class — some methods have default behaviour
9class FilterBase : public IFilter {
10public:
11 void reset() override { state_ = 0.0f; } // default reset
12protected:
13 float state_ = 0.0f;
14};
Use pure interfaces when implementations have nothing in common. Use an abstract base class when you have shared state or a non-trivial default that most implementations will use. Don’t mix the two roles in one class — separate the interface from the base class:
1struct IFilter { /* pure interface */ };
2class FilterBase : public IFilter { /* shared state + default reset */ };
3class MovingAvg : public FilterBase { /* specific process() */ };
4class ButterworthFilter : public FilterBase { /* specific process() */ };
Header layout — interface in its own file
Keep the interface in a separate header that has zero dependencies on implementation details:
include/
├── IFilter.h ← only standard headers
├── ISensor.h
└── IStorage.h
src/
├── MovingAvgFilter.h ← includes IFilter.h
├── MovingAvgFilter.cpp
├── Bmp280Sensor.h ← includes ISensor.h
└── Bmp280Sensor.cpp
IFilter.h should include nothing but <cstddef> or <cstdint> if needed.
No HAL headers, no driver headers, no platform headers. This is what allows
the filter to be compiled and tested on a PC without the STM32 toolchain.
Mocking for tests
The payoff of interface-based design is that mocks are trivial to write:
1// test/mocks/MockSensor.h
2struct MockSensor : ISensor {
3 float readValue = 0.0f;
4 bool readyValue = true;
5
6 float read() override { return readValue; }
7 bool ready() override { return readyValue; }
8};
Or using Google Mock for call expectation tracking:
1#include <gmock/gmock.h>
2
3struct MockSensor : ISensor {
4 MOCK_METHOD(float, read, (), (override));
5 MOCK_METHOD(bool, ready, (), (override));
6};
7
8TEST(Pipeline, ReadsWhenReady) {
9 MockSensor sensor;
10 EXPECT_CALL(sensor, ready()).WillOnce(Return(true));
11 EXPECT_CALL(sensor, read()).WillOnce(Return(25.3f));
12
13 SensorPipeline pipeline(sensor);
14 EXPECT_NEAR(pipeline.tick(), 25.3f, 0.01f);
15}
With a plain struct mock, no framework needed — just set readValue and run.
The cost of virtual dispatch
On a modern Cortex-M4 running at 168 MHz, a virtual function call costs roughly 3–5 ns — one indirect branch through the vtable. For a sensor read called at 100 Hz, the overhead is effectively zero.
The vtable cost matters in tight inner loops: if you’re calling a virtual function millions of times per second (audio processing, signal DSP), consider CRTP for zero-overhead static polymorphism instead. For everything else, the flexibility is worth far more than the nanoseconds.
Each polymorphic object carries one hidden pointer (the vptr): 4 bytes on 32-bit targets. On a system with 64 kB RAM and 5 sensor objects, the overhead is 20 bytes. This is irrelevant.
Composing interfaces
Small interfaces compose cleanly. A complex object can implement multiple interfaces and be passed as any of them:
1struct IReadable { virtual float read() = 0; };
2struct IConfigurable { virtual void configure(Cfg) = 0; };
3
4class Bmp280 : public IReadable, public IConfigurable {
5public:
6 float read() override { return bmp280_read(); }
7 void configure(Cfg) override { bmp280_set_osr(cfg.osr); }
8};
9
10// Pipeline only sees IReadable — doesn't know about configure()
11SensorPipeline pipeline(bmp280_instance);
12
13// Initialisation code only sees IConfigurable
14configureSensor(bmp280_instance, cfg);
Each consumer depends only on the interface it uses. If the pipeline is later
given a mock that only implements IReadable, the test doesn’t need to stub out
configure().
Common mistakes
Putting data in the interface
1// Wrong — interfaces have no data
2struct ISensor {
3 int channel; // ← breaks the abstraction
4 virtual float read() = 0;
5};
Data belongs in the implementation. If two implementations share data, extract a base class — but keep the interface pure.
One giant interface
1// Wrong — too many responsibilities
2struct IDevice {
3 virtual void read() = 0;
4 virtual void write() = 0;
5 virtual void configure() = 0;
6 virtual Diag getDiagnostics() = 0;
7 virtual void reset() = 0;
8 virtual float getTemperature() = 0;
9};
This forces every mock to stub six methods when the test only cares about one. Split it. See Interface Segregation Principle.
Non-virtual destructor
1struct ISensor {
2 virtual float read() = 0;
3 // Missing: virtual ~ISensor() = default;
4};
5
6ISensor* s = new Bmp280();
7delete s; // undefined behaviour — Bmp280 destructor never called
Always declare the destructor virtual in an interface, even if it does nothing.
Summary
- An interface in C++: pure virtual methods, virtual destructor, no data
- Design contracts: specify blocking behaviour, error handling, exception safety
- Run the LSP check: if any implementation would stub a method with
assert(false), split the interface - Keep interface headers dependency-free so they compile without the target toolchain
- Mocks become trivial: inherit the interface, set return values, done
- Virtual dispatch cost on Cortex-M4: ~3–5 ns per call — irrelevant outside of tight DSP loops
What’s next
- Dependency injection without a framework — injecting these interfaces at the composition root
- SOLID in practice — LSP and ISP explained with firmware examples
- CRTP — static polymorphism — zero-cost alternative when virtual dispatch is too slow