My approach to implementing delegates in C ++: calling a function with unknown parameters at runtime
Background
I like the C ++ language. I would even say that this is my favorite language. In addition, I use .NET technologies for my development, and many of the ideas in it, in my opinion, are simply amazing. Once I came up with the idea - how to implement some means of reflection and dynamic function calls in C ++? I really wanted C ++ to have such a CLI advantage as calling a delegate with an unknown number of parameters and their types. This can be useful, for example, when it is not known in advance which data types the function needs to be called.
Of course, a complete imitation of delegates is too complicated, so this article will demonstrate only the general architecture of the library and the solution to some important problems that arise when dealing with something that is not supported directly by the language.
Calling functions with an indefinite number of parameters and unknown types during compilation
Of course, this is the main problem with C ++, which is not so easy to solve. Of course, in C ++ there is a tool inherited from C - varargs , and most likely this is the first thing that comes to mind ... However, they do not fit, firstly, because of their type-unsafe nature (like many things from C), secondly, when using such arguments, you need to know in advance what types of arguments are. However, almost certainly, this is not all the problems with varargs . In general, this tool is not an assistant here.
And now I’ll list the tools that helped me solve this problem.
std :: any
Starting with C ++ 17, the language has a wonderful container container for anything - some distant similarity to System.Object in the CLI is std :: any . This container can really store anything, and even how: efficiently! - the standard recommends that small objects be stored directly in it, large ones can already be stored in dynamic memory (although this behavior is not mandatory, Microsoft did this in its C ++ implementation, which is good news). And only it can be called similarity because System.Object is involved in the inheritance relationship ("is a"), and std :: any is involved in the membership relationship ("has a"). In addition to the data, the container contains a pointer to the std :: type_info - RTTI object about the type whose object "lies" in the container.
A whole header file is allocated for the container
To “pull” an object from the container, you need to use the std :: any_cast () template function , which returns a reference to the object.
Usage example:
#include
void any_test()
{
std::any obj = 5;
int from_any = std::any_cast(obj);
}
If the requested type does not match what the object has inside the container, then an exception std :: bad_any_cast is thrown .
In addition to the std :: any , std :: bad_any_cast classes and the std :: any_cast functions , the header file has a template function std :: make_any , similar to std :: make_shared , std :: make_pair and other functions of this kind.
RTTI
Of course, it would be practically unrealistic in C ++ to implement a dynamic function call without type information at runtime. After all, it is necessary to somehow check whether the correct types are passed or not.
Primitive RTTI support in C ++ has been around for quite some time. That's just the point, that is primitive - we can learn little about a type, unless decorated and undecorated names. In addition, we can compare types with each other.
Typically, the term “RTTI” is used in connection with polymorphic types. However, here we will use this term in a broader sense. For example, we will take into account the fact that each type has information about the type at runtime (although you can only get it statically at compile time, unlike polymorphic types). Therefore, it is possible (and necessary) to compare types of even non-polymorphic types (sorry for the tautology) at runtime.
RTTI can be accessed using the std :: type_info class . This class is in the header file.
Patterns
Another extremely important feature of the language that we need to realize our ideas is templates. This tool is quite powerful and extremely difficult, in fact it allows you to generate code at compile time.
Templates are a very broad topic, and it will not be possible to reveal it within the framework of the article, and it is not necessary. We assume that the reader understands what it is about. Some obscure points will be revealed in the process.
Argument wrapping followed by a call
So, we have a certain function that takes several parameters as input.
I'll show you a code sketch that will explain my intentions.
#include
#include
#include
#include
#include
int f(int a, std::string s)
{
std::cout << "int: " << a << "\nstring: " << s << std::endl;
return 1;
}
void demo()
{
std::vector params;
params.push_back(5);
params.push_back(std::string{ "Hello, Delegates!" });
delegates::Variadic_args_binder binder{ f, params };
binder();
}
You may ask, how is this possible? The class name Variadic_args_binder tells you that the object binds the function and the arguments that you need to pass to it when you call it. Thus, it remains only to call this binder as a function without parameters!
So it looks outside.
If immediately, without thinking, make an assumption how this can be implemented, then it may come to mind to write several Variadic_args_binder specializations for a different number of parameters. However, this is not possible if it is necessary to support an unlimited number of parameters. And here's the problem: the arguments, unfortunately, need to be substituted into the function call statically, that is, ultimately for the compiler, the call code should be reduced to this:
fun_ptr(param1, param2, …, paramN);
This is how C ++ works. And all this greatly complicates.
Only template magic can handle it!
The main idea is to create recursive types that store at each nesting level one of the arguments or a function.
So, declare the _Tagged_args_binder class :
namespace delegates::impl
{
template
class _Tagged_args_binder;
}
To conveniently "transfer" type packages, we will create an auxiliary type, Type_pack_tag (why this was needed, it will become clear soon):
template
struct Type_pack_tag
{
};
Now we create specializations of the _Tagged_args_binder class .
Initial Specializations
As you know, so that recursion is not infinite, it is necessary to define boundary cases.
The following specializations are initial. For simplicity, I will cite specializations only for non-reference types and rvalue reference types.
Specialization for directly parameter values:
template
class _Tagged_args_binder, Type_pack_tag<>>
{
public:
static_assert(!std::is_same_v, "Void argument is not allowed");
using Ret_type = std::invoke_result_t;
_Tagged_args_binder(Func_type func, std::vector& args)
: ap_arg{ std::move(unihold::reference_any_cast(args.at(0))) },
ap_caller_part{ func, args } { }
auto operator()()
{
if constexpr(std::is_same_v)
{
ap_caller_part(std::move(ap_arg));
return;
}
else
{
return std::forward(ap_caller_part(std::move(ap_arg)));
}
}
auto operator()() const
{
if constexpr (std::is_same_v)
{
ap_caller_part(std::move(ap_arg));
return;
}
else
{
return std::forward(ap_caller_part(std::move(ap_arg)));
}
}
private:
_Tagged_args_binder, Type_pack_tag> ap_caller_part;
T1 ap_arg;
};
The first argument to the ap_arg call and the rest of the recursive ap_caller_part object are stored here . Note that the T1 type "moved" from the first packet of types in this object to the second in the "tail" of the recursive object.
Specialization for rvalue links:
template
class _Tagged_args_binder, Type_pack_tag<>>
{
using move_ref_T1 = std::add_rvalue_reference_t>;
public:
using Ret_type = std::invoke_result_t;
_Tagged_args_binder(Func_type func, std::vector& args)
: ap_arg{ std::move(unihold::reference_any_cast(args.at(0))) },
ap_caller_part{ func, args }
{
}
auto operator()()
{
if constexpr (std::is_same_v)
{
ap_caller_part(std::move(unihold::reference_any_cast(ap_arg)));
}
else
{
return std::forward(ap_caller_part(std::move(unihold::reference_any_cast(ap_arg))));
}
}
auto operator()() const
{
if constexpr (std::is_same_v)
{
ap_caller_part(std::move(unihold::reference_any_cast(ap_arg)));
}
else
{
return std::forward(ap_caller_part(std::move(unihold::reference_any_cast(ap_arg))));
}
}
private:
_Tagged_args_binder, Type_pack_tag> ap_caller_part;
std::any ap_arg;
};
Template “right-handed” links are not really right-handed meanings. These are the so-called "universal links", which, depending on the type of T1 , become either T1 & , or T1 && . Therefore, you have to use workarounds: firstly, since specializations are defined for both types of links (it is not quite correctly said, for the reasons already stated) and for non-reference parameters, when you instantiate the template, the desired specialization will be selected, even if it is a right-handed link; secondly, to transfer the T1 type from package to package, the corrected version of move_ref_T1 is used , which is turned into a real rvalue link.
Specialization with a normal link is done in the same way, with the necessary corrections.
Ultimate specialization
template
class _Tagged_args_binder, Type_pack_tag>
{
public:
using Ret_type = std::invoke_result_t;
inline _Tagged_args_binder(Func_type func, std::vector& args)
: ap_func{ func } { }
inline auto operator()(Param_type... param)
{
if constexpr(std::is_same_v(param)...))>)
{
ap_func(std::forward(param)...);
return;
}
else
{
return std::forward(ap_func(std::forward(param)...));
}
}
inline auto operator()(Param_type... param) const
{
if constexpr(std::is_same_v)
{
ap_func(param...);
return;
}
else
{
return std::forward(ap_func(param...));
}
}
private:
Func_type ap_func;
};
This specialization is responsible for storing a functional object and, in fact, is a wrapper over it. It is the final recursive type.
Notice how Type_pack_tag is used here . All parameter types are now compiled in the left package. This means that they are all processed and packaged.
Now, I think, it becomes clear why it was necessary to use Type_pack_tag . The fact is, the language would not allow the use of two types packages side by side, for example, like this:
template
class _Tagged_args_binder
{
};
therefore, you have to separate them into two separate packages inside two types. In addition, you need to somehow separate the processed types from those that have not yet been processed.
Intermediate Specializations
From intermediate specializations, I will finally give a specialization, again, for value types, the rest is by analogy:
template
class _Tagged_args_binder, Type_pack_tag>
{
public:
using Ret_type = std::invoke_result_t;
static_assert(!std::is_same_v, "Void argument is not allowed");
inline _Tagged_args_binder(Func_type func, std::vector& args)
: ap_arg{ std::move(unihold::reference_any_cast(args.at(sizeof...(Param_type)))) },
ap_caller_part{ func, args } { }
inline auto operator()(Param_type... param)
{
if constexpr (std::is_same_v)
{
ap_caller_part(std::forward(param)..., std::move(ap_arg));
return;
}
else
{
return std::forward(ap_caller_part(std::forward(param)..., std::move(ap_arg)));
}
}
inline auto operator()(Param_type... param) const
{
if constexpr (std::is_same_v)
{
ap_caller_part(std::forward(param)..., std::move(ap_arg));
}
else
{
return std::forward(ap_caller_part(std::forward(param)..., std::move(ap_arg)));
}
}
private:
_Tagged_args_binder,
Type_pack_tag> ap_caller_part;
T1 ap_arg;
};
This specialization is intended to pack any argument except the first.
Binder class
The _Tagged_args_binder class is not intended for direct use, which I wanted to emphasize with a single underscore at the beginning of its name. Therefore, I will give the code of a small class, which is a kind of “interface” to this ugly and inconvenient to use type (which, however, uses rather unusual C ++ tricks, which gives it some charm, in my opinion):
namespace cutecpplib::delegates
{
template
class Variadic_args_binder
{
using binder_type = impl::_Tagged_args_binder, Type_pack_tag<>>;
public:
using Ret_type = std::invoke_result_t;
inline Variadic_args_binder(Functor_type function, Param_type... param)
: ap_tagged_binder{ function, param... } { }
inline Variadic_args_binder(Functor_type function, std::vector& 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 convention - passing links inside std :: any
An attentive reader must have noticed that the code uses the unihold :: reference_any_cast () function . This function, as well as its analogue unihold :: pointer_any_cast () , is designed to implement the library agreement: the arguments that must be passed by reference are passed by pointer to std :: any .
The reference_any_cast function always returns a reference to an object, whether the object itself is stored in the container or only a pointer to it. If std :: any contains an object, then a reference to this object is returned inside the container; if it contains a pointer, then a reference is returned to the object pointed to by the pointer.
For each function, there are options for the constant std :: anyand overloaded versions to determine if the std :: any container is the owner of the object or contains only a pointer.
Functions need to be explicitly specialized in the type of stored object, just like C ++ type conversions and similar template functions.
The code for these functions:
template
std::remove_reference_t& unihold::reference_any_cast(std::any& wrapper)
{
bool result;
return reference_any_cast(wrapper, result);
}
template
const std::remove_reference_t& unihold::reference_any_cast(const std::any& wrapper)
{
bool result;
return reference_any_cast(wrapper, result);
}
template
std::remove_reference_t& unihold::reference_any_cast(std::any& wrapper, bool& is_owner)
{
auto ptr = pointer_any_cast(&wrapper, is_owner);
if (!ptr)
throw std::bad_any_cast{ };
return *ptr;
}
template
const std::remove_reference_t& unihold::reference_any_cast(const std::any& wrapper, bool& is_owner)
{
auto ptr = pointer_any_cast(&wrapper, is_owner);
if (!ptr)
throw std::bad_any_cast{ };
return *ptr;
}
template
std::remove_reference_t* unihold::pointer_any_cast(std::any* wrapper, bool& is_owner)
{
using namespace std;
using NR_T = remove_reference_t; // No_reference_T
// Указатель на указатель внутри wrapper
NR_T** double_ptr_to_original = any_cast(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(wrapper))
{
// Wrapper содержит копию объекта
is_owner = true;
return ptr_to_copy;
}
else
{
throw bad_any_cast{};
}
}
template
const std::remove_reference_t* unihold::pointer_any_cast(const std::any* wrapper, bool& is_owner)
{
using namespace std;
using NR_T = remove_reference_t; // No_reference_T
// Указатель на указатель внутри wrapper
NR_T*const * double_ptr_to_original = any_cast(wrapper);
// Указатель на копию объекта внутри wrapper
const NR_T* ptr_to_copy;
//remove_reference_t* ptr2 = any_cast>(&wrapper);
if (double_ptr_to_original)
{
// Wrapper содержит указатель на оригинал объекта
is_owner = false;
return *double_ptr_to_original;
}
else if (ptr_to_copy = any_cast(wrapper))
{
// Wrapper содержит копию объекта
is_owner = true;
return ptr_to_copy;
}
else
{
throw bad_any_cast{};
}
}
template
std::remove_reference_t* unihold::pointer_any_cast(std::any* wrapper)
{
bool result;
return pointer_any_cast(wrapper, result);
}
template
const std::remove_reference_t* unihold::pointer_any_cast(const std::any* wrapper)
{
bool result;
return pointer_any_cast(wrapper, result);
}
Conclusion
I tried to briefly describe one of the possible approaches to solving the problem of dynamic function calls in C ++. Subsequently, this will form the basis of the C ++ delegate library (in fact, I already wrote the main functionality of the library, namely, polymorphic delegates, but the library still needs to be rewritten as it should, in order to demonstrate the code, and add some unrealized functionality). In the near future I plan to finish work on the library and tell how exactly I implemented the rest of the delegate functionality in C ++.
PS Using RTTI will be demonstrated in the next part.