Что такое интерфейс
В 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-циклов
Что дальше
- Внедрение зависимостей без фреймворка — внедрение этих интерфейсов в корне компоновки
- SOLID на практике — LSP и ISP с примерами из прошивок
- CRTP — статический полиморфизм — альтернатива с нулевой стоимостью, когда виртуальный вызов слишком дорог