Хабрахабр

[Из песочницы] Мой подход к реализации делегатов в 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. Например, будем учитывать тот факт, что информация о типе во время выполнения есть у каждого типа (правда, получить её можно только статически, во время компиляции, в отличие от полиморфных типов). Ссылку на объект этого класса можно получить (по крайней мере, пока) лишь с помощью оператора typeid(). Этот класс находится в заголовочном файле <typeinfo>.

Шаблоны

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

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

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

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

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

#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()
); 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.

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

Для каждой из функций есть варианты константного 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++.

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть