Мой подход к реализации делегатов в C++: вызов функции с неизвестными параметрами во время выполнения

в 11:02, , рубрики: c++, Программирование, С++, шаблоны c++

Предыстория

Мне нравится язык C++. Я бы даже сказал, что это мой любимый язык. Кроме того, для своих разработок я использую технологии .NET, и многие идеи в нём, по моему мнению, просто восхитительны. Однажды мне пришла в голову идея – как реализовать некоторые средства рефлексии и динамического вызова функций в C++? Очень уж хотелось, чтобы C++ тоже обладал таким преимуществом CLI, как вызов делегата с неизвестным количеством параметров и их типов. Это может пригодиться, например, когда заранее неизвестно, какие типы данных нужны функции, которую нужно вызвать.

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

Вызов функций с неопределённым количеством параметров и неизвестными во время компиляции типами

Конечно, это главная проблема с C++, которая решается не так уж и просто. Конечно, в C++ есть средство, унаследованное из C – varargs, и, скорее всего, это первое, что придёт на ум… Однако они не подходят, во-первых, из-за своей типонебезопасной природы (как и многие вещи из C), во-вторых, при использовании таких аргументов надо точно заранее знать, какие у аргументов типы. Впрочем, почти наверняка, это ещё не все проблемы с varargs. В общем, это средство нам здесь не помощник.

А теперь перечислю средства, которые помогли мне решить эту проблему.

std::any

Начиная с C++17, в языке появился замечательный-контейнер-хранилище для чего угодно – некое отдалённое подобие System.Object в CLI – это std::any. Этот контейнер действительно может хранить что угодно, да ещё как: эффективно! – стандарт рекомендует маленькие объекты хранить непосредственно в нём, большие уже можно хранить в динамической памяти (хотя такое поведение не является обязательным, корпорация Microsoft в своей реализации C++ так и сделала, что не может не радовать). А подобием лишь его можно назвать потому, что System.Object участвует в отношениях наследования («is a»), а std::any – участвует в отношениях принадлежности («has a»). Кроме данных, контейнер содержит указатель на объект std::type_info – RTTI о типе, объект которого «лежит» в контейнере.

Для контейнера выделен целый заголовочный файл <any>.

Чтобы «вытащить» объект из контейнера, нужно использовать шаблонную функцию std::any_cast(), которая возвращает ссылку на объект.
Пример использования:

#include <any>
void any_test()
{
	std::any obj = 5;
	int from_any = std::any_cast<int>(obj);
}

Если запрашиваемый тип не совпадает с тем, что имеет объект внутри контейнера, тогда выбрасывается исключение std::bad_any_cast.

Кроме классов std::any, std::bad_any_cast и функции std::any_cast, в заголовочном файле есть шаблонная функция std::make_any, аналогичная std::make_shared, std::make_pair и другим функциям этого рода.

RTTI

Безусловно, практически нереально в C++ было бы реализовать динамический вызов функций без информации о типах во времени выполнения. Ведь надо же как-то проверять, правильные типы переданы, или нет.

Примитивная поддержка RTTI в C++ есть довольно давно. Вот только в том-то и дело, что примитивная – мы мало что можем узнать о типе, разве только декорированное и недекорированное имена. Кроме того, мы можем сравнивать типы друг с другом.

Обычно понятие «RTTI» применяется в связи с полиморфными типами. Однако здесь мы будем использовать этот термин в более широком смысле. Например, будем учитывать тот факт, что информация о типе во время выполнения есть у каждого типа (правда, получить её можно только статически, во время компиляции, в отличие от полиморфных типов). Поэтому сравнивать типы даже неполиморфных типов (простите за тавтологию) можно (и нужно) во время выполнения.
Доступ к RTTI можно получить с помощью класса std::type_info. Этот класс находится в заголовочном файле <typeinfo>. Ссылку на объект этого класса можно получить (по крайней мере, пока) лишь с помощью оператора typeid().

Шаблоны

Ещё одна чрезвычайно важная особенность языка, нужная нам для реализации нашей задумки – это шаблоны. Это средство – довольно мощное и исключительно непростое, по сути позволяет генерировать код во время компиляции.

Шаблоны – это очень обширная тема, и в рамках статьи раскрыть её не удастся, да и не нужно это. Будем считать, что читатель понимает, о чём речь. Какие-то неясные моменты будут раскрыты в процессе.

Упаковка аргументов с последующим вызовом

Итак, у нас есть некая функция, принимающая на вход несколько параметров.

Продемонстрирую набросок кода, который объяснит мои намерения.

#include <Variadic_args_binder.hpp>
#include <string>
#include <iostream>
#include <vector>
#include <any>

int f(int a, std::string s)
{
	std::cout << "int: " << a << "nstring: " << s << std::endl;
	return 1;
}

void demo()
{
	std::vector<std::any> params;
	params.push_back(5);
	params.push_back(std::string{ "Hello, Delegates!" });
	delegates::Variadic_args_binder<int(*)(int, std::string), int, std::string> binder{ f, params };
	binder();
}

Возможно, вы спросите – как это возможно? Название класса Variadic_args_binder подсказывает, что объект связывает функцию и аргументы, которые нужно ей передать при вызове. Таким образом, остаётся лишь вызвать этот связыватель как функцию без параметров!
Так это выглядит снаружи.

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

fun_ptr(param1, param2, …, paramN);

Так устроен C++. И это всё сильно усложняет.

Тут справится лишь шаблонная магия!

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

Итак, объявим класс _Tagged_args_binder:

namespace delegates::impl
{
	template <typename Func_type, typename... T>
	class _Tagged_args_binder;
}

Чтобы удобно «переносить» пакеты типов, создадим вспомогательный тип Type_pack_tag (зачем это понадобилось, скоро станет понятно):

template <typename... T>
struct Type_pack_tag
{
};

Теперь создаём специализации класса _Tagged_args_binder.

Начальные специализации

Как известно, чтобы рекурсия не была бесконечной, необходимо определить граничные случаи.
Следующие специализации являются начальными. Для упрощения приведу специализации лишь для нессылочных типов и правосторонних ссылочных типов (rvalue reference).
Специализация для непосредственно параметров-значений:

template <typename Func_type, typename T1, typename... Types_to_construct>
class _Tagged_args_binder<Func_type, Type_pack_tag<T1, Types_to_construct...>, Type_pack_tag<>>
{
public:
	static_assert(!std::is_same_v<void, T1>, "Void argument is not allowed");

using Ret_type = std::invoke_result_t<Func_type, T1, Types_to_construct...>;

	_Tagged_args_binder(Func_type func, std::vector<std::any>& args)
		: ap_arg{ std::move(unihold::reference_any_cast<T1>(args.at(0))) },
		ap_caller_part{ func, args } { }

	auto operator()()
	{
		if constexpr(std::is_same_v<void, Ret_type>)
		{
			ap_caller_part(std::move(ap_arg));
			return;
		}
		else
		{
			return std::forward<Ret_type>(ap_caller_part(std::move(ap_arg)));
		}
	}

	auto operator()() const
	{
		if constexpr (std::is_same_v<void, Ret_type>)
		{
			ap_caller_part(std::move(ap_arg));
			return;
		}
		else
		{
			return std::forward<Ret_type>(ap_caller_part(std::move(ap_arg)));
		}
	}

private:
	_Tagged_args_binder<Func_type, Type_pack_tag<Types_to_construct...>, Type_pack_tag<T1>> ap_caller_part;
	T1 ap_arg;
};

Здесь хранятся первый аргумент вызова ap_arg и остальная часть рекурсивного объекта ap_caller_part. Обратите внимание, что тип T1 «переместился» из первого пакета типов в этом объекте во второй в «хвосте» рекурсивного объекта.

Специализация для rvalue-ссылок:

template <typename Func_type, typename T1, typename... Types_to_construct>
class _Tagged_args_binder<Func_type, Type_pack_tag<T1&&, Types_to_construct...>, Type_pack_tag<>>
{
	using move_ref_T1 = std::add_rvalue_reference_t<std::remove_reference_t<T1>>;
public:
	using Ret_type = std::invoke_result_t<Func_type, move_ref_T1, Types_to_construct>;

	_Tagged_args_binder(Func_type func, std::vector<std::any>& args)
		: ap_arg{ std::move(unihold::reference_any_cast<T1>(args.at(0))) },
		ap_caller_part{ func, args }
	{
	}

	auto operator()()
	{
		if constexpr (std::is_same_v<void, Ret_type>)
		{
			ap_caller_part(std::move(unihold::reference_any_cast<T1>(ap_arg)));
		}
		else
		{
			return std::forward<Ret_type>(ap_caller_part(std::move(unihold::reference_any_cast<T1>(ap_arg))));
		}
	}

	auto operator()() const
	{
		if constexpr (std::is_same_v<void, Ret_type>)
		{
			ap_caller_part(std::move(unihold::reference_any_cast<T1>(ap_arg)));
		}
		else
		{
			return std::forward<Ret_type>(ap_caller_part(std::move(unihold::reference_any_cast<T1>(ap_arg))));
		}
	}

private:
	_Tagged_args_binder<Func_type, Type_pack_tag<Types_to_construct...>, Type_pack_tag<move_ref_T1>> ap_caller_part;
	std::any ap_arg;
}; 

