Хочу поделиться своим опытом разработки метасистемы для C++ и встраивания различных скриптовых языков.
Сравнительно недавно начал писать свой игровой движок. Разумеется, как и в любом хорошем движке встал вопрос о встраивании скриптового языка, а лучше даже нескольких. Безусловно, для встраивания конкретного языка уже есть достаточно инструментов (например, luabind для Lua, boost.python для Python), и свой велосипед изобретать не хотелось.
Начал со встраивания простого и шустрого Lua, а для биндинга использовал luabind. И он выглядит действительно неплохо.
class_<BaseScript, ScriptComponentWrapper>("BaseComponent") .def(constructor<>()) .def("start", &BaseScript::start, &ScriptComponentWrapper::default_start) .def("update", &BaseScript::update, &ScriptComponentWrapper::default_update) .def("stop", &BaseScript::stop, &ScriptComponentWrapper::default_stop) .property("camera", &BaseScript::getCamera) .property("light", &BaseScript::getLight) .property("material", &BaseScript::getMaterial) .property("meshFilter", &BaseScript::getMeshFilter) .property("renderer", &BaseScript::getRenderer) .property("transform", &BaseScript::getTransform)
Читается легко, класс регистрируется просто и без проблем. Но это решение исключительно для Lua.
Вдохновившись скриптовой системой Unity, понял, что однозначно должно быть несколько языков в системе, а также возможность их взаимодействия между собой. И тут такого рода инструменты, как luabind, дают слабину: в большинстве своем они написаны с использованием шаблонов C++ и генерируют код только для специфического языка. Каждый класс нужно зарегистрировать в каждой системе. При этом необходимо добавить множество заголовочных файлов и вручную вписать все в шаблоны.
А ведь хочется, чтобы была общая база типов для всех языков. А также возможность загрузить информацию о типах из плагинов прямо в рантайме. Для этих целей binding библиотеки не подходят. Нужна настоящая метасистема. Но тут тоже оказалось не все гладко. Готовые библиотеки оказались довольно громоздкими и неудобными. Существуют и весьма изящные решения, но они тянут за собой дополнительные зависимости и требуют использования специальных инструментов (например, Qt moc или gccxml). Есть, конечно же, и довольно симпатичные варианты, такие как, например, библиотека для рефлексии Camp. Выглядит она почти также, как и luabind:
camp::Class::declare<MyClass>("FunctionAccessTest::MyClass") // ***** constant value ***** .function("f0", &MyClass::f).callable(false) .function("f1", &MyClass::f).callable(true) // ***** function ***** .function("f2", &MyClass::f).callable(&MyClass::b1) .function("f3", &MyClass::f).callable(&MyClass::b2) .function("f4", &MyClass::f).callable(boost::bind(&MyClass::b1, _1)) .function("f5", &MyClass::f).callable(&MyClass::m_b) .function("f6", &MyClass::f).callable(boost::function<bool (MyClass&)>(&MyClass::m_b)); }
Правда производительность подобных «красивых» решений оставляет желать лучшего. Конечно же, как и любой «нормальный» программист, я решил написать свою метасистему. Так появилась библиотека uMOF.
Знакомство с uMOF
uMOF — кроссплатформенная open source библиотека для метапрограммирования. Концептуально напоминает Qt, но выполнена с помощью шаблонов, от которых в свое время отказались сами Qt. Они это сделали ради читаемости кода. И так реально быстрее и компактнее. Но, использование moc компилятора приводит в полную зависимость от Qt. Это не всегда оправдано.
Перейдем все же к делу. Чтобы сделать доступной для пользователя метаинформацию в классе наследнике Object нужно прописать макросы OBJECT с иерархией наследования и EXPOSE для объявления функций. После этого становится доступен API класса, в котором хранится информация о классе, функцияx и публичных свойствах.
class Test : public Object { OBJECT(Test, Object) EXPOSE(Test, METHOD(func), METHOD(null), METHOD(test) ) public: Test() = default; float func(float a, float b) { return a + b; } int null() { return 0; } void test() { std::cout << "test" << std::endl; } }; Test t; Method m = t.api()->method("func(int,int)"); int i = any_cast<int>(m.invoke(&t, args)); Any res = Api::invoke(&t, "func", {5.0f, "6.0"});
Пока определение метаинформации инвазивно, но планируется и внешний вариант для более удобной обертки стороннего кода.
Из-за использования продвинутых шаблонов uMOF получился очень быстрым, при этом довольно компактным. Это же привело и к некоторым ограничениям: т.к. активно используются возможности C++11, не все компиляторы подойдут (например, чтобы скомпилировать на Windows, нужен самый последний Visual C++ November CTP). Также использование шаблонов в коде не всем понравится, поэтому все завернуто в макросы. Между тем макросы скрывают большое количество шаблонов и код выглядит довольно аккуратно.
Дабы не быть голословным дальше привожу результаты бенчмарков.
Результаты тестирования
Я сравнивал метасистемы по трем параметрам: время компиляции/линковки, размер исполняемого файла и время вызова функции в цикле. В качестве эталона я взял пример с нативным вызовом функций. Испытуемые тестировались на Windows под Visual Studio 2013.
| Framework | Compile/Link time, ms | Executable size, KB | Call time spent*, ms |
|---|---|---|---|
| Native | 371/63 | 12 | 2 (45**) |
| uMOF | 406/78 | 18 | 359 |
| Camp | 4492/116 | 66 | 6889 |
| Qt | 1040/80 (129***) | 15 | 498 |
| cpgf | 2514/166 | 71 | 1184 |
** Force no inlining
*** Meta object compiler
Для наглядности тоже самое в виде графиков.



