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

в 7:26, , рубрики: binding, c++, c++11, game development, Lua, Программирование

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

Хочу поделиться своим опытом разработки метасистемы для 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

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

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>
	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

Источник

* - обязательные к заполнению поля