Что такое внедрение зависимостей на самом деле

Внедрение зависимостей (DI) — не фреймворк, не библиотека паттернов и не дизайн-философия. Это одна конкретная техника:

Дай объектам то, что им нужно; не позволяй им строить это самим.

Всё. Если класс создаёт свои зависимости сам, он владеет ими и вы не можете их заменить. Если класс получает зависимости при создании, вызывающий решает, что он получит.

 1// НЕ внедрение зависимостей
 2class DataLogger {
 3    Stm32SpiFlash flash_;   // создаётся здесь — владеем навсегда
 4public:
 5    DataLogger() : flash_(SPI1, GPIOA, GPIO_PIN_4) {}
 6};
 7
 8// Внедрение зависимостей
 9class DataLogger {
10    IStorage& storage_;     // внедряется вызывающим
11public:
12    explicit DataLogger(IStorage& s) : storage_(s) {}
13};

Разница — одна строка в классе и одна строка в месте вызова. Выигрыш — тестируемость, переносимость и возможность менять реализации, не трогая бизнес-логику.


Инъекция через конструктор — единственная нужная форма

Существуют три паттерна DI: через конструктор, через сеттер и через интерфейс. Инъекция через конструктор — правильная. Остальные — ловушки.

Инъекция через конструктор

 1class SensorPipeline {
 2    ISensor&      sensor_;
 3    IFilter&      filter_;
 4    IDisplay&     display_;
 5
 6public:
 7    SensorPipeline(ISensor& sensor, IFilter& filter, IDisplay& display)
 8        : sensor_(sensor), filter_(filter), display_(display) {}
 9
10    void tick() {
11        float raw = sensor_.read();
12        float filtered = filter_.process(raw);
13        display_.show(filtered);
14    }
15};

Объект валиден с первой строки после создания. Нет «неинициализированного состояния». Нельзя вызвать tick() с нулевым сенсором.

Инъекция через сеттер — почему её избегать

1// Инъекция через сеттер — объект сломан, пока сеттеры не вызваны
2class SensorPipeline {
3    ISensor* sensor_ = nullptr;
4public:
5    void setSensor(ISensor* s) { sensor_ = s; }
6    void tick() {
7        sensor_->read();  // падение, если setSensor не вызывался
8    }
9};

У объекта есть временная зависимость от порядка вызовов. Компилятор не может это проверить. Отправляем разыменование нулевого указателя в производство.

Используйте инъекцию через сеттер только тогда, когда действительно нужно менять зависимость в рантайме — например, горячая замена рендерера. Во встраиваемой прошивке и большинстве бизнес-логики этого не требуется.


Корень компоновки

Где вызывается new или создаются стековые объекты и соединяются вместе? В одном месте: корень компоновки — обычно main() или функция инициализации верхнего уровня.

 1// main.cpp — корень компоновки
 2int main() {
 3    // Слой оборудования
 4    Stm32AdcReader  adcReader(ADC1);
 5    Bmp280Sensor    bmpSensor(hi2c1);
 6
 7    // Слой бизнес-логики — никаких аппаратных типов здесь
 8    MovingAvgFilter filter(/*окно=*/8);
 9    TftDisplay      display(hspi1);
10
11    SensorPipeline  pipeline(bmpSensor, filter, display);
12
13    // Цикл работы
14    while (true) {
15        pipeline.tick();
16        HAL_Delay(100);
17    }
18}

main.cpp знает все конкретные типы. Всё, что ниже, знает только интерфейсы. Это инверсия зависимостей на архитектурном уровне: стрелки направлены внутрь, к абстракциям, а не наружу, к железу.

Для тестовой сборки main.cpp заменяется тестовым фикстуром, который внедряет моки:

 1TEST(SensorPipeline, FiltersAndDisplays) {
 2    MockSensor  sensor;
 3    MockFilter  filter;
 4    MockDisplay display;
 5
 6    EXPECT_CALL(sensor, read()).WillRepeatedly(Return(42.0f));
 7    EXPECT_CALL(filter, process(42.0f)).WillRepeatedly(Return(40.0f));
 8    EXPECT_CALL(display, show(40.0f)).Times(1);
 9
10    SensorPipeline pipeline(sensor, filter, display);
11    pipeline.tick();
12}

Никакого железа. Никакого HAL. Запускается на CI-сервере.


Управление временем жизни

Инъекция через конструктор передаёт ссылки — зависимость должна жить дольше объекта. Это безопасно, когда оба живут на стеке в одной области видимости (что почти всегда верно во встраиваемом коде).

1// БЕЗОПАСНО: оба живут в main(), который работает вечно
2Bmp280Sensor   sensor(hi2c1);
3SensorPipeline pipeline(sensor, filter, display);

