Pull to refresh

Внедрение зависимостей в C++

Reading time 7 min
Views 31K
MagicClass::getInstance().getFooFactory().createFoo().killMePlease();

«Внедрение зависимостей» и «Статическую типизацию» трудно назвать лучшими друзьями на все времена. Некоторые наработки в этом направлении существуют и легко гуглятся, однако интересно, насколько реально создать собственную простую реализацию баз хаков, ловких ухищрений и подключения внешних библиотек. Без особой гибкости, вот чтобы буквально два действия — настройка и внедрение. Вопросы многопоточности затрагиваться не будут, чтобы не отвлекаться от основной идеи. Итак, что же лично я хочу от внедрения зависимостей.

Постановка

Управление временем жизни объектов. Нюансы, касающиеся времени жизни, не должны разбавлять основную логику приложения. Чаще всего мне не нужно знать, что требуемый объект является экземпляром класса-одиночки или создается с помощью какого-то фабричного метода. Просто нужен пригодный для использования экземпляр. Совсем хорошо, если существует возможность изменять правила управления временем жизни объекта, не затрагивая основную логику приложения.

Тестирование. Одна из первоочередных целей внедрения зависимостей. Подразумевает возможность в подменять определенные объекты тестовыми заглушками, опять же, не затрагивая основную реализацию.

Упрощенный доступ к объектам. Информация, какой объект откуда можно получить чаще всего совершенно неинтересна. Более того, она отвлекает от базовой функциональности и способствует более сильному связыванию подсистем проекта, чем того хочется. Также нежелание программиста продумывать адекватные точки доступа к добавляемым сервисам может негативно сказаться на общей архитектуре системы. «В последние дни я получал почти все нужные мне объекты из модуля номер N, закину и эти туда же...».

Рабочий вариант с тестовым примером можно взять отсюда.

Перейдем к делу. Начнем со времени жизни. Хотелось бы иметь возможность использовать следующие варианты.

  1. Одиночка (Singleton). Существует только один статический экземпляр объекта на протяжении всего времени жизни приложения.
  2. Объект общего пользования (Shared). Похож на одиночку. Главное отличие — объект существует, пока им кто-то пользуется. Все клиенты используют ссылку на один и тот же экземпляр. Может быть создан и разрушен за время работы несколько раз, даже ни одного раза, если желающих не нашлось.
  3. Объект (Object). Время жизни объекта совпадает со временем существования клиента.
  4. Объект времени исполнения (Runtime). Все клиенты используют один и тот же объект, который может изменяться во время работы программы.


Естественно, клиента не должно заботить, к какому именно виду принадлежит внедренный объект.
Второе пожелание — естественность внедрения. Хочется, чтобы объект внедрялся в клиентский посредством синтаксиса, максимально приближенного к объявлению поля класса. Собственно, именно разновидностью свойства класса он и является.

class SomeClass
{
public:

private:
	inject(SomeInterface, mFieldName)
};


Должен сразу признаться, что изначально в мечтах, конечно, были шаблоны, однако получить простую и прозрачную реализацию описанных выше типов внедрений как-то сходу не удалось. А вот с приземленными макросами получилось неожиданно легко.

Должно существовать место в проекте, где зависимости настраиваются следующим образом.

inject_as_share(Interface1, Class1)
inject_as_singleton(Interface2, Class1)
inject_as_object(Interface3, Class1)
inject_as_runtime(Interface4)


Первый параметр — интерфейс, второй — реализующий его класс. Конечно, класс может выступать в роли интерфейса для самого себя. Для объекта времени исполнения (runtime) понадобится еще инициализация где-то в глубинах программы (не самый удачный вариант внедрения, но иногда и такой нужен)

inject_set_runtime(Interface4, &implementation4)


