Что такое внедрение зависимостей на самом деле
Внедрение зависимостей (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
- Объекты-параметры: группируйте связанные зависимости, чтобы избежать длинных конструкторов
Никакого фреймворка не нужно. Никакой магии макросов. Горсть интерфейсов, корень компоновки и устойчивая привычка спрашивать «откуда берётся эта зависимость?» перед написанием конструктора.
Что дальше
- Проектирование на основе интерфейсов — проектирование контрактов, которые держатся при подстановке
- SOLID на практике — DIP — это D в SOLID, посмотрите, как он вписывается в общую картину
- Многоуровневая архитектура прошивки — применение DI на уровне архитектуры