Когда нужен динамический срок жизни, используйте указатели — но явно обрабатывайте случай null:

1class SensorPipeline {
2    ISensor* sensor_;
3public:
4    explicit SensorPipeline(ISensor* sensor)
5        : sensor_(sensor)
6    {
7        assert(sensor != nullptr);  // явный сбой при создании, а не при tick()
8    }
9};

Или используйте std::reference_wrapper<ISensor>, чтобы сохранить семантику ссылки с возможностью переприсваивания:

1std::reference_wrapper<ISensor> sensor_;
2// sensor_.get().read();

Сервис-локатор — антипаттерн, похожий на DI

Паттерн «сервис-локатор» регистрирует зависимости в глобальном реестре и позволяет объектам искать их:

 1// Сервис-локатор
 2class ServiceLocator {
 3    static inline std::unordered_map<std::type_index, void*> services_;
 4public:
 5    template <typename T>
 6    static void register_(T* service) {
 7        services_[typeid(T)] = service;
 8    }
 9    template <typename T>
10    static T* get() {
11        return static_cast<T*>(services_[typeid(T)]);
12    }
13};
14
15class SensorPipeline {
16    void tick() {
17        auto* sensor = ServiceLocator::get<ISensor>();  // скрытая зависимость
18        sensor->read();
19    }
20};

Это выглядит как DI, потому что ISensor — интерфейс. Но это не DI. Зависимость скрыта — из сигнатуры класса не видно, что ему нужен сенсор. Нельзя проверить во время компиляции, что сенсор зарегистрирован. В тесте нужно не забыть зарегистрировать мок перед созданием SensorPipeline и отменить регистрацию после, причём эти операции используют глобальное состояние, общее для всех тестов.

Сервис-локатор — это глобальная переменная в плаще.

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


Обработка необязательных зависимостей

Иногда зависимость необязательна — логгер, который может и не быть подключён. Два варианта:

Паттерн «нулевой объект» — предоставить реализацию-заглушку:

 1struct ILogger {
 2    virtual void log(std::string_view msg) = 0;
 3};
 4
 5struct NullLogger : ILogger {
 6    void log(std::string_view) override {}  // ничего не делает
 7};
 8
 9class SensorPipeline {
10    ILogger& logger_;
11public:
12    explicit SensorPipeline(ISensor& s, ILogger& log = NullLogger::instance())
13        : logger_(log) {}
14};

Вызывающий опускает логгер и получает тихую работу. Код никогда не проверяет null — его нет.

Параметр по умолчанию NullLogger::instance() требует синглтона — допустимо для безсостоятельного объекта-заглушки.


Избегание взрыва параметров конструктора

Если класс принимает 6 параметров конструктора, что-то не так с дизайном — а не с DI. У класса слишком много обязанностей. Разбить его.

Когда конструкторы законно растут (3–4 параметра — норма, 5+ — подозрительно), рассмотрите объект-параметр:

 1struct PipelineConfig {
 2    ISensor&  sensor;
 3    IFilter&  filter;
 4    IDisplay& display;
 5    ILogger&  logger;
 6};
 7
 8class SensorPipeline {
 9public:
10    explicit SensorPipeline(PipelineConfig cfg) : cfg_(cfg) {}
11private:
12    PipelineConfig cfg_;
13};

Это не сервис-локатор — PipelineConfig — тип-значение, передаваемый из корня компоновки, а не глобальный реестр. Зависимости по-прежнему явны и видны в месте вызова.


Паттерн на практике

При последовательном применении DI формирует кодовую базу с чёткой структурой:

main.cpp (корень компоновки)
    │
    ├── создаёт аппаратные адаптеры (Stm32AdcReader, TftDisplay, ...)
    ├── создаёт бизнес-логику (SensorPipeline, AlarmManager, ...)
    └── соединяет их вместе
            │
            ▼
    Все остальные файлы: интерфейсы и реализации
    Ниже корня компоновки не видно конкретных типов

Корень компоновки — единственный файл, знающий об оборудовании. Замените МК, замените аппаратные адаптеры, перекомпилируйте. Логика конвейера не изменится.


Итоги

  • Инъекция через конструктор: давайте объектам то, что им нужно при создании, без исключений
  • Корень компоновки: одно место соединяет всё — обычно main()
  • Сервис-локатор: глобальная переменная с дополнительными шагами — избегайте
  • Нулевой объект: обрабатывайте необязательные зависимости без проверок на null
  • Объекты-параметры: группируйте связанные зависимости, чтобы избежать длинных конструкторов

Никакого фреймворка не нужно. Никакой магии макросов. Горсть интерфейсов, корень компоновки и устойчивая привычка спрашивать «откуда берётся эта зависимость?» перед написанием конструктора.

Что дальше