Первое, что бросается в глаза — inject_as_share, inject_as_singleton и inject_as_object должны иметь возможность создавать экземпляры классов, что подразумевает вызов их конструкторов. Простота-простотой, но рассчитывать исключительно на конструкторы по умолчанию было бы слишком легкомысленно. Поэтому будет реализована возможность передавать в макросы инициализации параметры конструкторов соответствующих классов, что-то типа

inject_as_share(Interface1, Class1, "param1", 1234, true)


Перед тем, как углубиться в реализацию предложенной концепции, хотелось бы кратко остановиться на других способах задания зависимости объекта o1 от o2

  1. o1 непосредственно создает экземпляр o2. Самый «лобовой» и негибкий способ, для подмены o2 придется перелопатить исходники и заменить создание одного на другой. Раз o1 создал o2, то и ответственность за разрушение берет на себя обычно он же.
  2. Передача o2 в качестве параметра в конструктор. Довольно распространенный способ внедрения зависимости, который, однако может оказаться неудобным, если зависимостей и/или конструкторов у клиентского класса много.
  3. Фабричный метод/класс. Подход позволяет эффективно инкапсулировать тонкости создания объекта o2, однако сами фабрики (особенно когда их много) добавляют информационный шум в архитектуру приложения. Еще один существенный недостаток фабрик — их использование не отражено в интерфейсе класса-клиента.

Конфигуратор

Перейдем собственно к реализации. Начнем с внедрения одиночки. Для пользователя вся настройка будет сводится к строке

inject_as_singleton(Interface, Class, [constructor_parameters_list])


Поскольку количество аргументов конструктора в общем случае неизвестно, inject_as_singleton — это макрос с переменным числом аргументов. Использоваться все виды инъекций будут единообразно (в качестве свойства класса), поэтому изначально нужно придумать интерфейс, который позволил бы обеспечить доступ к объектам вне зависимости от способа их создания. В нашем случае это будет структура Factory с единственным методом get, который возвращает ссылку на объект, реализующий заданный интерфейс.

struct Factory 
{ 
    Interface& get();
};


Реализация же класса одиночки будет классическая

Interface& getInstance()
{ 
    static T instance = T(...); 
    return instance; 
}


Вызов конструктора пришлось записать в несколько непривычном (по крайней мере, для меня) виде, потому что вариант

static T instance(...);

в случае конструктора без параметров упорно интерпретируется компилятором как объявление функции.

И, чтобы избежать конфликтов, поместим всю эту реализацию в пространство имен injectorXXX, где XXX — имя интерфейса внедрения. Итоговый макрос будет выглядеть так

#define inject_as_singleton(Interface, T, ...) \
namespace injector##Interface{ \
	Interface& getInstance() \
	{ \
		static T instance = T(__VA_ARGS__); \
		return instance; \
	} \
	struct Factory \
	{ \
		Interface& get() { return getInstance(); } \
	}; \
}


Дальше настолько пошагово расписывать не буду, но подход всегда один — реализация стратегии управления временем жизни «как получится» + фиксированная структура-интерфейс Factory, и все это в отдельном пространстве имен.

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

#define inject_as_share(Interface, T, ...) \
namespace injector##Interface{ \
	struct Factory \
	{ \
		Factory() { \
			if (refCount == 0) { \
				object = new T(__VA_ARGS__); \
			} \
			++refCount; \
		} \
		~Factory() { \
			if (--refCount == 0) { \
				delete object; \
			} \
		} \
		Interface& get() { return *object; } \
		static T* object; \
		static unsigned int refCount; \
	}; \
	T* Factory::object = 0; \
	unsigned int Factory::refCount = 0; \
}


Код в особых пояснениях не нуждается — в конструкторе увеличиваем счетчик, при необходимости создавая экземпляр внедряемого класса, в деструкторе счетчик уменьшаем и, если пробил час, удаляем ненужный объект.

Внедрение «свой объект для каждого клиента» реализуется совсем просто — как поле фабрики.

#define inject_as_object(Interface, T, ...) \
namespace injector##Interface{ \
	struct Factory \
		{ \
		Factory() : object(__VA_ARGS__) {} \
		Interface& get() { return object; } \
		T object; \
	}; \
}


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

