Привет, Хабрхабр!
Хочу поделиться своим опытом разработки метасистемы для 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:
- Поддерживается не всеми компиляторами;
- Вспомогательные макросы довольно неказисты.
Библиотека пока достаточно сырая, хотелось бы еще много чего интересного сделать — функции переменной арности (читай, параметры по умолчанию), неинвазивная регистрация типов, сигналы об изменении свойств объекта. И все это обязательно появится, ведь метод показал весьма хорошие результаты.
Всем спасибо за внимание. Надеюсь библиотека окажется для кого-то полезной.
Проект можно найти по ссылке. Пишите свои отзывы и рекомендации в комментариях.
Автор: occash