Что такое интерфейс

В C++ нет ключевого слова interface. Интерфейс — соглашение: класс только с чисто виртуальными методами и виртуальным деструктором. Никаких членов-данных, никакой реализации.

1struct ISensor {
2    virtual float read()  = 0;
3    virtual bool  ready() = 0;
4    virtual ~ISensor()    = default;
5};

Вот и всё. Любой класс, наследующий от ISensor и реализующий оба метода, является сенсором с точки зрения любого кода, зависящего от ISensor.

Цель — отделить что от как. Код, зависящий от ISensor, не знает — и не должен знать — общается ли он с BMP280 по I2C, NTC-термистором на канале АЦП 3 или генератором синусоиды в юнит-тесте.


Проектирование контрактов, которые держатся

Интерфейс — контракт между двумя сторонами кодовой базы. Слабый контракт бесполезен; лживый контракт опасен. Хорошее проектирование интерфейса означает вопрос:

Что гарантирует каждая реализация?

 1// Слабый контракт: оставляет слишком много неуказанным
 2struct IStorage {
 3    virtual void write(const void* data, size_t len) = 0;
 4};
 5// Блокирует ли write()? Возвращает при ошибке? Бросает исключение?
 6// Молча теряет байты? Вызывающий вынужден угадывать или читать реализацию.
 7
 8// Более строгий контракт: документируем гарантии в самом интерфейсе
 9struct IStorage {
10    // Записывает len байт. Блокирует до завершения.
11    // Возвращает количество записанных байт; возвращает 0 при ошибке.
12    // Никогда не бросает исключений.
13    virtual size_t write(const void* data, size_t len) noexcept = 0;
14};

noexcept — часть контракта: сообщает вызывающему «вам не нужен try/catch вокруг этого». На встраиваемых платформах, где исключения отключены, это ещё и гарантия времени компиляции.

Проверка по Лисков

Перед финализацией интерфейса выполните проверку по принципу подстановки Лисков для каждого метода: может ли законная реализация отказаться реализовать этот метод? Если да — интерфейс слишком широкий.

1// IDisplay имеет setBrightness() — отладочный UART-дисплей не может это реализовать
2// → разделить на IDisplay и IDimmableDisplay

Если любая реализация вынуждена написать assert(false) или бросить исключение в теле метода — интерфейс требует хирургии.


Чисто виртуальная база vs абстрактный класс с реализациями по умолчанию

Два стиля:

 1// Чистый интерфейс — никакой реализации вообще
 2struct IFilter {
 3    virtual float process(float x) = 0;
 4    virtual void  reset()          = 0;
 5    virtual ~IFilter()             = default;
 6};
 7
 8// Абстрактный класс — некоторые методы имеют поведение по умолчанию
 9class FilterBase : public IFilter {
10public:
11    void reset() override { state_ = 0.0f; }   // сброс по умолчанию
12protected:
13    float state_ = 0.0f;
14};

Используйте чистые интерфейсы, когда у реализаций нет ничего общего. Используйте абстрактный базовый класс, когда есть разделяемое состояние или нетривиальное поведение по умолчанию, которое используют большинство реализаций. Не смешивайте оба назначения в одном классе — разделяйте интерфейс и базовый класс:

1struct IFilter    { /* чистый интерфейс */ };
2class FilterBase  : public IFilter { /* разделяемое состояние + сброс по умолчанию */ };
3class MovingAvg   : public FilterBase { /* конкретный process() */ };
4class ButterworthFilter : public FilterBase { /* конкретный process() */ };

Расположение заголовков — интерфейс в отдельном файле

Держите интерфейс в отдельном заголовке с нулевыми зависимостями от деталей реализации:

include/
├── IFilter.h          ← только стандартные заголовки
├── ISensor.h
└── IStorage.h
src/
├── MovingAvgFilter.h  ← включает IFilter.h
├── MovingAvgFilter.cpp
├── Bmp280Sensor.h     ← включает ISensor.h
└── Bmp280Sensor.cpp

IFilter.h должен включать только <cstddef> или <cstdint>, если нужно. Никаких заголовков HAL, никаких заголовков драйверов, никаких заголовков платформы. Это позволяет компилировать и тестировать фильтр на PC без тулчейна STM32.


Моки для тестов

Отдача от проектирования на основе интерфейсов в том, что моки тривиально пишутся:

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};

Или с Google Mock для отслеживания ожиданий:

 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}

С простым struct-моком никакого фреймворка не нужно — просто установите readValue и запустите.


Стоимость виртуальной диспетчеризации

На современном Cortex-M4 на 168 МГц виртуальный вызов обходится примерно в 3–5 нс — один косвенный переход через vtable. Для чтения сенсора, вызываемого на частоте 100 Гц, накладные расходы фактически нулевые.

Стоимость vtable важна в плотных внутренних циклах: если вы вызываете виртуальную функцию миллионы раз в секунду (аудиообработка, сигнальный DSP), рассмотрите CRTP для статического полиморфизма с нулевыми накладными расходами. Для всего остального гибкость стоит намного больше, чем наносекунды.

Каждый полиморфный объект несёт один скрытый указатель (vptr): 4 байта на 32-битных платформах. На системе с 64 кБ RAM и 5 объектами-сенсорами накладные расходы — 20 байт. Это несущественно.


Компоновка интерфейсов

Малые интерфейсы компонуются чисто. Сложный объект может реализовывать несколько интерфейсов и передаваться как любой из них:

 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// Конвейер видит только IReadable — ничего не знает о configure()
11SensorPipeline pipeline(bmp280_instance);
12
13// Код инициализации видит только IConfigurable
14configureSensor(bmp280_instance, cfg);

Каждый потребитель зависит только от используемого им интерфейса. Если конвейеру позже дать мок, реализующий только IReadable, тест не нужно будет заглушать configure().


Типичные ошибки

Данные в интерфейсе

1// Неправильно — интерфейсы не имеют данных
2struct ISensor {
3    int channel;        // ← нарушает абстракцию
4    virtual float read() = 0;
5};

Данные принадлежат реализации. Если две реализации разделяют данные, выделите базовый класс — но держите интерфейс чистым.

Один гигантский интерфейс

1// Неправильно — слишком много обязанностей
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};

Это заставляет каждый мок заглушать шесть методов, когда тест заботится только об одном. Разделить. Смотрите принцип разделения интерфейсов.

Невиртуальный деструктор

1struct ISensor {
2    virtual float read() = 0;
3    // Пропущено: virtual ~ISensor() = default;
4};
5
6ISensor* s = new Bmp280();
7delete s;   // неопределённое поведение — деструктор Bmp280 никогда не вызывается

Всегда объявляйте деструктор виртуальным в интерфейсе, даже если он ничего не делает.


Итоги

  • Интерфейс в C++: чисто виртуальные методы, виртуальный деструктор, никаких данных
  • Проектируйте контракты: указывайте блокирующее поведение, обработку ошибок, безопасность исключений
  • Проверяйте по Лисков: если любая реализация должна заглушать метод через assert(false), разделите интерфейс
  • Держите заголовки интерфейсов без зависимостей, чтобы они компилировались без тулчейна платформы
  • Моки становятся тривиальными: унаследовать интерфейс, задать возвращаемые значения, готово
  • Стоимость виртуального вызова на Cortex-M4: ~3–5 нс на вызов — несущественно вне плотных DSP-циклов

Что дальше