Совсем недавно в проекте, над которым я работаю в данный момент, потребовалось использовать IoC-контейнер. У программистов на C# в этой области достаточно большой выбор (Unity, Ninject, Castle-Windsor); однако, мой проект на С++, поэтому все гораздо сложнее. Потратив некоторое время на поиски решения в Интернете, наткнулся на следующий проект: PocoCapsule, но при взгляде на размер инсталлятора (22 мб), решительно отказался от этого решения, оно показалось мне тяжеловесным. Таким образом, пришло осознание того, что придется писать IoC контейнер самому.
Решение требовалось максимальное простое, без лишних наворотов наподобие XML-файла конфигурации и т.п. Все, что требовалось от контейнера – возможность зарегистрировать в нем объекты, а затем получить эти объекты, когда они потребуют. Итак, приступим.
Первый вопрос, который мне предстояло решить – каким образом контейнер будет хранить объекты. Варианта было два. Первый – регистрировать в контейнере конкретные экземпляры классов; от этого варианта сразу пришлось отказаться, т.к. хранить в контейнере экземпляры объектов, которые, возможно, никогда не потребуются, – дорогое удовольствие. Второй вариант – регистрировать в контейнере не объекты, а механизмы создания объектов определенного типа, т.е. то, каким образом мы можем создать конкретный экземпляр требуемого объекта. Решение с указателем на функцию меня вполне устроило, на нем и остановился.
Второй вопрос, который предстояло решить, состоял в том, каким образом идентифицировать объекты в контейнере, как задать тип требуемого объекта при его получении из контейнера. Кроме того, возникла еще одна проблема – каким образом определять, что объект, лежащий в контейнере удовлетворяет нашему запросу. В качестве идентификаторов рассматривались три варианта – обычное число типа int, строка (const char* или std::string), либо type_info. Последний вариант отпал сам собой, т.к. в проекте не были включены механизмы RTTI, да и проблему соответствия объекта в контейнере запрашиваемому объекту он не решал; кроме того, оператор typeid в общем случае не гарантирует идентичности возвращаемых type_info при повторном запуске программы. Остались целое число и строка; выбор пал на число в виду его простоты, хотя вариант со строкой вполне допустим, а в некоторых случаях и более предпочтительней.
Итак, в качестве реализации идей, описанных в абзаце выше, родился интерфейс, который должны наследовать все объекты, с которыми будет работать наш контейнер. Вот его код:
struct ITyped { static const int TypeOf = -1; virtual int GetType() const =0; virtual bool IsA(int type) const =0; virtual ~ITyped() { } };
Константа TypeOf однозначно идентифицирует класс. Думаю, не стоит говорить о том, что TypeOf у каждого класса должен в приложении быть уникальным. Метод GetType() позволяет узнать типа объекта (аналог typeid), а IsA(int type) проверяет наследует ли данный объект класс, помеченный идентификатором type(аналог оператора is из C# или instanceof из Java).
Теперь вернемся к первому вопросу, а именно, к функциям создания объектов. Первоначально планировался такой их вид:
typedef ITyped* (*OBJECT_CREATE_FUNC)(IoCContainer& iocContainer);
Такая сигнатура мне поначалу показалась удачной – в процессе создания объекта ему могут потребоваться другие объекты, которые он может получить из контейнера (а-ля DI). Однако, немного подумав, я решил добавить еще один параметр – собственно идентификатор создаваемого типа. Это позволило бы использовать одну функцию для создания целого семейства объектов. Получилось вот что:
typedef ITyped* (*OBJECT_CREATE_FUNC)(int type, IoCContainer& iocContainer);
Напомню, что все, что мне требовалось получить от контейнера – это методы регистрации и получения объектов. В данный момент мы обладаем всем необходимым, что реализовать эти требования. Вот интерфейс нашего контейнера:
class IoCContainer { public: void Register(int type, OBJECT_CREATE_FUNC createFunc); ITyped* Resolve(int type); private: map<int, OBJECT_CREATE_FUNC> m_types; };
Реализация тривиальна:
void IoCContainer::Register(int type, OBJECT_CREATE_FUNC createFunc) { m_types[type] = createFunc; } ITyped* IoCContainer::Resolve(int type) { map<int, OBJECT_CREATE_FUNC>::const_iterator pos = m_types.find(type); if (pos == m_types.end()) { // raise exception } OBJECT_CREATE_FUNC func = pos->second; return func(type, *this); }
Вот пример использования созданного нами контейнера. У нас имеется интерфейс IA, реализующий его класс A:
struct IA : public ITyped { static const int TypeOf = 1; int GetType() const { return TypeOf; } bool IsA(int type) const { return TypeOf == type; } virtual void DoSomething() =0; }; struct A : public IA { static const int TypeOf = 2; int GetType() const { return TypeOf; } bool IsA(int type) const { return TypeOf == type || IA::IsA(type); } static ITyped* Create(int type, IoCContainer&) { return new A(); } void DoSomething() { cout << "A::DoSomething" << endl; } }; int main() { IoCContainer container; container.Register(IA::TypeOf, &A::Create); IA* pA = (IA*)container.Resolve(IA::TypeOf); pA->DoSomething(); delete pA; return 0; }
На этом можно было бы закончить, но я решил добавить немного сахара в контейнер. Мне не очень нравилась строка:
IA* pA = (IA*)container.Resolve(IA::TypeOf);
во-первых, приходится явно приводить возвращаемый объект к требуемому интерфейсу, а во-вторых зачем писать IA::TypeOf, когда можно переложить эти обязанности на компилятор. Решается данный вопрос достаточно просто – нам на помощь приходят шаблоны:
class IoCContainer { public: template <class T> void Register(OBJECT_CREATE_FUNC createFunc) { Register(T::TypeOf, createFunc); } template<class T> T* Resolve() { return static_cast<T*>(T::TypeOf); } void Register(int type, OBJECT_CREATE_FUNC createFunc); ITyped* Resolve(int type); private: map<int, OBJECT_CREATE_FUNC> m_types; };
Как можно заметить, с помощью шаблонов мы делаем то, что раньше приходилось делать ручками. Теперь нашу функцию main можно переписать следующим образом:
int main() { IoCContainer container; container.Register<IA>(&A::Create); IA* pA = container.Resolve<IA>(); pA->DoSomething(); delete pA; return 0; }
Итак, мы достигли поставленных вначале статьи целей – наш IoC контейнер умеет регистрировать и извлекать объекты. Однако, как и все в этом мире, он не идеален, вот лишь несколько недостатков:
- неплохо бы сделать наш контейнер синглтоном;
- возлагать удаление полученных от контейнера объектов на пользователя – не лучшая идея; возможно, стоит воспользоваться каким-либо типом умного указателя (например, shared_ptr), и возвращать из контейнера умный, а не обычный указатель;
- при получении из контейнера каждый раз создается новый экземпляр объекта. В большинстве сценариев такое поведение не требуется, предпочтительнее, чтобы при каждом запросе возвращался указатель на однажды созданный объект. Однако, эту функциональность я реализую в следующей статье в ближайшее время.