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