Шаблонные «правосторонние» ссылки – на самом деле не являются правосторонними значениями. Это так называемые «универсальные ссылки», которые, в зависимости от типа T1, становятся то T1&, то T1&&. Поэтому приходится использовать обходные пути: во-первых, так как определены специализации для обеих видов ссылок (не совсем корректно сказано, по уже озвученной причине) и для нессылочных параметров, при инстанцировании шаблона будет выбрана именно нужная специализация, даже если это правосторонняя ссылка; во-вторых – для передачи типа T1 из пакета в пакет используется исправленная версия move_ref_T1, превращённая в настоящую rvalue-ссылку.

Специализация с обычной ссылкой делается аналогично, с необходимыми исправлениями.

Конечная специализация

template <typename Func_type, typename... Param_type>
class _Tagged_args_binder<Func_type, Type_pack_tag<>, Type_pack_tag<Param_type...>>
{
public:
	using Ret_type = std::invoke_result_t<Func_type, Param_type...>;

	inline _Tagged_args_binder(Func_type func, std::vector<std::any>& args)
		: ap_func{ func } { }
	
	inline auto operator()(Param_type... param)
	{
		if constexpr(std::is_same_v<void, decltype(ap_func(std::forward<Param_type>(param)...))>)
		{
			ap_func(std::forward<Param_type>(param)...);
			return;
		}
		else
		{
			return std::forward<Ret_type>(ap_func(std::forward<Param_type>(param)...));
		}
	}

	inline auto operator()(Param_type... param) const
	{
		if constexpr(std::is_same_v<void, Ret_type>)
		{
			ap_func(param...);
			return;
		}
		else
		{
			return std::forward<Ret_type>(ap_func(param...));
		}
	}

private:
	Func_type ap_func;
};

Эта специализация ответственна за хранение функционального объекта и, по сути, является обёрткой над ним. Она является завершающей рекурсивный тип.

Обратите внимание, как используется здесь Type_pack_tag. Все типы параметров теперь собраны в левом пакете. Это значит, что все они обработаны и упакованы.

Теперь, думаю, становится понятно, зачем нужно было использовать именно Type_pack_tag. Дело в том, язык не позволил бы использовать рядом два пакета типов, например, вот так:

template <typename Func_type, typename T1, typename... Types_to_construct, typename... Param_type>
class _Tagged_args_binder<Func_type, T1, Types_to_construct..., Param_type...>
{
};

поэтому приходится разделять их на два раздельных пакета внутри двух типов. Кроме того, надо как-то отделять обработанные типы от ещё не обработанных.

Промежуточные специализации

Из промежуточных специализаций напоследок приведу специализацию, опять-таки, для типов-значений, остальное по аналогии:

template <typename Func_type, typename T1, typename... Types_to_construct, typename... Param_type>
class _Tagged_args_binder<Func_type, Type_pack_tag<T1, Types_to_construct...>, Type_pack_tag<Param_type...>>
{
public:
	using Ret_type = std::invoke_result_t<Func_type, Param_type..., T1, Types_to_construct...>;

	static_assert(!std::is_same_v<void, T1>, "Void argument is not allowed");

	inline _Tagged_args_binder(Func_type func, std::vector<std::any>& args)
		: ap_arg{ std::move(unihold::reference_any_cast<T1>(args.at(sizeof...(Param_type)))) },
		ap_caller_part{ func, args } { }

	inline auto operator()(Param_type... param)
	{
		if constexpr (std::is_same_v<void, Ret_type>)
		{
			ap_caller_part(std::forward<Param_type>(param)..., std::move(ap_arg));
			return;
		}
		else
		{
			return std::forward<Ret_type>(ap_caller_part(std::forward<Param_type>(param)..., std::move(ap_arg)));
		}
	}

	inline auto operator()(Param_type... param) const
	{
		if constexpr (std::is_same_v<void, Ret_type>)
		{
			ap_caller_part(std::forward<Param_type>(param)..., std::move(ap_arg));
		}
		else
		{
			return std::forward<Ret_type>(ap_caller_part(std::forward<Param_type>(param)..., std::move(ap_arg)));
		}
	}

private:
	_Tagged_args_binder<Func_type,
		Type_pack_tag<Types_to_construct...>,
		Type_pack_tag<Param_type..., T1>> ap_caller_part;
	T1 ap_arg;
};

Эта специализация предназначена для упаковки любого аргумента, кроме первого.

Класс-связыватель

Класс _Tagged_args_binder не предназначен для непосредственного использования, что я хотел подчеркнуть одинарным подчёркиванием в начале его названия. Поэтому приведу код небольшого класса, являющегося своего рода «интерфейсом» к этому некрасивому и неудобному в использовании типу (в котором, однако, используются довольно необычные приёмы C++, что придаёт ему некоторый шарм, на мой взгляд):

namespace cutecpplib::delegates
{
	template <typename Functor_type, typename... Param_type>
	class Variadic_args_binder
	{
		using binder_type = impl::_Tagged_args_binder<Functor_type, Type_pack_tag<Param_type...>, Type_pack_tag<>>;

