Універсальна метасистема на C++

Привіт, Хабрхабр!

Хочу поділитися своїм досвідом розробки метасистемы для 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 класу, в якому зберігається інформація про клас, функції та публічних властивості.
Приклад
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

Для наочності теж саме у вигляді графіків.

image

image

image

Я також розглядав ще кілька бібліотек:

  • 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>
static inline 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(const T& 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:
  • Підтримується не всіма компіляторами;
  • Допоміжні макроси досить нагадують бур'ян.
Бібліотека поки що досить сира, хотілося б ще багато чого цікавого зробити — функції змінної арности (читай, параметри за замовчуванням), неінвазивна реєстрація типів, сигнали про зміну властивостей об'єкта. І все це обов'язково з'явиться, адже метод показав дуже гарні результати.

Всім дякую за увагу. Сподіваюся бібліотека виявиться для когось корисною.

Проект можна знайти на посилання. Пишіть свої відгуки та рекомендації в коментарях.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.