Antecedentes
Me gusta el lenguaje C ++. Incluso diría que este es mi idioma favorito. Además, utilizo tecnologías .NET para mi desarrollo, y muchas de las ideas que contiene, en mi opinión, son simplemente increíbles. Una vez que se me ocurrió la idea, ¿cómo implementar algunos medios de reflexión y llamadas a funciones dinámicas en C ++? Realmente quería que C ++ tuviera una ventaja de CLI como llamar a un delegado con un número desconocido de parámetros y sus tipos. Esto puede ser útil, por ejemplo, cuando no se sabe de antemano a qué tipo de datos se debe llamar la función.
Por supuesto, una imitación completa de delegados es demasiado complicada, por lo que este artículo demostrará solo la arquitectura general de la biblioteca y la solución a algunos problemas importantes que surgen cuando se trata de lo que el lenguaje no admite directamente.
Llamar a funciones con un número indefinido de parámetros y tipos desconocidos durante la compilación
Por supuesto, este es el principal problema con C ++, que no es tan fácil de resolver. Por supuesto, en C ++ hay una herramienta heredada de C -
varargs , y lo más probable es que esto sea lo primero que viene a la mente ... Sin embargo, no encajan, en primer lugar, debido a su naturaleza insegura de tipo (como muchas cosas de C), En segundo lugar, cuando se utilizan dichos argumentos, debe saber de antemano qué tipos de argumentos son. Sin embargo, casi con certeza, estos no son todos los problemas con
varargs . En general, esta herramienta no es un asistente aquí.
Y ahora enumeraré las herramientas que me ayudaron a resolver este problema.
std :: any
Comenzando con C ++ 17, el lenguaje tiene un maravilloso contenedor contenedor para cualquier cosa, alguna similitud distante con
System.Object en la CLI es
std :: any . Este contenedor realmente puede almacenar cualquier cosa, e incluso cómo: ¡eficientemente! - el estándar recomienda que almacene objetos pequeños directamente en él, los objetos grandes ya se pueden almacenar en la memoria dinámica (aunque este comportamiento no es obligatorio, Microsoft lo hizo en su implementación de C ++, lo cual es una buena noticia). Y solo se puede llamar similitud porque System.Object está involucrado en la relación de herencia ("es un"), y std :: any está involucrado en la relación de membresía ("tiene un"). Además de los datos, el contenedor contiene un puntero a un objeto
std :: type_info - RTTI sobre el tipo cuyo objeto está "acostado" en el contenedor.
Se asigna un archivo de encabezado completo
<any> para el contenedor.
Para "extraer" un objeto del contenedor, debe usar la función de plantilla
std :: any_cast () , que devuelve una referencia al objeto.
Ejemplo de uso:
#include <any> void any_test() { std::any obj = 5; int from_any = std::any_cast<int>(obj); }
Si el tipo solicitado no coincide con lo que tiene el objeto dentro del contenedor, se
genera una excepción
std :: bad_any_cast .
Además de las
clases std :: any ,
std :: bad_any_cast y
std :: any_cast , en el archivo de encabezado hay una función de plantilla
std :: make_any similar a
std :: make_shared ,
std :: make_pair y otras funciones de este tipo.
RTTI
Por supuesto, sería prácticamente irreal en C ++ implementar una llamada de función dinámica sin información de tipo en tiempo de ejecución. Después de todo, es necesario verificar de alguna manera si se pasan o no los tipos correctos.
El soporte RTTI primitivo en C ++ ha existido durante bastante tiempo. Ese es el punto, eso es primitivo: podemos aprender poco sobre un tipo, a menos que los nombres decorados y sin decorar. Además, podemos comparar tipos entre sí.
Típicamente, el término "RTTI" se usa en conexión con los tipos polimórficos. Sin embargo, aquí usaremos este término en un sentido más amplio. Por ejemplo, tendremos en cuenta el hecho de que cada tipo tiene información sobre el tipo en tiempo de ejecución (aunque solo puede obtenerlo estáticamente en tiempo de compilación, a diferencia de los tipos polimórficos). Por lo tanto, es posible (y necesario) comparar tipos incluso de tipos no polimórficos (perdón por la tautología) en tiempo de ejecución.
Se puede acceder a RTTI usando la
clase std :: type_info . Esta clase se encuentra en el archivo de encabezado
<typeinfo> . Se puede obtener una referencia a un objeto de esta clase (al menos por ahora) solo usando el operador
typeid () .
Patrones
Otra característica extremadamente importante del lenguaje que necesitamos para realizar nuestras ideas son las plantillas. Esta herramienta es bastante poderosa y extremadamente difícil, de hecho le permite generar código en tiempo de compilación.
Las plantillas son un tema muy amplio y no será posible revelarlo dentro del marco del artículo, y no es necesario. Asumimos que el lector comprende de qué se trata. Algunos puntos oscuros serán revelados en el proceso.
Ajuste de argumentos seguido de una llamada
Entonces, tenemos una cierta función que toma varios parámetros como entrada.
Te mostraré un bosquejo de código que explicará mis intenciones.
#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(); }
Usted puede preguntar, ¿cómo es esto posible? El nombre de clase
Variadic_args_binder le dice que el objeto une la función y los argumentos que necesita pasarle cuando lo llama. Por lo tanto, solo queda llamar a esta carpeta como una función sin parámetros.
Entonces se ve afuera.
Si inmediatamente, sin pensarlo, supone que esto se puede implementar, entonces puede venir a la mente escribir varias especializaciones
Variadic_args_binder para un número diferente de parámetros. Sin embargo, esto no es posible si es necesario admitir un número ilimitado de parámetros. Y aquí está el problema: los argumentos, desafortunadamente, necesitan ser sustituidos en la llamada a la función estáticamente, es decir, en última instancia para el compilador, el código de la llamada debe reducirse a esto:
fun_ptr(param1, param2, …, paramN);
Así es como funciona C ++. Y todo esto se complica mucho.
¡Solo la plantilla mágica puede manejarlo!
La idea principal es crear tipos recursivos que almacenen en cada nivel de anidamiento uno de los argumentos o una función.
Entonces, declare la clase
_Tagged_args_binder :
namespace delegates::impl { template <typename Func_type, typename... T> class _Tagged_args_binder; }
Para "transferir" convenientemente los paquetes de tipos, crearemos un tipo auxiliar,
Type_pack_tag (por qué esto era necesario, pronto se aclarará):
template <typename... T> struct Type_pack_tag { };
Ahora creamos especializaciones de la clase
_Tagged_args_binder .
Especializaciones Iniciales
Como saben, para que la recursividad no sea infinita, es necesario definir casos límite.
Las siguientes especializaciones son iniciales. Para simplificar, citaré especializaciones solo para tipos sin referencia y tipos de referencia rvalue.
Especialización para valores de parámetros directamente:
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; };
El primer argumento para la llamada
ap_arg y el resto del objeto recursivo
ap_caller_part se
almacenan aquí . Tenga en cuenta que el tipo
T1 "se movió" del primer paquete de tipos en este objeto al segundo en la "cola" del objeto recursivo.
Especialización para enlaces 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; };
Los enlaces de plantilla "diestros" no son realmente significados diestros. Estos son los llamados "enlaces universales", que, dependiendo del tipo de
T1 , se convierten en
T1 & o
T1 && . Por lo tanto, debe usar soluciones alternativas: en primer lugar, dado que las especializaciones se definen para ambos tipos de enlaces (no se dice correctamente, por las razones ya mencionadas) y para los parámetros que no son de referencia, cuando crea una instancia de la plantilla, se seleccionará la especialización necesaria, incluso si es un enlace derecho; en segundo lugar, para transferir el tipo
T1 de paquete a paquete,
se utiliza la versión corregida de
move_ref_T1 , que se convierte en un enlace de valor real.
La especialización con un enlace normal se realiza de la misma manera, con las correcciones necesarias.
Máxima especialización
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; };
Esta especialización es responsable de almacenar un objeto funcional y, de hecho, es una envoltura sobre él. Es el tipo recursivo final.
Observe cómo
se usa
Type_pack_tag aquí. Todos los tipos de parámetros ahora se compilan en el paquete de la izquierda. Esto significa que todos están procesados y empaquetados.
Ahora, creo, queda claro por qué era necesario usar
Type_pack_tag . El hecho es que el lenguaje no permitiría el uso de paquetes de dos tipos uno al lado del otro, por ejemplo, así:
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...> { };
por lo tanto, debe separarlos en dos paquetes separados dentro de dos tipos. Además, debe separar de alguna manera los tipos procesados de los que aún no se han procesado.
Especializaciones Intermedias
De las especializaciones intermedias, finalmente daré una especialización, nuevamente, para los tipos de valor, el resto es por analogía:
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; };
Esta especialización está destinada a empaquetar cualquier argumento, excepto el primero.
Clase de carpeta
La clase
_Tagged_args_binder no
está destinada para uso directo, lo que quería enfatizar con un solo guión bajo al comienzo de su nombre. Por lo tanto, le daré el código de una clase pequeña, que es una especie de "interfaz" para este tipo de uso desagradable e inconveniente (que, sin embargo, utiliza trucos C ++ bastante inusuales, lo que le da cierto encanto, en mi opinión):
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; }; }
Convención unihold - paso de enlaces dentro de std :: any
Un lector atento debe haber notado que el código usa la función
unihold :: reference_any_cast () . Esta función, así como su
unihold analógico
:: pointer_any_cast () , está diseñada para implementar el acuerdo de la biblioteca: los argumentos que se deben pasar por referencia se pasan por puntero a
std :: any .
La función
reference_any_cast siempre devuelve una referencia a un objeto, ya sea que el objeto esté almacenado en el contenedor o solo un puntero al mismo. Si
std :: any contiene un objeto, se devuelve una referencia a este objeto dentro del contenedor; si contiene un puntero, se devuelve una referencia al objeto señalado por el puntero.
Para cada función, hay opciones para la constante
std :: any y versiones sobrecargadas para determinar si el contenedor
std :: any posee un objeto o contiene solo un puntero.
Las funciones deben estar explícitamente especializadas en el tipo de objeto almacenado, al igual que las conversiones de tipo C ++ y funciones de plantilla similares.
El código para estas funciones:
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>;
Conclusión
Traté de describir brevemente uno de los posibles enfoques para resolver el problema de las llamadas a funciones dinámicas en C ++. Posteriormente, esto formará la base de la biblioteca de delegados de C ++ (de hecho, ya he escrito la funcionalidad básica de la biblioteca, a saber, delegados polimórficos, pero la biblioteca aún debe reescribirse como debería, para demostrar el código y agregar algunas funcionalidades no realizadas). En un futuro próximo planeo terminar el trabajo en la biblioteca y decir cómo exactamente implementé el resto de la funcionalidad de delegado en C ++.
PS El uso de RTTI se demostrará en la siguiente parte.