Привет, Хабрхабр!
Хочу поделиться своим опытом разработки метасистемы для C++ и встраивания различных скриптовых языков.
Сравнительно недавно начал писать свой игровой движок. Разумеется, как и в любом хорошем движке встал вопрос о встраивании скриптового языка, а лучше даже нескольких. Безусловно, для встраивания конкретного языка уже есть достаточно инструментов (например, luabind для Lua, boost.python для Python), и свой велосипед изобретать не хотелось.
Начал со встраивания простого и шустрого Lua, а для биндинга использовал luabind. И он выглядит действительно неплохо.
Читается легко, класс регистрируется просто и без проблем. Но это решение исключительно для Lua.
Вдохновившись скриптовой системой Unity, понял, что однозначно должно быть несколько языков в системе, а также возможность их взаимодействия между собой. И тут такого рода инструменты, как luabind, дают слабину: в большинстве своем они написаны с использованием шаблонов C++ и генерируют код только для специфического языка. Каждый класс нужно зарегистрировать в каждой системе. При этом необходимо добавить множество заголовочных файлов и вручную вписать все в шаблоны.
А ведь хочется, чтобы была общая база типов для всех языков. А также возможность загрузить информацию о типах из плагинов прямо в рантайме. Для этих целей binding библиотеки не подходят. Нужна настоящая метасистема. Но тут тоже оказалось не все гладко. Готовые библиотеки оказались довольно громоздкими и неудобными. Существуют и весьма изящные решения, но они тянут за собой дополнительные зависимости и требуют использования специальных инструментов (например, Qt moc или gccxml). Есть, конечно же, и довольно симпатичные варианты, такие как, например, библиотека для рефлексии Camp. Выглядит она почти также, как и luabind:
Правда производительность подобных «красивых» решений оставляет желать лучшего. Конечно же, как и любой «нормальный» программист, я решил написать свою метасистему. Так появилась библиотека uMOF.
uMOF — кроссплатформенная open source библиотека для метапрограммирования. Концептуально напоминает Qt, но выполнена с помощью шаблонов, от которых в свое время отказались сами Qt. Они это сделали ради читаемости кода. И так реально быстрее и компактнее. Но, использование moc компилятора приводит в полную зависимость от Qt. Это не всегда оправдано.
Перейдем все же к делу. Чтобы сделать доступной для пользователя метаинформацию в классе наследнике Object нужно прописать макросы OBJECT с иерархией наследования и EXPOSE для объявления функций. После этого становится доступен API класса, в котором хранится информация о классе, функцияx и публичных свойствах.
Пока определение метаинформации инвазивно, но планируется и внешний вариант для более удобной обертки стороннего кода.
Из-за использования продвинутых шаблонов uMOF получился очень быстрым, при этом довольно компактным. Это же привело и к некоторым ограничениям: т.к. активно используются возможности C++11, не все компиляторы подойдут (например, чтобы скомпилировать на Windows, нужен самый последний Visual C++ November CTP). Также использование шаблонов в коде не всем понравится, поэтому все завернуто в макросы. Между тем макросы скрывают большое количество шаблонов и код выглядит довольно аккуратно.
Дабы не быть голословным дальше привожу результаты бенчмарков.
Я сравнивал метасистемы по трем параметрам: время компиляции/линковки, размер исполняемого файла и время вызова функции в цикле. В качестве эталона я взял пример с нативным вызовом функций. Испытуемые тестировались на Windows под Visual Studio 2013.
Для наглядности тоже самое в виде графиков.
Я также рассматривал еще несколько библиотек:
Но они не попали на роль испытуемых по разным причинам. Boost.Mirror и XcppRefl выглядят перспективно, но пока находятся на стадии активной разработки. Reflex требует GCCXML, какой либо адекватной замены для Windows я не нашел. XRtti опять же в текущем релизе не поддерживает Windows.
Итак, как это все работает. Скорость и компактность библиотеке дают шаблоны с функциями в качестве аргументов, а также variadic шаблоны. Вся мета информация по типам организована как набор статических таблиц. Никакой дополнительно нагрузки в рантайме нет. А простая структура в виде массива указателей не дает коду сильно распухнуть.
Немаловажную роль в эффективности также играет класс Any, который позволяет достаточно компактно хранить типы и информацию о них. Основой послужил класс hold_any из библиотеки boost spirit. Здесь также активно используются шаблоны, чтобы эффективно оборачивать типы. Типы меньше указателя по размеру хранятся непосредственно в void*, для более крупных типов указатель ссылается на объект типа.
От RTTI тоже пришлось отказаться, слишком медленно. Проверка типа идет исключительно сравнением указателей на таблицу типа. Все модификаторы типа предварительно очищаются, иначе, например, int и const int окажутся разными типами. Но на самом деле их размер одинок, и вообще это один и тот же тип.
Встраивание скриптовых языков стало легким и приятным. Например, для Lua достаточно определить обобщённую функцию вызова, которая проверит количество аргументов и их типы и разумеется вызовет саму функцию. Биндинг тоже не представляет сложности. Для каждой функции в Lua достаточно сохранить MetaMethod в upvalue. Кстати все объекты в uMOF «тонкие», то есть просто обертка над указателем, который ссылается на запись в статической таблице. Поэтому можно копировать их без опасения насчет производительности.
Пример биндинга Lua:
Итак, что мы имеем:
Достоинства uMOF:
Недостатки uMOF:
Библиотека пока достаточно сырая, хотелось бы еще много чего интересного сделать — функции переменной арности (читай, параметры по умолчанию), неинвазивная регистрация типов, сигналы об изменении свойств объекта. И все это обязательно появится, ведь метод показал весьма хорошие результаты.
Всем спасибо за внимание. Надеюсь библиотека окажется для кого-то полезной.
Проект можно найти по ссылке. Пишите свои отзывы и рекомендации в комментариях.
Хочу поделиться своим опытом разработки метасистемы для 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 |
Сноски
* 10.000.000 calls
** Force no inlining
*** Meta object compiler
** 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:
- Поддерживается не всеми компиляторами;
- Вспомогательные макросы довольно неказисты.
Библиотека пока достаточно сырая, хотелось бы еще много чего интересного сделать — функции переменной арности (читай, параметры по умолчанию), неинвазивная регистрация типов, сигналы об изменении свойств объекта. И все это обязательно появится, ведь метод показал весьма хорошие результаты.
Всем спасибо за внимание. Надеюсь библиотека окажется для кого-то полезной.
Проект можно найти по ссылке. Пишите свои отзывы и рекомендации в комментариях.