Динамическая рефлексия для C++

в 17:16, , рубрики: c++, рефлексия

Вступление

Всем привет

Меня всегда интересовала тема рефлексии в языках программирования, и то, какие программы можно создавать с ее помощью. Рефлексия — это мощный инструмент, позволяющий работать с программой не как с набором логических объектов (в случае использования ООП), а как с набором свойств и методов из которых они состоят. Такой подход дает возможность создавать алгоритмы, которые могут работать с любыми типами данных, для которых включена поддержка рефлексии.

Во многих языках программирования рефлексия является частью языка. Так например в языке C#, рефлексия используется во многих функциях .Net фреймворка:

  • Сериализация

  • Тестирование

  • Внедрение зависимостей

  • Динамическое построение запросов в EntityFramework

Что же касается C++, то тут рефлексия на уровне языка, вовсе отсутствует. В нем есть rtti, но назвать это полноценной рефлексией трудно, из-за скудного функционала.

Одним из наиболее полноценных решений для C++, которое мне встречалось, это рефлексия в Unreal Engine. С ее помощью в движке реализовано большое количество функционала, например:

  • Система плагинов

  • Возможность модификации состояния С++ объектов через редактор

  • Сериализация

  • RPC (Remote Procedure Call)

  • Вызов С++ функций из Blueprints (система визуального программирования)

Именно знакомство с этим решением вдохновило меня на создание полноценной (насколько это возможно) рефлексии для C++, которую можно было бы использовать в любом проекте.

Требования

