Создавать объекты по строковым именам их классов и получать информацию о наследниках классов во время исполнения программы. С++ либо не поддерживает, либо слабо поддерживает подобный функционал «из коробки».
В этом цикле статей будет я подробно рассказываю о том, как создавал свою микро-библиотеку, реализующую подобное поведение и как готовил её к публикации.
Моя совесть не будет чиста, если я не признаюсь в одной хитрой мысли, которая подтолкнула меня написать данный цикл статей. Так как это касается, скорее, маркетинга, я использую прятки под кат. Предупреждаю: там достаточно много текста.
Во время чтения этих статей вы будете встречать упоминание некой «основной библиотеки», частью которой являлся cpprt. Я работаю над этой самой «основной библиотекой» (у которой пока даже нет названия, только условное data_mapping) чистым временем второй месяц. И по поводу «основной библиотеки» у меня была одна очень наивная мысль. Я хотел на ней заработать.
Понимаете? Заработать на библиотеке. Причём на утилитной библиотеке. Причём продавая её людям самостоятельно, а не через какую-нибудь площадку… Безумие. План был следующий:
1. Создать некое относительное стабильное ядро функционала, которое показывало бы основные фичи библиотеки на нескольких эффектных примерах.
2. Найти людей, которых загорятся идеей проекта. Искать планировалось среди знакомых (и через знакомых), на конференциях, через социальные сети, и т.д.
3. Найдя единомышленников, ввести их в курс дела по проекту и добиться того, чтобы они начали стабильно коммитить в репозиторий проекта.
4. Коллективными силами довести проект до коммерческого вида, по дороге выяснить о механизмах продажи (только тут, обратите внимание, выяснить – святая простота!..) и начать продавать библиотеку. Прибыль от продаж планировалось делить в процентном соотношении на каких-нибудь честных, заранее оговоренных условиях между основными участниками.
5. После того, как пойдут продажи, на правах основателя я надеялся делегировать задачи по поддержке и развитию другим участникам (возможно, несколько снизив при этом свой процент от прибыли) и дальше вкладывать меньше сил в библиотеку и заняться самому своими главными делами, имея при этом источник более или менее стабильного заработка.
6. Ну и тут, как полагается, куда же без него… PROFIT!
Самое странное, я всерьёз верил в работоспособность этого плана.
Я верил в этот план, несмотря на то, что имел пять лет опыта разработки своих pet-проектов и знал – у людей есть тенденция хотеть жить жизнь во внерабочее время, и это самое «хотение жить жизнь» для очень небольшого количества знакомых (где-то для полутора знакомых, если быть честным) означает «кодить всякие интересные штуки на плюсах». Я знал, что заманить опытных коллег по работе в свои репозитории невозможно, а подтягивать заинтересованных студентов до уровня достойного коммитера практически непосильно.
Я верил в этот план даже после того, как задал вопрос и получил ответ в блоге пользователя apofig (он же СанЁк Баглай, спасибо ему). Суть ответа сводилась, вкратце, к тому, что библиотека, даже если она начнёт продаваться, это та же работа – причём работа подчас более каторжная и нервная, чем мирная пахота на дядю.
Я верил в этот план, исправно работая над кодом два месяца кряду. И только произошедший две недели назад, уже во время написания данного цикла статей, разговор по-настоящему открыл мне глаза на истинный порядок вещей. Излагаю суть этого разговора ниже и очень надеюсь, что он оправдает столь длинное и пространное вступление:
Ответ Юрия Рощенко
Человека, который расставил все точки над i, звали Юрий Рощенко. Он работал руководителем отдела в крупной международной аутсорсинговой компании и хорошо разбирался в ведении проектов вообще. Юрий потратил минут сорок своего времени на разговор со мной, за что я ему крайне благодарен.
Изложив свой план по разработке библиотеки, я получил однозначный ответ: нет, так не работает. Действовать нужно совсем по-другому. И Юрий рассказал как. Совместив ответ Юрия Рощенко и некоторые советы от Александра Баглая, я сформулировал следующие шаги развития проекта:
1. Оформить минимальный репозиторий проекта с описанием и демонстрацией его возможностей. При этом важно, чтобы проект запускался без сложной настройки, в один клик, вообще без участия вас как автора и источника подсказок.
2. Показать проект максимально широкому кругу знакомых: коллегам в компаниях, где вы работали, друзьям, и т.д. Дать доступ к репозиторию, предложить опробовать проект. Это то, что у apofig было обозначено словами «дай ему [пользователю] инструмент в руки, закрой рот и смотри, что он с ним делать будет».
3. Следующий пункт стал для меня откровением. По словам Юрия, если на проект будет положительная реакция знакомых, нужно – внимание! – выкатить его на open source. Как можно?!.. Я всегда трясся над своими достижениями аки Кощей Бессмертный над златом. Мысль так вот запросто отдать плоды своих трудов звучала для меня крамолой. «Сопрут!» – думал я. «Украдут, сплагиатят!» – думал я.
Нет, ответил мне Юрий. Всё будет хорошо… Почему всё будет хорошо, он объяснил дальше.
4. После публикации проекта в open source, нужно максимально его распиарить. Больше огласки – больше пользователей, больше отловленных багов, больше действительно полезных фичей, затребованных напрямую от попробовавших проект людей. Количество по Гегелю имеет тенденцию перерастать в качество, а пользователи – в контрибуторов. Больше контрибуторов – ещё выше качество проекта, ещё больше звёзд. Больше звёзд, выше позиция проекта на репозитории, а значит больше огласки и… круг замыкается. И в данном случае то, что круг замыкается – очень хорошо.
Примечание: Если не ясно, кто такие контрибуторы – не беда. Вот ссылка на небольшую статью о ролях пользователей GitHub. Контрибуторы – это такие хорошие люди, которые вносят предложения об изменениях (pull request) в ваш проект.
5. В какой-то момент проектом могут начать пользоваться коммерческие компании. Нужно узнавать, кто пользуется, нужно писать названия компаний в качестве рекламы – ведь если продуктом пользуются проекты, которые приносят прибыль своим авторам, это значит, что продукт сам по себе тоже достаточно ценен.
И только в этот момент имеет смысл создать закрытый форк со своего открытого репозитория и можно попробовать продавать его под коммерческой лицензией, приправив бесплатную версию какими-нибудь дополнительными полезными и нужными фичами.
Как владелец, вы будете иметь доступ к информации, очень полезной для коммерциализации библиотеки: к статистике скачиваний, к часто задаваемым вопросам и паттернам использования кода, к разным предложениям, которые могут поступать к вам как к владельцу проекта от пользователей напрямую.
В контексте разговора Юрий именно здесь ответил на вопрос, почему библиотеку не украдут и не сплагиатят. Дело в том, что, как основатель, вы всегда будете разбираться в проекте глубже, чем большинство контрибуторов. Чтобы потенциальным злоумышленникам разобраться в проекте и написать похожий код, им придётся потратить прорву времени и сил или – говоря по-другому – денег. А это, в свою очередь, означает, что подобным никто не будет заниматься до тех пор, пока покупка библиотеки не станет значительно дороже вложений в разработку её клона.
6. Если проект станет-таки астрономически известным, и вы почувствуете, что даже какой-нибудь Google хочет такую же штуку, как у вас – стоит попробовать продаться. Продаться – это успех, это хорошо. Грамотно поторговавшись, можно выручить с продажи столько денег, сколько самим вряд ли заработать с этой библиотеки за всю жизнь.
При этом если заартачиться, то есть реальный риск, что какой-нибудь Google напишет в течение недели такую же библиотеку, как у вас, и вы останетесь у разбитого корыта.
По пунктам, кажется, всё. Добавлю ещё несколько замечаний от Юрия:
– Нужно помнить про две поворотные точки развития проекта: (1) момент, когда имеет смысл сделать коммерческий форк, и (2) момент, когда для кого-нибудь купить проект станет дороже, чем написать самому.
– Хабра – действительно хорошая площадка для популяризации проекта в русскоязычном сегменте интернета. Мой собеседник упомянул здесь ребята из PVS-studio, которые продвигают свой продукт на хабре, одновременно рассказывая людям полезные вещи.
– Важно помнить про англоязычный сегмент – и ориентироваться во многом на него тоже.
В общем и целом, я изложил здесь все основные мысли, которые вынес из разговора. Надеюсь, кому-нибудь поможет представленный алгоритм. И, надеюсь также, кто-нибудь поделится в комментариях своим опытом и своими мыслями по данному поводу. Уверен, это будет полезно для всего сообщества.
… да, чуть не забыл. Я хотел признаться в одной хитрости.
Дело в том, что данный цикл статей касается очень небольшой, но, на мой взгляд, самодостаточной части «основной библиотеки», отщипнутой от него и превращённой в библиотеку. На её примере я решил попробовать, каково это – продвигать и публиковать ПО с открытым исходным кодом. Пройти таким образом путь, который предстоит пройти потом для «основной библиотеки»… При этом, естественно, я честно готов поддерживать саму библиотеку cpprt, которой посвящён данный цикл статей, если она кого-нибудь заинтересует.
Возможно, кто-то может посчитать мою задумку манипуляцией. Возможно… Но я оправдываю себя тем, что стараюсь здесь максимально подробно рассказывать о своём опыте, что может быть ценно для людей, которые захотят когда-нибудь решать аналогичные задачи.
Надеюсь, это окупит время, которое вы, дорогие читатели, потратите на ознакомление с данным циклом статей.
Данный цикл статей состоит из трёх частей:
Первая часть, ретроспективная. В ней подробно излагается история разработки микро-библиотеки.
Зачем читать: Данная часть предназначена для людей, которым интересно, какие шишки можно набить в процессе написания кода для регистрации информации о классах до старта функции main(). Серьёзным программистам С++ эта часть может показаться наивной и не интересной.
Вторая часть, публикационная. Рассказывает о подготовке репозитория библиотеки к публикации: выбор лицензии, организация структуры репозитория, изучение CMake, и т.д.
Зачем читать: Данная часть может быть полезна людям, у которых пылится на диске своя библиотека и которые хотят узнать, как можно представить эту библиотеку людям.
Третья часть, документационная. Здесь даётся взгляд на библиотеку с позиции пользователя: основные use cases использования, механизм регистрации классов, API для создания объектов по строковым именам классов, API для доступа к информации о наследованных классах через указание родительского класса во время исполнения программы. Эта часть содержит примеры кода, а также планы на будущее.
Зачем читать: Данная часть может быть полезна людям, желающим получить предоставляемый cpprt функционал в своём проекта, а также, снова, для желающих внести свою лепту в развитие библиотеки и, возможно даже, в развитие «основной библиотеки».
За сим я заканчиваю о цикле, и перехожу к сути конкретно данной статьи.
0. Вступление
Структура данной статьи:
Раздел №0. Данный раздел. Здесь рассказывается об аналогах библиотеки cpprt и даются некоторые мысли по поводу того, почему cpprt имеет право на существовании при наличии серьёзных аналогов.
Раздел №1. Зачем вообще понадобилась рефлексия в С++.
Раздел №2. Про поиск решения для возникшей задачи.
Раздел №3. Про первую реализацию механизма регистрации классов.
Раздел №4. Про то, какие проблемы имелись в первом решении о том, как я их исправлял.
Раздел №5. Добавление возможности регистрировать информацию о наследовании.
Раздел №6. Заключение.
И теперь, наконец, начинаем разговор.
На момент публикации данного цикла статей моя библиотека позволяет очень немного:
1. Создавать объекты по строковому имени их классов.
2. Получать информацию о наследниках классов в runtime.
Уже при работе над статьёй знакомый подкинул ссылку вот на этот обзор существующих библиотек. В нём упомянуты действительно мощные проекты. Они позволяют оснащать классы С++ толстенным слоем метаинформации и связанного с этим функционала: создавать объекты по строковым именам классов, получать перечень полей и методов классов во время исполнения программы, вызывать эти методы… Короче, позволяют купаться в целых океанах метаинформации. Мои достижения казались ничтожными в тени конкурентов. Однако, чуть успокоившись, я подумал: возможно, определённый смысл рассказать о проделанной мною работе есть.
Во-первых, в данных статьях много внимания уделяется ретроспективе: с какими трудностями я сталкивался в процессе работы, как я их решал. Это может быть полезно в качестве учебного материала.
С другой стороны, данный цикл рассказывает о том, как я готовил библиотеку к публикации: исследовал лицензии, разбирался с CMake, с миром открытого ПО и с тем, как принято оформлять свои проекты в этом мире. Это тоже может кому-нибудь пригодиться.
Ну и, наконец, да, пусть моя микро-библиотека действительно очень ограничена в возможностях по сравнению с существующими зубрам рефлексии С++. Но, возможно, в этом заключается её преимущество. Чем меньше библиотека – тем легче пользователю осознать особенности её работы и тем легче её интеграция в проект. Нужна навороченная рефлексия – используйте мощную библиотеку. Нужен набор из нескольких простых возможностей – можно попробовать cpprt.
1. Зачем мне понадобился подобный функционал
С++ известен своей эффективностью. Код, написанным на нём, выходит хорошо оптимизированным прежде всего возможно благодаря возможности тонкой настройки использования ресурсов компьютера. С++ — действительно позволяет писать очень хорошо оптимизированный код. Но за это нужно платить.
На алтарь эффективности, помимо всего прочего, кладётся возможность использования некоторых метаданных во время исполнения программы. Да, есть RTTI с его typeid и dynamic_cast. Есть Boost.TypeTraits. Но RTTI часто отключают для экономии ресурсов (ссылка по поводу), а Boost.TypeTraits, будучи библиотекой, построенной на шаблонах, не особо дружит с логикой времени исполнения программы и порождает много служебных специализаций своих шаблонов.
При этом иногда без возможности создания класса по его строковому имени не обойтись. Мне, например, такая возможность понадобилась в рамках механизма сериализации: для сохранения и загрузки объектов, хранящихся по указателям на родительский класс. Звучит, возможно, не очень понятно. Объясню подробнее.
Пусть у нас есть система для сохранения состояния объектов классов. Пусть использование её выглядит как-нибудь так:
// В рамках реализуемой схемы интерфейс ISerializable должен
// реализовывать любой класс, который можно сериализировать
class ISerializable {
virtual void save(Serializer *inSerializer) = 0;
virtual void load(Deserializer *inDeserializer) = 0;
}
class Base : public ISerializable {
virtual void save(Serializer *inSerializer) { /* сохранение состояния объекта через inSerializer */ }
virtual void load(Deserializer *inDeserializer) { /* загрузка состояния объекта через inDeserializer */ }
};
class Derived : public Base {
virtual void save(Serializer *inSerializer) { /* сохранение состояния объекта через inSerializer */ }
virtual void load(Deserializer *inDeserializer) { /* загрузка состояния объекта через inDeserializer */ }
};
Тестовый код, который сохраняет/загружает данные:
Base *theBase = new Derived();
. . .
Serializer theSerializer("save.txt");
theSerializer.save("name", theBase);
theSerializer.flush();
. . .
Deserializer theDeserializer("save.txt");
theBase = theDeserializer.load("name");
При сохранении объекта, хранящегося по указателю на родительский класс, сериализатор вызовет метод save(...) наследника за счёт полиморфизма. Но при загрузке десериализатор должен как-нибудь узнать, какой именно класс был у объекта в момент сохранения, чтобы иметь возможность создать его.
Отсюда следует, что на этапе сохранения нужно иметь некий идентификатор класса объекта, по которому на этапе загрузки мы сможем узнать, объект какого класса нужно создать и, воспользовавшись этим идентификатором, через некое API создать объект нужного класса.
Эта же мысль в виде кода:
class Serializer {
void saveInt(const char *inName, int inValue) { ... }
void saveString(const char *inName, const std::string &inValue) { ... }
// . . .
// Тут нам поможет пока полиморфизм – будет вызвана реализация
// метода save( ) у наследника за счёт виртуальности метода save(...)
void saveObject(ISerializable *inObject) { inObject->save(this); }
};
class Deserializer {
int loadInt(const char *inName) { ... }
void loadString(const char *inName, std::string &outValue) { ... }
// . . .
ISerializable *loadObject() {
// А вот тут нужно как-то узнать класс, который
// был у сохранённого объекта – иначе не ясно, объект
// какого класса создавать
ISerializable *theObject = new < ??? >( )
theObject->load(this);
return theObject;
}
};
Вывод: код методов для сохранения объектов должен быть расширен с использованием какого-нибудь API. Время прототипировать!
void saveObject(ISerializable *inObject) {
// Сохранение идентификатора, задающего тип сохраняемого объекта
this->saveObjectType(inObject->classID()); // <<<--– classID()
inObject->save(this);
};
ISerializable *loadObject() {
// Создание объекта через фабрику на основе загружаемого идентификатора
ISerializable *theObject = objectFabric().create(
this->loadObjectType());// <<<--– objectFabric().create(...)
theObject->load(this);
return theObject;
};
Накидав таким образом общий вид нужного API, я взялся за поиск решения, которое предоставляло бы подобный функционал.
2. Поиск решения
Для начала я погуглил, чтобы узнать, что по поводу нужного поведения думают люди. Хотелось чего-то легковесного, без тысячи зависимостей, без использования вспомогательных систем вроде Qt MOC (ещё статья на тему) и, помимо всего прочего, простого в использовании. Найти готовое решение в виде библиотеки, удовлетворяющей заданным критериям, не удалось.
Я начал думать, как реализовать подобный функционал самостоятельно.
Решения, в общем и целом, крутились вокруг описания фабричных функций и/или классов, которые регистрировались и использовались дальше через некий механизм, который выбирал нужную фабрику в зависимости от переданного имени класса. В рамках данной статьи я решил выделить вот этот ответ на stackoverflow (авторства Johannes Schaub, спасибо ему за идею), так как в нём воплотились основные подходы, найденные в сети.
Первая идея, предложенная Johannes Schaub, подразумевала использование шаблонной фабричной функции и сохранение её специализаций в словаре. Идея сама по себе мне не очень понравилась – механизм выглядел как-то незавершённо, без красивой объектной обёртки. Тем не менее, некоторые отголоски подобной реализации можно обнаружить в получившейся финальной реализации cpprt (использование фабричных классов).
Вторая идея от Johannes Schaub: включение механизма регистрации фабрик в конструкторы классов, которые [конструкторы] вызывались при создании статических объектов этих классов. Эта идея мне понравилась, особенно с учётом предложенных макросов: макросы позволяли скрывать детали механизма регистрации, за счёт чего этот механизм можно было бы менять от версии к версии библиотеки без побочных эффектов для пользователей кода.
Привожу здесь решение Johannes Schaub с минимальными изменениями и со своими комментариями:
// in base.hpp:
// Шаблонная фабричная функция. Создаёт объект произвольного типа.
// Здесь упоминается некий класс Base, который Johannes, как я понимаю,
// рассматривает в качестве базового для всех регистрируемых классов.
// В принципе, из фабричной функции можно было бы возвращать указатель
// на void, обобщив, таким образом, решение.
// Но если выделить некий базовый регистрируемый класс Base, то можно
// сделать механизм регистрации типов более удобным
|| (о чём будет рассказано дальше).
template< typename T > Base * createT() { return new T; }
// Базовый класс для "регистраторов" фабричных функций
struct BaseFactory {
// Словарь, с помощью которого мы сможем получать указатель
// на специализацию фабричной функции по строковому имени
// класса. Такой словарь можно создать за счёт того, что все
// специализации функции createT имеют одинаковую сигнатуру
// (возвращают одинаковый тип и отличаются только реализацией).
typedef std::map< std::string, Base*(*)() > map_type;
// Метод для создания объектов по строковым именам их
// классов... Думаю, тут всё и так ясно.
static Base *createInstance(std::string const& s) {
map_type::iterator it = getMap()->find(s);
return (it == getMap()->end()) ? NULL : it->second();
}
protected:
// Этот метод, а также следующий указатель-статическая
// переменная map реализуют вместе шаблон синглтон. В этом
// синглтоне хранится глобальный словарь, связывающий
// фабричные функции со строковыми именами.
static map_type * getMap() {
// Комментарий Johannes Schaub:
// never delete'ed. (exist until program termination)
// because we can't guarantee correct destruction order
if(!map) { map = new map_type; }
return map;
}
private:
static map_type * map;
};
// Класс, в конструкторе связывающий специализацию фабричной
// функции со строковым именем. Фактически, вызов конструктора
// данного класса – это всё, что нужно, чтобы для класса T можно было
// выполнять конструирование объектов по строковому имени класса.
template< typename T >
struct DerivedRegister : BaseFactory {
DerivedRegister(std::string const& s) {
getMap()->insert(std::make_pair(s, &createT< T >));
}
};
// in derivedb.hpp
// Тестовый класс, который регистрируется через механизм,
// созданный Johannes Schaub. Насколько я понимаю, Johannes Schaub
// забыл унаследовать его от Base. Я исправил это:
class DerivedB : public Base {
...;
private:
// Здесь мы имеем предекларацию статической переменной reg.
static DerivedRegister< DerivedB > reg;
};
// in derivedb.cpp:
// Здесь (в файле, где расположена реализация класса DerivedB)
// описывается сама статическая переменная, в конструкторе которой
// выполняется связывание фабричного метода со строковым именем.
// Во время статической инициализации будет вызван конструктор
// специализации DerivedRegister< DerivedB > и пройдёт связывание
// фабричной функции с именем DerivedB
DerivedRegister< DerivedB > DerivedB::reg("DerivedB");
Johannes Schaub предложил также макросы для упрощения регистрации типов. Они позволяют закрыть особенности реализации системы регистрации и делают код лаконичнее:
#define REGISTER_DEC_TYPE(NAME)
static DerivedRegister<NAME> reg
#define REGISTER_DEF_TYPE(NAME)
DerivedRegister<NAME> NAME::reg(#NAME)
Я взял решение Johannes Schaub за основу, чуть изменив его по своему вкусу.
3. Первая реализация
В решении Johannes Schaub имелся шаблонный класс DerivedRegister, объекты специализаций которого создавались как статические поля в рамках регистрируемых классов (static DerivedRegister reg). Первым делом я решил перенести фабричные функции в класс DerivedRegister в качестве фабричных методов. За счёт этого, помимо упрощения кода, появлялась возможность расширять метаинформацию о регистрируемых классах простым добавлением полей в класс DerivedRegister.
Также я перенёс информацию о строковом имени классов в свой аналог DerivedRegister, начав таким образом использовать его, как хранилище метаинформации (пока в качестве метаинформации выступало только строковое имя класса).
Вышла, фактически, реализация шаблона проектирования абстрактная фабрика с кое-какими наворотами для возможности выполнения кода регистрации метаданных до старта функции main():
// Обобщённый менеджер классов (аналог DerivedRegister из решения
// Johannes Schaub). Содержит в себе типичную для любого класса
// метаинформацию. Требует от наследников реализовывать фабричный
// метод createAbstractObject()
//
class IClassManager {
private:
const char *_name;
public:
IClassManager(const char *inClassName) : _name(inClassName) { }
//-– Workflow
const const char *name() const { return _name; }
virtual IManagedClass *createAbstractObject() = 0;
};
//-----------------------------------------------------------------
// Реализация менеджера классов. Реализует фабричный
// метод и берёт на себя задачу регистрации самого себя
// в системе регистрации метаинформации о классах.
// Регистрация выполняется в рамках конструктора.
template<typename T_Type>
class ClassManager : public IClassManager {
public:
// Регистрация фабрики выполняется, как и в коде
// Johannes Schaub, вот тут, в конструкторе:
ClassManager(const char *inClassName) : IClassManager(inClassName) {
globalRuntime.registerClass(this);
}
T_Type *createObject() { return new T_Type(); }
virtual IManagedClass *createAbstractObject() { return createObject(); }
};
Переменная globalRuntime – это глобальный объект, который выделен для управления всей метаинформационной машинерией. Фактически, просто объектная обёртка вокруг вектора, хранящего объекты специализаций ClassManager.
Рассмотрим код класса, объект которого – это globalRuntime. Думаю, суть его работы будет ясна без комментариев:
class CPPRuntime {
private:
std::vector< IClassManager * > _registries;
IClassManager *managerByName(const char *inName);
public:
CPPRuntime() : _registries() { }
void registerClass(IClassManager *inClass);
IManagedClass *createObject(const char *inClassName);
};
//-----------------------------------------------------------------------------
extern CPPRuntime globalRuntime;
IClassManager *CPPRuntime::managerByName(const char *inName) {
for (size_t theIndex = 0, theSize = _registries.size();
theIndex < theSize; ++theIndex)
{
if (0 == strcmp(_registries[theIndex]->name(), inName)) {
return _registries[theIndex];
}
}
return NULL;
}
//-– Registering
void CPPRuntime::registerClass(IClassManager *inClass) {
_registries.push_back(inClass);
}
//-– Public API
IManagedClass *CPPRuntime::createObject(const char *inClassName) {
IClassManager *theRegistry = managerByName(inClassName);
//TODO: Through an exception if no class found
return theRegistry->createAbstractObject();
}
//-----------------------------------------------------------------------------
CPPRuntime globalRuntime;
Оставалось описать базовый класс IManagedClass (аналог класса Base из решения Johannes Schaub) и создать макросы для упрощения механизма регистрации классов.
В связи с описанием базового класса IManagedClass, следует вспомнить API, ради которого всё затевалось:
1. API для загрузки данных. Возможность создавать объект по идентификатору:
objectFabric().create(inObjectID)
Это готово, метод globalRuntime.createObject(«ClassName»).
2. API для сохранения данных. Возможность получать идентификатор класса объекта:
object->classID()
Решение, предложенное Johannes Schaub, не включало в себя подобное API. Его придётся реализовывать самостоятельно.
Я набросал use case для наследования IManagedClass на примере одного класса. Думаю, тут тоже все будет более или менее ясно без лишних комментариев:
//---------------------------------------------------------------------------------------------------
// Базовый класс, с которым взаимодействует разрабатываемая система.
// Задаёт принципы получения метаинформации для объектов всех
// регистрируемых классов.
class IClassManager {
public:
virtual IClassManager *getRegistry() = 0;
};
//---------------------------------------------------------------------------------------------------
// Класс пользователя, наследующий базовый класс. Пользователь должен
// наследовать базовые классы своих иерархий от базового класса чтобы
// иметь возможность привязывать в ним метаданные и получить доступ
// к метаданным во время исполнения программы.
class TestClass : public IClassManager {
public:
// Служебный код для регистрации класса. Его стоит закрыть за макросом.
static ClassManager< TestClass > gClassManager;
virtual IClassManager *getRegistry() { return &gClassManager; }
public:
};
// Вызов конструктора объекта gClassManager должен зарегистрировать
// фабрику для создания экземпляров класса TestClass по строковому
// имени этого класса. Данный код тоже стоило бы закрыть за макросом.
ClassManager< TestClass > TestClass::gClassManager("TestClass");
Я запустил код с использованием этого класса, а именно, отдебажил вызов globalRuntime.createObject(«TestClass»). Объект благополучно создался.
Оставалось описать макросы, которые сняли бы с пользователя необходимость вручную делать копи-пасту кода для регистрации классов:
#define CPPRT_DECLARATION(M_ClassName)
public:
static ClassManager< M_ClassName > gClassManager;
virtual IClassManager *getClassManager() { return &gClassManager; }
protected:
// Что приятно, это решение будет работать в том числе для классов, вложенных в namespace.
#define CPPRT_IMPLEMENTATION(M_ClassName) ClassManager< M_ClassName > M_ClassName::gClassManager(#M_ClassName);
Примечание: Тут впервые упоминается префикс CPPRT. Это сокращение от слов C Plus Plus Run Time.
Макрос был готов. Принцип его использования не отличался от принципов использования макроса, предложенного Johannes Schaub:
//--– TestClass.h ---
class TestClass : public IClassManager {
// Этот макрос лучше всего прописывать в начале декларации
// класса – чтобы гарантированно не поменять спецификатор
// доступа, задаваемый в начале декларации классов как protected
CPPRT_DECLARATION(TestClass)
. . .
};
Дальше создаём файл реализации. В файле реализации используем макрос для описания статического объекта, хранящего информацию о классе CPPRT_IMPLEMENTATION (вспоминаем, через макрос CPPRT_DECLARATION мы этот объект только декларировали).
//--– TestClass.cpp ---
CPPRT_IMPLEMENTATION(TestClass)
Готово! Дрожа от нетерпения, я обвязал описанными макросами иерархию своих классов, реализовал методы сериализации/десериализации с использованием свежесозданного API, запустил код…
Ура! Объекты корректно сохранялись и загружались, сохраняя свой тип! Я хорошенько всё оттестировал, сохраняя объекты разных классов. Работало! Совершенно не ожидал, что сходу всё так просто заведётся…
4. Первые проблемы и борьба с ними
… и я был совершенно прав. На самом деле, в написанном коде была одна опасная ошибка. В какой-то момент – а именно, когда я добавил очередной зарегистрированный класс – всё поломалось. Некоторые классы перестали регистрироваться, причём признак, по которому отваливалась регистрация классов, я уловить не мог. Рандом какой-то. Я потратил полчаса времени, переходя с дебага на логирование и обратно. Конструктор ClassManager для каждого класса вызывался. Регистрации, соответственно, проходили… Но когда дело доходило до создания объекта некоего класса globalRuntime.createObject(«SomeHellClass»), то оказывалось, что в массиве зарегистрированных менеджеров классов отсутствует ClassManager для класса SomeHellClass.
Минут тридать мне казалось, что кто-то из нас двоих сошёл с ума: либо я, либо С++. И, как всегда, выяснилось, что с ума сошёл всё-таки я. Всё стало на свои места, когда я попробовал добавлять/удалять исходники для передачи их на компиляцию. Каждый раз при этом менялся набор классов, регистрация которых «отваливалась». То есть дело было в порядке компиляции исподников.
Люди, которые внимательно читали код, думаю, уже поняли в чём причина ошибки.
Обратите внимание на то, как был определён globalRuntime:
//--– CPPRuntime.h ---
extern CPPRuntime globalRuntime;
//--– CPPRuntime.cpp ---
CPPRuntime globalRuntime;
Это глобальный объект. Не вложенный в функцию и создаваемый, таким образом, в момент первого вызова функции, а просто глобальный объект.
При этом вспомним реализацию конструктора шаблонного класса ClassManager, в котором объекты специализации ClassManager регистрируют самих себя в рамках объекта globalRuntime:
//--– ClassManager.h ---
ClassManager(const char *inClassName)
: IClassManager(inClassName)
{
globalRuntime.registerClass(this);
}
И вспомним ещё, как создаются объекты специализаций ClassManager (они описываются через макросы):
#define CPPRT_DECLARATION(M_ClassName)
public:
static ClassManager< M_ClassName > gClassManager;
virtual IClassManager *getClassManager() { return &gClassManager; }
protected:
#define CPPRT_IMPLEMENTATION(M_ClassName)
ClassManager< M_ClassName > M_ClassName::gClassManager(#M_ClassName);
Объекты специализаций ClassManager (например, объект SomeHellClass::gClassManager) тоже являются глобальными переменными!.. Должны являться глобальными переменными: ведь важно, чтобы для каждого такого объекта выполнялся конструктор до запуска main(), иначе не будет выполняться регистрация таких объектов.
А теперь вспомним: в каком порядке вызываются конструкторы глобальных и статических переменных в С++?.. Да, верно. В произвольном порядке (stackoverflow, цитата из Стандарта там тоже имеется). Что же из этого следует?
А из этого следует возможность того, что конструктор объекта globalRuntime может вызываться после того, как вызвались конструкторы каких-либо специализаций ClassManager. Ситуация очень нехорошая: в конструкторах объектов специализаций ClassManager (объекты с именами gClassManager) может происходить обращение к методам объекта, который ещё не было создан (globalRuntime). Такое поведение могло привести к какому-нибудь свалу, но не приводило – что ещё хуже в данном случае. Типичный undefined behavior.
Не делайте так. Никогда.
Фикс проблемы был очевиден: для корректного доступа к объекту нужно было реализовать одну из вариаций синглтона Мейерса (более подробная статья о синглтонах, там можете найти и про этот синглтон):
extern CPPRuntime globalRuntime;
//--– CPPRuntime.h ---
CPPRuntime &globalRuntime();
CPPRuntime globalRuntime;
//--– CPPRuntime.cpp ---
CPPRuntime &globalRuntime() {
// Да, знаю, такой подход не дружит с многопоточностью, но
// это пока не критично и легко исправляется – ведь у нас не
// header only код. Пулреквестните, если желаете thread safe...
static CPPRuntime sCPPRuntime;
return sCPPRuntime;
}
Доступ через функцию обеспечивает гарантированное создание объекта класса CPPRuntime в любой момент времени, из любого места в коде.
После фикса оставалось только внести изменение в конструктор специализаций ClassManager, в котором осуществлялся доступ к объекту класса CPPRuntime:
ClassManager(const char *inClassName)
: IClassManager(inClassName)
{
// Теперь обращаемся к объекту CPPRuntime безопасным путём,
// через функцию globalRuntime()
globalRuntime().registerClass(this);
}
Я хорошенько погонял код по разным иерархиям объектов, изменяя перечень исходников, отправляемых на компиляцию – чтобы точно проверить, что теперь-то уж всё хорошо.
Всё действительно было хорошо.
Шло время, основная библиотека жила своей жизнью, развивалась. И вот в какой-то момент понадобился новый функционал…
5. Генеалогия классов
В какой-то момент возникла необходимость собирать информацию о наследниках классов во время исполнения программы. Я не буду вдаваться в детали, зачем именно это понадобилось – об этом расскажу как-нибудь, когда придёт время опубликовать основную библиотеку. Чтобы вести более предметный разговор, предположим, что это было нужно для отладочных целей: для удобства просмотра информации о регистрированных классах для особо крупных проектов.
Решение из boost (is_base_of) не подошло по причине его ориентированности на использование в compile time (об особенностях работы is_base_of и об API этой структуры в документации boost).
И вот оно, опять… Время прототипировать!
API виделось где-то таким:
std::vector< IClassManager * > theChildManagers;
// Сохранение в массив объектов специализаций ClassManager
// (приведённых к общему для них базовому типу IClassManager)
// для классов-наследников класса BaseClass.
globalRuntime(IClassManager).getChildren(BaseClass::gClassManager, theChildManagers);
// И использование полученных данных – распечатка имён
// классов-наследников класса BaseClass:
std::cout << "Children of class " << BaseClass::gClassManager.name() << std::endl;
for (size_t theIndex = 0, theSize = theChildManagers.size(); theIndex < theSize; ++theIndex) {
std::cout << theChildManagers[theIndex]->name() << std::endl;
}
Ясно, что имея подобное API, можно было бы печатать полные деревья наследования классов, начиная с любого родительского класса.
Набросав таким образом ориентировочный вид API для доступа к новым метаданным о наследовании, я принялся думать, как реализовать регистрацию с минимальным усложнением API регистрации классов.
Я выбрал самое простое решение: добавить в ClassManager массивы для хранения указателей на ClassManager наследников и базовых классов. Вот так вот:
class IClassManager {
private:
. . .
// Массивы для хранения информации о
// родственных отношениях класса
std::vector< IClassManager *> _parents;
std::vector< IClassManager *> _children;
protected:
// Метод, добавляющий ссылку на ClassManager для
// базовых классов
void setParent(IClassManager *inParent) {
_parents.push_back(inParent);
inParent->_children.push_back(this);
}
. . .
};
//-----------------------------------------------------------------
template<typename T_Type>
class ClassManager : public IClassManager {
. . .
public:
// Изменения в конструкторах, думаю,
// не требуют комментариев
ClassManager(const char *inClassName)
: IClassManager(inClassName)
{
globalRuntime().registerClass(this);
}
ClassManager(const char *inClassName,
IClassManager *inParent0)
: IClassManager(inClassName)
{
globalRuntime().registerClass(this);
setParent(inParent0);
}
ClassManager(const char *inClassName,
IClassManager *inParent0,
IClassManager *inParent1)
: IClassManager(inClassName)
{
globalRuntime().registerClass(this);
setParent(inParent0);
setParent(inParent1);
}
// И т.д., для большего количества базовых классов
. . .
};
После этого нужно было обновить макросы для регистрации классов с разным числом базовых классов:
// 0 parents
#define CPPRT_IMPLEMENTATION_0(M_Class)
ClassManager< M_Class > M_Class::gClassManager(#M_Class);
// 1 parent
#define CPPRT_IMPLEMENTATION_1(M_Class, M_BaseClass0)
ClassManager< M_Class > M_Class::gClassManager(#M_Class,
&M_BaseClass0::gClassManager);
// 2 parents
#define CPPRT_IMPLEMENTATION_2(M_Class, M_BaseClass0, M_BaseClass1)
ClassManager< M_Class > M_Class::gClassManager(#M_Class,
&M_BaseClass0::gClassManager,
&M_BaseClass1::gClassManager);
// И т.д., для большего количества базовых классов
Теперь остаётся добавить в CPPRuntime метод, с помощью которого будем обходить базовые классы для всех зарегистрированных специализаций ClassManager. Код для обхода наверняка знаком тем, кто имел дело с обработкой деревьев:
class CPPRuntime {
. . .
private:
// Функция, реализующая рекурсивный обход
void CPPRuntime:: getClassRegistries_internal(
IClassManager *inClassManager,
std::vector<IClassManager *> &outRegistries)
public:
// Функция, входящая в интерфейс класса CPPRuntime.
// Просто вызывает из себя getClassRegistries_internal(...)
// Так сделано, чтобы не засорять пользовательское API возможными
// деталями реализации обхода дерева.
void getChildren(IClassManager *inBaseRegistry,
std::vector< IClassManager *> inChildRegistries);
. . .
};
. . .
. . .
void CPPRuntime::getClassRegistries_internal(
IClassManager *inRegistry,
std::vector<IClassManager *> &outRegistries)
{
// Добавляем текущий ClassManager в результирующий список
outRegistries.push_back(inRegistry);
std::vector< IClassManager * > &theChilds = inRegistry->_childs;
for (size_t theIndex = 0, theSize = theChilds.size(); theIndex < theSize;
++theIndex)
{
// Вызываем эту же функцию для всех наследников
// текущего ClassManager
getClassRegistries_internal(theChilds[theIndex], outRegistries);
}
}
void CPPRuntime::getChildren(IClassManager *inBaseRegistry,
std::vector< IClassManager *> &outRegistries)
{
getClassHeirarhieNames_internal(inBaseRegistry, outRegistries);
}
. . .
Данный код не собирался из-за нарушения инкапсуляции (доступ к полю inRegistry->_childs в методе CPPRuntime:: getClassRegistries_internal). Чтобы не засорять пользовательский интерфейс классов ClassManager геттером поля _children, я использовал ключевое слово friend. Согласен, что решение неуклюжее, но лишний геттер это тоже плохо:
template< typename T_Type >
class ClassManager : public IClassManager {
. . .
// Чтобы CPPRuntime имел доступ к _children.
friend class CPPRuntime;
. . .
};
После добавления friend код собрался. Больше того, код заработал — я получил распечатанный список наследников класса.
Но я теперь был научен горьким опытом. На всякий случай решил сразу потестить, добавляя/удаляя файлы в рамках проекта. И опять вылез косяк: сплошь и рядом информация о наследовании не заполнялась… Причём массивы _parents заполнялись правильно, а вот _children – нет. Догадались в чём проблема?
Проблема всё та же: неопределённый порядок вызова конструкторов глобальных переменных. Вся инициализация информации о классе происходила в конструкторе ClassManager:
#define CPPRT_IMPLEMENTATION_NO_PREFIX_1(M_Class, M_BaseClass0)
ClassManager< M_Class > M_Class::gClassManager(
#M_Class,
&M_BaseClass0::gClassManager);
В данном коде передаётся адрес объекта специализации ClassManager родительского класса (&M_BaseClass0::gClassManager) в объект специализации ClassManager регистрируемого класса (M_Class::gClassManager), после чего в конструкторе вызывается метод setParent(...):
class IClassManager {
. . .
void setParent(IClassManager *inParent) {
_parents.push_back(inParent);
inParent->_children.push_back(this); // <<<--– ВОТ ЭТА СТРОЧКА!
}
. . .
};
Указанная в коде строчка обращается к полю ClassManager родительского класса… Но конструктор объекта специализации ClassManager родительского класса может быть ещё не вызван, ведь оба этих объекта на равных правах статических объектов классов!
Опять имеем обращение к полю ещё не созданного объекта.
Я сел за поиск решения сложившейся проблемы. И вот тут возникла дилемма. Дело в том, что в отличие от синглтона globalRuntime(), создание объектов специализаций ClassManager должно происходить гарантированно для каждого класса, а не по запросу. Создание этих объектов несёт функциональную нагрузку: в рамках конструкторов специализаций ClassManager выполняется логика регистрации классов и заполнения метаинформации о классах. Если эта логика не будет выполнена в рамках gClassManager для некоего класса SomeHellClass, метаинформация о наследовании для классов, базовых для SomeHellClass, окажется не заполненной.
//----– Declaration.h -----
class Base {
public:
static ClassManager<Base> *getClassManager( );
};
class Child {
public:
static ClassManager<Child> *getClassManager( );
};
class ChildOfChild {
public:
static ClassManager<ChildOfChild> *getClassManager( );
};
//----– Declaration.cpp -----
ClassManager<Base> *Base::getClassManager() {
static ClassManager<Base> gClassManager("Base");
return &gClassManager;
}
ClassManager<Child> *Child::getClassManager() {
static ClassManager<Child> gClassManager("Child",
Base::getClassManager());
return &gClassManager;
}
ClassManager<ChildOfChild> *ChildOfChild::getClassManager() {
static ClassManager<ChildOfChild> gClassManager("ChildOfChild",
Child::getClassManager());
return &gClassManager;
}
А теперь давайте глянем, что будет, если мы захотим пройтись по наследникам, например, класса Child. Для этого, по крайней мере, придётся получить доступ к ClassManager для данного класса. Вот так:
ClassManager<Child> *theManager = Child::getClassManager();
При вызове getClassManager() будет выполняться следующий код:
. . .
static ClassManager<Child> gClassManager("Child", Base::getClassManager()); // <<---!!!
return &gClassManager;
. . .
Здесь будет создан объект, в который через вызов Base::getClassManager() будет передана информация о родительском классе… НО информация о наследниках при этом не будет получена! Она будет получена только при вызове ChildOfChild::getClassManager(). Чтобы окончательно прояснить ситуацию, вспомним, как регистрируется информация о наследовании:
// Конструктор специализации ClassManager
. . .
globalRuntime().registerClass(this);
IClassManager::setParent(inParent0); // <<<--- !!!
. . .
Вот. Строчка, из-за которой возникает проблема, выделена восклицательными знаками. Регистрация информации о наследниках происходит во время регистрации специализации ClassManager для класса-наследника. Если getClassManager() для наследника не будет вызван, в массив _child для родительского класса не попадёт указатель на объект специализации ClassManager для наследника.
И главная проблема в том, что по-другому регистрировать информацию о наследовании невозможно – это будет противоречить принципам, заложенным в механизме наследования самого С++, а значит, будет громоздко и неудобно в использовании.
Таким образом, с одной стороны, во время инициализаций ClassManager должно быть гарантировано, что конструктор для родителя уже выполнился, и гарантировать это можно только если организовать доступ к объекту специализации ClassManager через вызов функции, как это делается для объекта CPPRuntime.
С другой же стороны, для каждого класса инициализация должна быть гарантированна, значит, для каждого класса должна быть гарантированно вызвана функция для доступа к объекту специализации ClassManager. А вызывать метод или функцию вне коллстека функции main() разрешается, только если её исполнение входит в dynamic initialization для глобальных переменных. Поэтому нужно, чтобы результат исполнения функции чему-нибудь присваивался.
Первый фикс проблемы был сделан, исходя из следующего принципа: несмотря на то, что объекты не инициализированы, они всё же существуют и, значит, обладают адресами. Соответственно, в момент регистрации можно сохранять указатели в неком массиве, даже когда конструктор ClassManager не был вызван – а для сохранения информации об отношении между классами использовать индексы указателей в рамках этого массива:
class IClassManager {
. . .
std::vector< int > _parentsIndexes;
std::vector< int > _childIndexes;
. . .
void setParent(IClassManager *inParent) {
_parentIndexes.push_back(globalRuntime().indexOf(inParent));
inParent->_childIndexes.push_back(globalRuntime().indexOf(this));
}
. . .
};
class CPPRuntime {
. . .
std::vector< IClassManager * > _registries;
. . .
int indexOf(IClassManager *inRegistry) {
for(int theIndex = 0, theSize = _registries.size();
theIndex < theSize; ++theIndex)
if (_registries[theIndex] == inRegistry) return theIndex;
_registries.push_back(inRegistry);
return _registries.size() – 1;
}
. . .
};
Решение было некрасивым и создавало ещё один уровень косвенной адресации, через индексы, что снижало эффективность кода, причем не только во время регистрации классов, но при любом запросе метаинформации (например, при запросе родственников классов).
Ещё один вариант – тоже не особо красивый – использовал обёртку-синглтон для gClassManager. Привожу здесь макрос, который использовался, пока была такая реализация:
. . .
// 1 parent
#define CPPRT_IMPLEMENTATION_1(M_Class, M_BaseClass0)
ClassManager< M_Class > *M_Class::getRegistry() {
static ClassManager< M_Class > gClassManager(#M_Class,
&M_BaseClass0:: getRegistry() );
return &gClassManager;
}
// Эта конструкция гарантирует, что хотя бы раз пройдёт конструктор
// объекта для специализации ClassManager< M_Class >, а значит, класс
// будет зарегистрирован. Имя переменной делаем таким, чтобы не было
// проблем с повторным использованием макроса в одном cpp-файле.
// Ясно, что переменная при этом лежала мёртвым грузом в течение всей
// работы программы. Не особо большие потери памяти – но грязно,
// грязно ведь!
char __dummy__##M_Class = (char)M_Class::getRegistry();
. . .
И уже во время работы над данной статьёй нашлось более изящное решение. Помог один из нюансов пункта 3.6.2 Стандарта. Вот ссылки: на любимый мною stackoverflow, через который нашёлся этот пункт и на стандарт (увы, не знаю, как дать ссылку на раздел в PDF, сами найдите пункт 3.6.2 по оглавлению).
Variables with static storage duration (3.7.1) or thread storage duration (3.7.2) shall be zero-initialized (8.5) before any other initialization takes place
Это значит, что мы можем применить такую нотацию (псевдокод):
getPointer() {
// Проверка корректна, ведь до вызова функции, благодаря
// статической zero-value инициализации, указатель
// гарантированно будет иметь значение, равное нулю
if (!Class::gPointer) { Class::gPointer = initializeValue(); }
return Class::gPointer;
}
Class::gPointer = getPointer();
Я использовал этот подход, после чего провёл полную ревизию библиотечного кода. При этом семейство классов ClassManager переехало жить в качестве внутренних классов в CPPRuntime, а сам CPPRuntime получил более широкие полномочия.
В результате указанных изменений удалось ужать код библиотеки до размера пары файлов суммарным объёмом в 450 строчек кода.
6. Заключение
Оставляю здесь ссылки на репозитории библиотеки:
1. Ссылка на «черновой» репозиторий bitbucket. Так выглядел проект до переезда на GitHub. По мотивам истории этого репозитория была написана данная статья.
2. Ссылка на репозиторий GitHub — такой, каким он получился после перевода сборки проекта на CMake (о чём рассказывается в следующей статье).
Собственно, всё. На этом данная статья кончается. Спасибо за внимание!
Автор заранее благодарит читателей за указания на ошибки в статье, конструктивные советы и пожелания
Автор: semenyakinVS