Введение в Dependency Injection в C++
В современной разработке на C++ одним из ключевых принципов создания гибкого и тестируемого кода является Dependency Injection (DI) — внедрение зависимостей. Этот паттерн проектирования позволяет уменьшить связанность между компонентами системы, делая код более модульным, переиспользуемым и простым для тестирования.
В этой статье мы разберем, что такое Dependency Injection в C++, какие проблемы он решает, рассмотрим различные способы его реализации на практике и приведем примеры кода, которые помогут вам внедрить этот подход в свои проекты.
Проблема прямой зависимости
Прежде чем говорить о решении, давайте посмотрим на типичную проблему. Представьте, что у нас есть класс UserService, который использует Logger для записи логов:
class Logger {
public:
void log(const std::string& message) {
std::cout << "[LOG]: " << message << std::endl;
}
};
class UserService {
private:
Logger logger; // Жесткая зависимость
public:
void createUser(const std::string& name) {
logger.log("Creating user: " + name);
// ... логика создания пользователя
}
};
Здесь UserService самостоятельно создает экземпляр Logger. Это создает несколько проблем:
- Сложность тестирования: невозможно подменить логгер на mock-объект во время юнит-тестов.
- Жесткая связанность: изменение конструктора
Loggerпотребует изменения всех классов, которые его используют. - Нарушение SRP (Single Responsibility Principle): класс отвечает не только за свою логику, но и за создание зависимостей.
Что такое Dependency Injection?
Dependency Injection — это паттерн, при котором объект получает свои зависимости извне, а не создает их самостоятельно. Основная идея: "не спрашивай, а получи".
В C++ существует три основных способа внедрения зависимостей:
- Constructor Injection — через конструктор.
- Setter Injection — через setter-методы.
- Interface Injection — через интерфейс.
1. Constructor Injection (Внедрение через конструктор)
Это самый распространенный и предпочтительный способ. Зависимости передаются в конструктор класса:
#include <memory>
#include <iostream>
class ILogger {
public:
virtual ~ILogger() = default;
virtual void log(const std::string& message) = 0;
};
class ConsoleLogger : public ILogger {
public:
void log(const std::string& message) override {
std::cout << "[CONSOLE]: " << message << std::endl;
}
};
class FileLogger : public ILogger {
public:
void log(const std::string& message) override {
// Логика записи в файл
std::cout << "[FILE]: " << message << std::endl;
}
};
class UserService {
private:
std::unique_ptr<ILogger> logger_; // Зависимость через интерфейс
public:
// Constructor Injection
explicit UserService(std::unique_ptr<ILogger> logger)
: logger_(std::move(logger)) {}
void createUser(const std::string& name) {
logger_->log("Creating user: " + name);
// ... логика
}
};
int main() {
auto consoleLogger = std::make_unique<ConsoleLogger>();
UserService service(std::move(consoleLogger));
service.createUser("Alice");
return 0;
}
Преимущества:
- Зависимость явно видна в сигнатуре конструктора.
- Объект всегда находится в валидном состоянии после создания.
- Легко тестировать — можно подставить mock.
2. Setter Injection (Внедрение через сеттеры)
Используется, когда зависимость не является обязательной или может меняться в рантайме:
class UserServiceWithSetter {
private:
std::shared_ptr<ILogger> logger_; // shared_ptr для гибкости
public:
// Зависимость не обязательна
void setLogger(std::shared_ptr<ILogger> logger) {
logger_ = std::move(logger);
}
void createUser(const std::string& name) {
if (logger_) {
logger_->log("Creating user: " + name);
}
// ... логика
}
};
Минусы: объект может быть в неполном состоянии, если забыть вызвать сеттер.
3. Interface Injection (Внедрение через интерфейс)
Это более редкий подход, при котором зависимость передается через отдельный метод, объявленный в интерфейсе. В C++ часто реализуется через шаблоны:
template <typename TLogger>
class UserServiceTemplate {
private:
TLogger logger_;
public:
explicit UserServiceTemplate(TLogger logger) : logger_(std::move(logger)) {}
void createUser(const std::string& name) {
logger_.log("Creating user: " + name);
}
};
// Использование
UserServiceTemplate<ConsoleLogger> service(ConsoleLogger{});
service.createUser("Bob");
DI-контейнеры в C++
Для управления большим количеством зависимостей часто используются DI-контейнеры (или IoC-контейнеры). В C++ нет встроенного контейнера, но его можно реализовать самостоятельно или использовать сторонние библиотеки (например, Boost.DI).
Простой пример DI-контейнера:
#include <unordered_map>
#include <functional>
#include <memory>
#include <typeindex>
class DIContainer {
private:
std::unordered_map<std::type_index, std::function<std::shared_ptr<void>()>> factories_;
public:
template <typename T>
void registerFactory(std::function<std::shared_ptr<T>()> factory) {
factories_[std::type_index(typeid(T))] = factory;
}
template <typename T>
std::shared_ptr<T> resolve() {
auto it = factories_.find(std::type_index(typeid(T)));
if (it != factories_.end()) {
return std::static_pointer_cast<T>(it->second());
}
throw std::runtime_error("Dependency not registered");
}
};
// Пример использования
int main() {
DIContainer container;
container.registerFactory<ILogger>([]() {
return std::make_shared<ConsoleLogger>();
});
auto logger = container.resolve<ILogger