Для начала нужно было определится с ключевыми требованиями к будущему решению:

  1. Решение которое можно было б использовать в любом C++ проекте, с минимальным количеством зависимостей

  2. Возможность выборочного применения рефлексии к типам

  3. Автоматическая генерация метаинформации

  4. Наличие большинства (по возможности) функций, которые существуют в языках со встроенной рефлексией (за основу взята рефлексия из C#)

Реализация

Требования

Для начала нужно было определится с ключевыми требованиями к будущему решению:

  1. Решение которое можно было б использовать в любом C++ проекте, с минимальным количеством зависимостей

  2. Возможность выборочного применения рефлексии к типам

  3. Автоматическая генерация метаинформации

  4. Наличие большинства (по возможности) функций, которые существуют в языках со встроенной рефлексией (за основу взята рефлексия из C#)

Принцип работы

Как я говорил ранее, одним из ключевых источников вдохновения для создания моего решения стала система рефлексии в Unreal Engine. В целом, основной принцип работы позаимствован именно оттуда, за исключения некоторых отличий. Система работает следующим образом.

  1. Генерация метаинформации: проект использует утилиту MetaGenerator, которая отвечает за автоматическое создание метаинформации. При запуске MetaGenerator парсит все указанные файлы проекта, и на их основе генерирует дополнительные программные файлы, которые содержат в себе описание типов данных. Таким образом, вся метаинформация становится частью проекта, и попадает в конечную сборку.

  2. Доступ к метаинформации в рантайме: для работы с метаинформацией в рантайме используется библиотека CppReflection.

Использование и возможности CppReflection

Сразу хочу ввести такое понятие как программная сущность (ПС), под которым я подразумеваю любую конструкцию языка C++, вроде класса, поля, метода, перечисления и тд.

Маркеры

Маркеры — это обыкновенные макросы которые используются для обозначения того к каким ПС будет применена рефлексии. Именно благодаря маркерам, MetaGenerator понимает для каких сущностей нужно генерировать метаинформацию. На данный момент существуют следующие маркеры:

  • REFLECTABLE() — примеряется к классам, шаблонам и перечислениям

  • FIELD() — примеряется к полям класса

  • METHOD() — примеряется к методам класса

  • CONSTRUCTOR() — примеряется к конструкторам класса

REFLECTABLE()
class Calculator
{
	#include "Generation/Calculator.meta.inl" 

	FIELD()
	int m_A;

	int	m_B;

	CONSTRUCTOR()
	Calculator(int a, int b) :
		m_A(a),
		m_B(b)
	{
	}

	METHOD()
	int Calculate(int op) const
	{
		switch (op)
		{
		case 1: return m_A + m_B;
		case 2: return m_A - m_B;
		case 3: return m_A * m_B;
		case 4: return m_A / m_B;
		}
	}

	METHOD()
	static const char* GetModelName()
	{
		return "MyCalculator"; 
	}
};

REFLECTABLE()
enum class Numebers
{
	ONE = 1,
	TWO,
	THREE
};

Класс Type

Type является ключевым классом который содержит в себе основную информацию об определенном типе данных. С его помощью можно получить такую информацию о типе как имя, размер, атрибуты, родительские типы и так далее.

Есть несколько способов получить информацию о типе.

Первый способ — использовать шаблон TypeOf. С его помощью можно статически получить информацию о типе.

if (auto type = TypeOf<Calculator>::Get())
{
	auto name = type->GetName();
	auto size = type->GetSize();
	auto isEnum = type->IsEnum();

	// что-то шаманим
}

Второй способ — использовать метод GetType, который генерируется внутри Reflectable класса. Преимущество данного способа заключается в том, что он использует виртуальный метод, что дает возможность получить реальный тип объекта, через базовый указатель.

Calculator* obj = new SuperCalculator();
if (auto type = obj->GetType()) // вернет тип SuperCalculator
{
	// что-то шаманим
}

Кроме того, существует возможность получения типа по имени, но об этом чуть позже.

Помимо общей информации о типе, можно также получить информацию о его полях, методах и родительских типах.

Например, можно найти поле по имени, узнать тип, спецификаторы (является ли поле константным, статическим или указателем), а также получить или изменить его значение.

FieldPtr fieldPtr = type->GetFieldPtr("m_A");

if (fieldPtr != nullptr)
{
	int32_t* valuePtr = fieldPtr.GetValue<int32_t>(obj);
	*valuePtr = 123456789;
}

Если с полями всё достаточно просто и понятно, то вот с методами дела обстоят чуть интереснее. Помимо получения общей информации, можно проверить их сигнатуру, а также вызвать их.

if (const MethodInfo* method = type->GetMethod("GetModelName"))
{
	using ReturnType = const char*;

	if (method->IsStatic() &&
		method->CheckSignature<ReturnType>())
	{
		const char* name = method->Invoke<ReturnType>();
	}
}

Перечисления

Доступ к информации о перечислениях осуществляется точно так же, как и к классам, только используются другие методы.

TypePtr type = TypeOf<Numebers>::Get();
TypePtr baseType = type->GetEnumBaseType();

for (auto& info : type->GetEnumValues())
{
	auto name = info.name;
	uint64_t value = info.value;
}

Атрибуты

Атрибуты — это мощный инструмент, позволяющий добавлять дополнительную добавлять дополнительную метаинформации к вашим ПС. Для создания пользовательского атрибута нужно унаследоваться от базового класса Attribute.

Чтобы добавить атрибут к сущности, его следует объявить внутри маркера, используя корректную сигнатуру конструктора.

REFLECTABLE()
class DisplayName : public Reflection::Attribute
{
	#include "Generation/DisplayName.meta.inl"

private:

	const char* m_Name;

public:

	DisplayName(const char* name) :
		m_Name(name)
	{
	}

	const char* GetName() const
	{
		return m_Name;
	}
};

...

// добавляем атрибут к полю внутри класса Calculator
FIELD(DisplayName("Super Integer"))
int32_t m_A;

После этого, вы можете извлекать атрибуты из ПС, в рантайме.

if (const FieldInfo* info = type->GetField("m_A"))
{
	if (auto displayName = info->GetAttribute<DisplayName>())
	{
		auto name = displayName->GetName();
	}
}

Сборки

Если вы задаетесь вопросом, где же динамика, то сейчас она появится. Во всех предыдущих примерах, демонстрирующих основной функционал, предполагалось что программа работает только с собственными типами или со статическими зависимостями (то есть все используемые типы известны во время компиляции). Но что если мы хотим работать с каким нибудь неизвестным типом из динамически загружаемой библиотеки? В таком случае мы можем использовать класс Assembly.

Assembly предоставляет возможность загружать библиотеки и извлекать из них все Reflectable типы. Использование сборок, позволяет вам работать с неизвестными типами и модифицировать поведение программы в рантайме.

const Assembly* plugin = Assembly::Load("MyPlugin"); // Загружаем плагин
if (plugin == nullptr) return;

const TypePtr type = plugin->GetType("Calculator"); // Находим нужный тип
if (type == nullptr) return;

const LifetimeControl* lifetimeControl = type->GetLifetimeControl(); // Смотрим можно ли создать объект
if (lifetimeControl == nullptr) return;

const ConstructorInfo* constructor = lifetimeControl->GetConstructor<int32_t, int32_t>(); // Находим нужный конструктор (по сигнатуре)
auto obj = malloc(type->GetSize()); // Выделяем память для объекта

// Инициализация объекта
{
	uint32_t args[2] = { 3, 4 }; // Все аргументы упаковываются в один буфер
	InvokeInfo invoke =
	{
		.result = obj,
		.args = args
	};

	constructor->ConstructExplicit(&invoke); // Вызываем контруктор
}

if (const MethodInfo* method = type->GetMethod("Calculate"))
{
	if (method->IsStatic() == false)
	{
		int subOperation = 2;
		ArgumentsPack pack(obj, subOperation); // Для нестатических методов, всегда 1ый агрумент - объект
		int result;

		const InvokeInfo invoke =
		{
			.result = &result,
			.args = pack.Get()
		};
		method->InvokeExplicit(&invoke); // Вызываем метод
	}
}

lifetimeControl->Destroy(obj); // Вызываем деструктор
free(obj); // Освобождаем память объекта

Assembly::Free(plugin); // Выгружаем плагин

Зачастую с неизвестными объектами, работают через заранее известные интерфейсы. CppReflection предоставляет возможность, приводит объекты, как базовому, так и к производному типу (аналог dynamic_cast). Если предыдущий пример переделать так, чтобы Calculator реализовывал интерфейс ICalculator, то можно будет сделать следующее:

ICalculator* calculator = Cast<ICalculator>(obj, type);
int product = calculator->Calculate(3);

Адаптация под C++

Одним из второстепенных требований к данному решению была адаптация под C++. Под этим подразумевалось не только поддержка особых конструкций языка (вроде указателей, ссылок или множественного наследования), но и максимальную гибкость в использовании. Например, вы спокойно можете создать Reflectable тип, и унаследовать его от не Reflectable типа, и наоборот. Или создать Reflectable поле, тип которого будет не Reflectable.

Замечание

Конечно такой подход делает рефлексию максимально гибкой, как и задумывалось, но имеет подводные камни которые нужно учитывать. Помимо очевидных вещей, вроде, что вы не сможете получить тип поля, имеющего не Reflectable тип (а только его имя, спецификаторы и позицию), есть более неявные недостатки. Менее очевидный недостаток проявляется в сложных иерархиях классов, где Reflectable и не Reflectable типы чередуются. В такой ситуации, имея ссылку на фактический объект, невозможно получить полную цепочку базовых классов, поскольку нельзя определить типы, от которых наследуется не Reflectable класс.

MetaGenerator и настройка проекта

С функционалом вроде разобрались, теперь немного поговорим о генераторе и настройке проекта. Генератор — это утилита, которая занимается анализом и генерацией дополнительных программных файлов.

Генератор должен запускаться после внесения изменений в заголовочные файлы с Reflectable типами, чтобы сгенерировать актуальную информацию. Можно настроить запуск перед компиляцией в ide или вызывать его вручную - кому как удобно.

Также стоит упомянуть какие именно файлы генерируются.

  • (ProjectName)ReflectionInclude — это основной заголовочный файл, содержащий TypeOf для всех Reflectable типов текущей сборки. Это единственный файл который вы должны включить в заголовки с Reflectable типами, а также в файлы других проектов, которые имеют зависимость от данного проекта.

  • Inline файлы — эти файлы генерируются для каждого Reflectable класса, и содержат метод GetType и другую дополнительную информацию. Его нужно вручную включать в тело класса.

Пример вызова генератора

MetaGenerator.exe ../MyProject/ analyze_dirs=Include/MyProject gen_dir=Include dll_export=MY_PROJECT_DLL_EXPORT
  1. Первый аргумент — директория проекта

  2. analyze_dirs — указываем из какой директории парсить файлы

  3. gen_dir — указываем где будут находиться все сгенерированные файлы

  4. dll_export — указываем уникальный макрос проекта (нужно для динамических библиотек)

Заключение

Данная версия не является финальной, и в ней присутствует много вещей которые можно улучшить и доработать, но даже сейчас с его помощью можно реализовать разные вещи по типу универсальных алгоритмов или системы расширений.

Детальнее с данным решение вы можете ознакомиться в репозитории.

Буду рад любой обратной связи.

Автор: 7Ilya

Источник

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


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js