Contexte
J'aime le langage C ++. Je dirais même que c'est ma langue préférée. De plus, j'utilise des technologies .NET pour mon développement, et bon nombre des idées qu'il contient, à mon avis, sont tout simplement incroyables. Une fois que j'ai eu l'idée - comment implémenter des moyens de réflexion et des appels de fonctions dynamiques en C ++? Je voulais vraiment que C ++ ait un tel avantage CLI que d'appeler un délégué avec un nombre inconnu de paramètres et leurs types. Cela peut être utile, par exemple, lorsque l'on ne sait pas à l'avance quels types de données la fonction doit être appelée.
Bien sûr, une imitation complète des délégués est trop compliquée, donc cet article ne montrera que l'architecture générale de la bibliothèque et la solution à certains problèmes importants qui se posent lorsque vous traitez avec ce qui n'est pas directement pris en charge par la langue.
Appeler des fonctions avec un nombre indéfini de paramètres et de types inconnus pendant la compilation
Bien sûr, c'est le principal problème avec C ++, qui n'est pas si facile à résoudre. Bien sûr, en C ++, il existe un outil hérité de C -
varargs , et c'est probablement la première chose qui vient à l'esprit ... Cependant, ils ne conviennent pas, premièrement, en raison de leur nature non sécurisée (comme beaucoup de choses de C), deuxièmement, lorsque vous utilisez de tels arguments, vous devez savoir à l'avance quels types d'arguments sont. Cependant, presque certainement, ce ne sont pas tous les problèmes avec les
varargs . En général, cet outil n'est pas un assistant ici.
Et maintenant, je vais lister les outils qui m'ont aidé à résoudre ce problème.
std :: any
À partir de C ++ 17, le langage a un merveilleux conteneur conteneur pour n'importe quoi - une similitude lointaine avec
System.Object dans la CLI est
std :: any . Ce conteneur peut vraiment stocker n'importe quoi, et même comment: efficacement! - la norme vous recommande de stocker directement les petits objets dedans, les gros objets peuvent déjà être stockés dans la mémoire dynamique (bien que ce comportement ne soit pas obligatoire, Microsoft l'a fait dans son implémentation C ++, ce qui est une bonne nouvelle). Et seulement cela peut être appelé similitude car System.Object est impliqué dans la relation d'héritage ("est un"), et std :: any est impliqué dans la relation d'appartenance ("a un"). En plus des données, le conteneur contient un pointeur vers un objet
std :: type_info - RTTI sur le type dont l'objet est "couché" dans le conteneur.
Un fichier d'en-tête entier
<any> est alloué au conteneur.
Pour «extraire» un objet du conteneur, vous devez utiliser la fonction de modèle
std :: any_cast () , qui renvoie une référence à l'objet.
Exemple d'utilisation:
#include <any> void any_test() { std::any obj = 5; int from_any = std::any_cast<int>(obj); }
Si le type demandé ne correspond pas à ce que l'objet a à l'intérieur du conteneur, une exception
std :: bad_any_cast est levée .
En plus des
classes std :: any ,
std :: bad_any_cast et des fonctions
std :: any_cast , le fichier d'en-tête
contient une fonction modèle
std :: make_any similaire à
std :: make_shared ,
std :: make_pair et d'autres fonctions de ce type.
RTTI
Bien sûr, il serait pratiquement irréaliste en C ++ d'implémenter un appel de fonction dynamique sans informations de type lors de l'exécution. Après tout, il est nécessaire de vérifier en quelque sorte si les bons types sont passés ou non.
Le support RTTI primitif en C ++ existe depuis un certain temps. C'est juste le point, c'est primitif - nous pouvons en apprendre peu sur un type, à moins que les noms décorés et non décorés. De plus, nous pouvons comparer les types entre eux.
Typiquement, le terme "RTTI" est utilisé en relation avec les types polymorphes. Cependant, nous utiliserons ici ce terme dans un sens plus large. Par exemple, nous prendrons en compte le fait que chaque type possède des informations sur le type lors de l'exécution (bien que vous ne puissiez les obtenir que statiquement au moment de la compilation, contrairement aux types polymorphes). Par conséquent, il est possible (et nécessaire) de comparer des types de types même non polymorphes (désolé pour la tautologie) au moment de l'exécution.
RTTI est accessible à l'aide de la
classe std :: type_info . Cette classe se trouve dans le fichier d'en-tête
<typeinfo> . Une référence à un objet de cette classe ne peut être obtenue (au moins pour l'instant) qu'en utilisant l'opérateur
typeid () .
Patterns
Une autre caractéristique extrêmement importante du langage dont nous avons besoin pour réaliser nos idées est les modèles. Cet outil est assez puissant et extrêmement difficile, en fait il vous permet de générer du code au moment de la compilation.
Les modèles sont un sujet très large, et il ne sera pas possible de le révéler dans le cadre de l'article, et ce n'est pas nécessaire. Nous supposons que le lecteur comprend de quoi il s'agit. Certains points obscurs seront révélés au cours du processus.
Habillage d'argument suivi d'un appel
Donc, nous avons une certaine fonction qui prend plusieurs paramètres en entrée.
Je vais vous montrer un croquis de code qui expliquera mes intentions.
#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(); }
Vous pouvez demander, comment est-ce possible? Le nom de classe
Variadic_args_binder vous indique que l'objet lie la fonction et les arguments que vous devez lui passer lorsque vous l'appelez. Ainsi, il ne reste plus qu'à appeler ce classeur comme une fonction sans paramètres!
Il regarde donc dehors.
Si immédiatement, sans réfléchir, faites une hypothèse sur la façon dont cela peut être implémenté, alors il peut vous venir à l'esprit d'écrire plusieurs spécialisations
Variadic_args_binder pour un nombre différent de paramètres. Cependant, cela n'est pas possible s'il est nécessaire de prendre en charge un nombre illimité de paramètres. Et voici le problème: les arguments, malheureusement, doivent être substitués statiquement dans l'appel de fonction, c'est-à-dire qu'en fin de compte pour le compilateur, le code d'appel doit être réduit à ceci:
fun_ptr(param1, param2, …, paramN);
C'est ainsi que C ++ fonctionne. Et tout cela complique grandement.
Seul le modèle magique peut le gérer!
L'idée principale est de créer des types récursifs qui stockent à chaque niveau d'imbrication l'un des arguments ou une fonction.
Donc, déclarez la classe
_Tagged_args_binder :
namespace delegates::impl { template <typename Func_type, typename... T> class _Tagged_args_binder; }
Pour "transférer" les paquets de type, nous allons créer un type auxiliaire,
Type_pack_tag (pourquoi cela était nécessaire, cela deviendra clair bientôt):
template <typename... T> struct Type_pack_tag { };
Nous créons maintenant des spécialisations de la classe
_Tagged_args_binder .
Spécialisations initiales
Comme vous le savez, pour que la récursivité ne soit pas infinie, il est nécessaire de définir des cas limites.
Les spécialisations suivantes sont initiales. Par souci de simplicité, je ne citerai les spécialisations que pour les types non référence et les types référence rvalue.
Spécialisation pour les valeurs de paramètres directement:
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; };
Le premier argument de l'appel
ap_arg et le reste de l'objet récursif
ap_caller_part sont
stockés ici . Notez que le type
T1 "déplacé" du premier paquet de types dans cet objet au second dans la "queue" de l'objet récursif.
Spécialisation pour les liens 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; };
Les modèles de liens «droitiers» ne sont pas vraiment des significations droitières. Ce sont les soi-disant «liaisons universelles» qui, selon le type de
T1 , deviennent soit
T1 & , soit
T1 && . Par conséquent, vous devez utiliser des solutions de contournement: premièrement, puisque les spécialisations sont définies pour les deux types de liens (ce n'est pas tout à fait correctement dit, pour les raisons déjà indiquées) et pour les paramètres non référentiels, lorsque vous instanciez le modèle, la spécialisation nécessaire sera sélectionnée, même s'il s'agit d'un lien droitier; deuxièmement, pour transférer le type
T1 de package à package, la version corrigée de
move_ref_T1 est utilisée , qui est transformée en un véritable lien rvalue.
La spécialisation avec un lien normal se fait de la même manière, avec les corrections nécessaires.
Spécialisation ultime
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; };
Cette spécialisation est responsable du stockage d'un objet fonctionnel et, en fait, est un wrapper par-dessus. C'est le dernier type récursif.
Remarquez comment
Type_pack_tag est utilisé ici. Tous les types de paramètres sont désormais compilés dans le package de gauche. Cela signifie qu'ils sont tous traités et emballés.
Maintenant, je pense, il devient clair pourquoi il était nécessaire d'utiliser
Type_pack_tag . Le fait est que le langage ne permettrait pas l'utilisation de deux types de packages côte à côte, par exemple, comme ceci:
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...> { };
par conséquent, vous devez les séparer en deux packages distincts dans deux types. De plus, vous devez en quelque sorte séparer les types traités de ceux qui n'ont pas encore été traités.
Spécialisations intermédiaires
Des spécialisations intermédiaires, je vais enfin donner une spécialisation, encore une fois, pour les types de valeur, le reste est par analogie:
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; };
Cette spécialisation est destinée à contenir n'importe quel argument à l'exception du premier.
Classe de liant
La classe
_Tagged_args_binder n'est pas destinée à une utilisation directe, que je voulais souligner avec un seul trait de soulignement au début de son nom. Par conséquent, je vais donner le code d'une petite classe, qui est une sorte d '"interface" à ce type laid et peu pratique à utiliser (qui, cependant, utilise des astuces C ++ plutôt inhabituelles, ce qui lui donne un certain charme, à mon avis):
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; }; }
Convention Unihold - Passer des liens à l'intérieur de std :: any
Un lecteur attentif doit avoir remarqué que le code utilise la fonction
unihold :: reference_any_cast () . Cette fonction, ainsi que son
unihold :: pointer_any_cast () analogique, est conçue pour implémenter l'accord de bibliothèque: les arguments qui doivent être passés par référence sont passés par pointeur à
std :: any .
La fonction
reference_any_cast renvoie toujours une référence à un objet, que l'objet lui-même soit stocké dans le conteneur ou seulement un pointeur sur celui-ci. Si
std :: any contient un objet, une référence à cet objet est renvoyée à l'intérieur du conteneur; s'il contient un pointeur, une référence est renvoyée à l'objet pointé par le pointeur.
Pour chaque fonction, il existe des options pour la constante
std :: any et les versions surchargées pour déterminer si le conteneur
std :: any possède un objet ou contient uniquement un pointeur.
Les fonctions doivent être explicitement spécialisées dans le type d'objet stocké, tout comme les conversions de type C ++ et les fonctions de modèle similaires.
Le code de ces fonctions:
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>;
Conclusion
J'ai essayé de décrire brièvement l'une des approches possibles pour résoudre le problème des appels de fonctions dynamiques en C ++. Par la suite, cela constituera la base de la bibliothèque des délégués C ++ (en fait, j'ai déjà écrit la fonctionnalité principale de la bibliothèque, à savoir les délégués polymorphes, mais la bibliothèque doit encore être réécrite comme il se doit, afin de pouvoir démontrer le code et ajouter des fonctionnalités non réalisées). Dans un avenir proche, je prévois de terminer le travail sur la bibliothèque et de dire exactement comment j'ai implémenté le reste des fonctionnalités de délégué en C ++.
PS L'utilisation de RTTI sera démontrée dans la partie suivante.