	public:
		using Ret_type = std::invoke_result_t<binder_type>;

		inline Variadic_args_binder(Functor_type function, Param_type... param)
			: ap_tagged_binder{ function, param... } { }

		inline Variadic_args_binder(Functor_type function, std::vector<std::any>& args)
			: ap_tagged_binder{ function, args } { }

		inline auto operator()()
		{
			return ap_tagged_binder();
		}

		inline auto operator()() const
		{
			return ap_tagged_binder();
		}

	private:
		binder_type ap_tagged_binder;
	};
}

Соглашение unihold – передача ссылок внутри std::any

Внимательный читатель наверняка заметил, что в коде используется функция unihold::reference_any_cast(). Эта функция, а также её аналог unihold::pointer_any_cast(), разработаны для реализации соглашения библиотеки: аргументы, которые необходимо передать по ссылке, передаются по указателю в std::any.

Функция reference_any_cast всегда возвращает ссылку на объект, хранится ли в контейнере сам объект или только указатель на него. Если std::any содержит в себе объект, то возвращается ссылка на этот объект внутри контейнера; если же содержит указатель – то возвращается ссылка на объект, на который указывает указатель.

Для каждой из функций есть варианты константного std::any и перегруженные версии для определения того, является ли контейнер std::any владельцем объекта или же содержит лишь указатель.

Функции нужно специализировать явно типом хранимого объекта, так же, как преобразования типов C++ и подобные им шаблонные функции.

Код этих функций:

template <typename T>
std::remove_reference_t<T>& unihold::reference_any_cast(std::any& wrapper)
{
	bool result;
	return reference_any_cast<T>(wrapper, result);
}

template <typename T>
const std::remove_reference_t<T>& unihold::reference_any_cast(const std::any& wrapper)
{
	bool result;
	return reference_any_cast<T>(wrapper, result);
}

template <typename T>
std::remove_reference_t<T>& unihold::reference_any_cast(std::any& wrapper, bool& is_owner)
{
	auto ptr = pointer_any_cast<T>(&wrapper, is_owner);
	if (!ptr)
		throw std::bad_any_cast{ };
	return *ptr;
}

template <typename T>
const std::remove_reference_t<T>& unihold::reference_any_cast(const std::any& wrapper, bool& is_owner)
{
	auto ptr = pointer_any_cast<T>(&wrapper, is_owner);
	if (!ptr)
		throw std::bad_any_cast{ };
	return *ptr;
}

template <typename T>
std::remove_reference_t<T>* unihold::pointer_any_cast(std::any* wrapper, bool& is_owner)
{
	using namespace std;
	using NR_T = remove_reference_t<T>; // No_reference_T
	// Указатель на указатель внутри wrapper
	NR_T** double_ptr_to_original = any_cast<NR_T*>(wrapper);
	// Указатель на копию объекта внутри wrapper
	NR_T* ptr_to_copy;
	if (double_ptr_to_original)
	{
		// Wrapper содержит указатель на оригинал объекта
		is_owner = false;
		return *double_ptr_to_original;
	}
	else if (ptr_to_copy = any_cast<NR_T>(wrapper))
	{
		// Wrapper содержит копию объекта
		is_owner = true;
		return ptr_to_copy;
	}
	else
	{
		throw bad_any_cast{};
	}
}

template <typename T>
const std::remove_reference_t<T>* unihold::pointer_any_cast(const std::any* wrapper, bool& is_owner)
{
	using namespace std;
	using NR_T = remove_reference_t<T>; // No_reference_T
	// Указатель на указатель внутри wrapper
	NR_T*const * double_ptr_to_original = any_cast<NR_T*>(wrapper);
	// Указатель на копию объекта внутри wrapper
	const NR_T* ptr_to_copy;
	//remove_reference_t<T>* ptr2 = any_cast<remove_reference_t<T>>(&wrapper);
	if (double_ptr_to_original)
	{
		// Wrapper содержит указатель на оригинал объекта
		is_owner = false;
		return *double_ptr_to_original;
	}
	else if (ptr_to_copy = any_cast<NR_T>(wrapper))
	{
		// Wrapper содержит копию объекта
		is_owner = true;
		return ptr_to_copy;
	}
	else
	{
		throw bad_any_cast{};
	}
}

template <typename T>
std::remove_reference_t<T>* unihold::pointer_any_cast(std::any* wrapper)
{
	bool result;
	return pointer_any_cast<T>(wrapper, result);
}

template <typename T>
const std::remove_reference_t<T>* unihold::pointer_any_cast(const std::any* wrapper)
{
	bool result;
	return pointer_any_cast<T>(wrapper, result);
}

Заключение

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

P.S. Использование RTTI будет демонстрироваться в следующей части.

Автор: eyesolaris

Источник


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


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