#define inject_as_runtime(Interface) \
namespace injector##Interface{ \
	struct Factory \
	{ \
		Interface& get() { return *object; } \
		static Interface* object; \
	}; \
	Interface* Factory::object = 0; \
}


Для окончательной реализации стратегии понадобится специальный макрос для задания объекта

#define inject_set_runtime(Interface, Value) injector##Interface::Factory::object = (Value);

Внедрение

С фабриками закончили, теперь перейдем к самому внедрению. Стратегия будет следующая — объект владеет экземпляром интересующей его фабрики, обращаясь при необходимости к предоставляемому ею объекту. Термин «фабрика» здесь, конечно, неидеален, так как она не всегда создает экземпляры, но на момент написания статьи ничего удачнее не придумалось.

Здесь мы сталкиваемся с проблемой. Заставлять пользователя каждый раз вручную вызывать метод get не хочется — все-таки сам факт существования фабрики хотелось бы скрыть, чтобы развязать себе руки для дальнейших доработок механизма. Нужно каким-то образом сделать имитатор обычного свойства. Первое, что приходит в голову — реализация интерфейса посредством наследования. Как приходит, так и уходит. Если это реализуемо, то будет достаточно нетривиально. Поэтому поступим проще — создадим класс-обертку, в котором переопределим оператор ->, заставив его возвращать экземпляр внедряемого класса. Получится почти идеально. Из ограничений пользователь столкнется только с обязанностью пользоваться внедренным объектом как указателем, что уже вполне терпимо.

Итак, макрос внедрения свойства будет выглядеть так
#define inject(Interface, Name) \
	struct Interface##Proxy \
	{ \
		Interface* operator->() \
		{ \
			return &factory.get(); \
		} \
		injector##Interface::Factory factory; \
	}; \
	Interface##Proxy Name;


Просто класс-обертка, владеющий нужной фабрикой и обращающийся к ней в операторе ->

Пример

Как можно видеть, реализация достаточно простая. Использовать же предлагаемый механизм еще проще. Рассмотрим простой пример. Пусть есть интерфейс

class IWork
{
public:
	virtual void doSmth() = 0;
	virtual ~IWork() {};
};


И несколько классов Work1, Work2 и т.п, его реализующие, некоторые из этих классов имеют конструкторы с параметрами (полный текст примера находится здесь). Сначала конфигурируем

inject_as_share(IWork, Work1)
inject_as_singleton(Work2, Work2, 1)
inject_as_object(Work3, Work3, 1, true)
inject_as_runtime(Work4)


Потом создаем класс для опытов

class Employee
{
public:

	void doIt()
	{
		mWork1->doSmth();
		mWork2->doSmth();
		mWork3->doSmth();
		mWork4->doSmth();
	}

private:
	inject(IWork, mWork1)
	inject(Work2, mWork2)
	inject(Work3, mWork3)
	inject(Work4, mWork4)
};


И используем

Work4 w4;
inject_set_runtime(Work4, &w4)
Employee e1;
e1.doIt();


Самое главное здесь — клиентский класс Employee вообще не делает никаких предположений относительно времени жизни внедренных объектов — об этом заботятся директивы конфигурирования inject_as_XXX. В полном примере можно более подробно понаблюдать, как внедренные объекты чувствуют себя при созданиях и разрушения клиентских объектов.

На этом все. Из недостатков метода хочется отметить необходимость в потенциально достаточно больших и сильносвязанных файлах конфигурации, в которые придется включать чуть ли не все исходники проекта. Однако файлы эти будут полностью линейные, и никто не запрещает предварительно продумать разбивку конфигурации. Зато в остальных участках проекта уровень связанности существенно снизится, позволяя разработчику лучше сконцентрироваться на реализуемой логике.
Tags:
Hubs:
+17
Comments 15
Comments Comments 15

Articles