Я также рассматривал еще несколько библиотек:
- Boost.Mirror;
- XcppRefl;
- Reflex;
- XRtti.
Но они не попали на роль испытуемых по разным причинам. Boost.Mirror и XcppRefl выглядят перспективно, но пока находятся на стадии активной разработки. Reflex требует GCCXML, какой либо адекватной замены для Windows я не нашел. XRtti опять же в текущем релизе не поддерживает Windows.
Что под капотом
Итак, как это все работает. Скорость и компактность библиотеке дают шаблоны с функциями в качестве аргументов, а также variadic шаблоны. Вся мета информация по типам организована как набор статических таблиц. Никакой дополнительно нагрузки в рантайме нет. А простая структура в виде массива указателей не дает коду сильно распухнуть.
template<typename Class, typename Return, typename... Args> struct Invoker<Return(Class::*)(Args...)> { typedef Return(Class::*Fun)(Args...); inline static int argCount() { return sizeof...(Args); } inline static const TypeTable **types() { static const TypeTable *staticTypes[] = { Table<Return>::get(), getTable<Args>()... }; return staticTypes; } template<typename F, unsigned... Is> inline static Any invoke(Object *obj, F f, const Any *args, unpack::indices<Is...>) { return (static_cast<Class *>(obj)->*f)(any_cast<Args>(args[Is])...); } template<Fun fun> static Any invoke(Object *obj, int argc, const Any *args) { if (argc != sizeof...(Args)) throw std::runtime_error("Bad argument count"); return invoke(obj, fun, args, unpack::indices_gen<sizeof...(Args)>()); } };
Немаловажную роль в эффективности также играет класс Any, который позволяет достаточно компактно хранить типы и информацию о них. Основой послужил класс hold_any из библиотеки boost spirit. Здесь также активно используются шаблоны, чтобы эффективно оборачивать типы. Типы меньше указателя по размеру хранятся непосредственно в void*, для более крупных типов указатель ссылается на объект типа.
template<typename T> struct AnyHelper<T, True> { typedef Bool<std::is_pointer<T>::value> is_pointer; typedef typename CheckType<T, is_pointer>::type T_no_cv; inline static void clone(const T **src, void **dest) { new (dest)T(*reinterpret_cast<T const*>(src)); } }; template<typename T> struct AnyHelper<T, False> { typedef Bool<std::is_pointer<T>::value> is_pointer; typedef typename CheckType<T, is_pointer>::type T_no_cv; inline static void clone(const T **src, void **dest) { *dest = new T(**src); } }; template<typename T> Any::Any(T const& x) : _table(Table<T>::get()), _object(nullptr) { const T *src = &x; AnyHelper<T, Table<T>::is_small>::clone(&src, &_object); }
От RTTI тоже пришлось отказаться, слишком медленно. Проверка типа идет исключительно сравнением указателей на таблицу типа. Все модификаторы типа предварительно очищаются, иначе, например, int и const int окажутся разными типами. Но на самом деле их размер одинок, и вообще это один и тот же тип.
template <typename T> inline T* any_cast(Any* operand) { if (operand && operand->_table == Table<T>::get()) return AnyHelper<T, Table<T>::is_small>::cast(&operand->_object); return nullptr; }
Как этим пользоваться
Встраивание скриптовых языков стало легким и приятным. Например, для Lua достаточно определить обобщённую функцию вызова, которая проверит количество аргументов и их типы и разумеется вызовет саму функцию. Биндинг тоже не представляет сложности. Для каждой функции в Lua достаточно сохранить MetaMethod в upvalue. Кстати все объекты в uMOF «тонкие», то есть просто обертка над указателем, который ссылается на запись в статической таблице. Поэтому можно копировать их без опасения насчет производительности.
Пример биндинга Lua:
#include <lua/lua.hpp> #include <object.h> #include <cassert> #include <iostream> class Test : public Object { OBJECT(Test, Object) EXPOSE( METHOD(sum), METHOD(mul) ) public: static double sum(double a, double b) { return a + b; } static double mul(double a, double b) { return a * b; } }; int genericCall(lua_State *L) { Method *m = (Method *)lua_touserdata(L, lua_upvalueindex(1)); assert(m); // Retrieve the argument count from Lua int argCount = lua_gettop(L); if (m->parameterCount() != argCount) { lua_pushstring(L, "Wrong number of args!"); lua_error(L); } Any *args = new Any[argCount]; for (int i = 0; i < argCount; ++i) { int ltype = lua_type(L, i + 1); switch (ltype) { case LUA_TNUMBER: args[i].reset(luaL_checknumber(L, i + 1)); break; case LUA_TUSERDATA: args[i] = *(Any*)luaL_checkudata(L, i + 1, "Any"); break; default: break; } } Any res = m->invoke(nullptr, argCount, args); double d = any_cast<double>(res); if (!m->returnType().valid()) return 0; return 0; } void bindMethod(lua_State *L, const Api *api, int index) { Method m = api->method(index); luaL_getmetatable(L, api->name()); // 1 lua_pushstring(L, m.name()); // 2 Method *luam = (Method *)lua_newuserdata(L, sizeof(Method)); // 3 *luam = m; lua_pushcclosure(L, genericCall, 1); lua_settable(L, -3); // 1[2] = 3 lua_settop(L, 0); } void bindApi(lua_State *L, const Api *api) { luaL_newmetatable(L, api->name()); // 1 // Set the "__index" metamethod of the table lua_pushstring(L, "__index"); // 2 lua_pushvalue(L, -2); // 3 lua_settable(L, -3); // 1[2] = 3 lua_setglobal(L, api->name()); lua_settop(L, 0); for (int i = 0; i < api->methodCount(); i++) bindMethod(L, api, i); } int main(int argc, char *argv[]) { lua_State *L = luaL_newstate(); luaL_openlibs(L); bindApi(L, Test::classApi()); int erred = luaL_dofile(L, "test.lua"); if (erred) std::cout << "Lua error: " << luaL_checkstring(L, -1) << std::endl; lua_close(L); return 0; }
Заключение
Итак, что мы имеем:
Достоинства uMOF:
- Компактный;
- Быстрый;
- Не требует сторонних инструментов, только современный компилятор.
Недостатки uMOF:
- Поддерживается не всеми компиляторами;
- Вспомогательные макросы довольно неказисты.
Библиотека пока достаточно сырая, хотелось бы еще много чего интересного сделать — функции переменной арности (читай, параметры по умолчанию), неинвазивная регистрация типов, сигналы об изменении свойств объекта. И все это обязательно появится, ведь метод показал весьма хорошие результаты.
Всем спасибо за внимание. Надеюсь библиотека окажется для кого-то полезной.
Проект можно найти по ссылке. Пишите свои отзывы и рекомендации в комментариях.
