Spiria logo.
Pierre Baillargeon
dans «  Développement desktop  »,
 
11 février 2021.

C++ : le pire des deux mondes

Le C++ est certainement un langage de programmation fort complexe. Les templates en sont une partie particulièrement difficile. Ajout tardif au C++, ils devaient s’intégrer à la syntaxe existante. En outre, au moment de la création de la librairie STL, les designers du langage C++ ont découvert que les templates avaient plus de pouvoir que prévu. En bref, ils avaient créé par accident un langage de type fonctionnel très pur. Malheureusement, cette origine accidentelle a fait en sorte que ce langage fonctionnel a une syntaxe à peine utilisable. Mais, étant donné sa puissance et la nécessité de répondre au besoin de techniques C++ avancées, les templates du C++ ne cessent de gagner en fonctionnalités et en puissance.

En contraste extrême, Python est souvent décrit comme l’un des langages de programmation les plus simples à apprendre et à lire. Il est également très dynamique, permettant de passer facilement n’importe quelle donnée à n’importe quelle fonction. Mieux, les fonctions peuvent être définies et redéfinies à tout moment. Lorsque vous appelez une fonction, vous ne savez jamais d’avance quel code sera vraiment exécuté.

Alors, pourquoi ne pas combiner les deux ? La complexité et la syntaxe obscure des templates du C++ avec les surprises dynamiques de Python ? Si ce genre de mélange peu ragoûtant vous intéresse, alors vous êtes à la bonne place !

Sérieusement…

En fait, mon but est de déplacer la résolution des fonctions surchargées (overloaded functions en anglais) à l’exécution du code plutôt qu’au moment de la compilation.

Je voulais créer un système dynamique d’appel de fonctions qui pourrait appeler une fonction avec n’importe quelle donnée. Je voulais que ce système soit dynamique et extensible, afin de permettre de nouvelles surcharges de la fonction pour de nouveaux types de données. Je voulais également pouvoir appeler ces fonctions avec des données concrètes ou un tas de std::any. Enfin, je voulais aussi que tout cela soit raisonnablement efficace.

Pour atteindre tous ces objectifs, je me suis tourné vers les templates. Pas de simples templates, mais la version plus complexe des templates variadiques.

Syntaxe des templates variadiques

Qu’est-ce qu’un template variadique ? Les templates normaux prennent en argument un nombre de types fixe. C’est bien quand on sait à l’avance combien de types on doit utiliser. En revanche, un template variadique prend en argument un nombre variable de types. Il peut en recevoir zéro, un, deux, ou n’importe combien.

En plus de recevoir ces types, le template doit être capable de s’en servir. Comme vous le savez peut-être déjà, les templates sont entièrement générés à la compilation. Ils doivent donc fonctionner sans modifier aucune donnée. Ainsi, pour manipuler un nombre variable de types, une nouvelle syntaxe a dû être ajoutée au C++. La nouvelle syntaxe pour la réception des types et leur utilisation a été créée avec l’ellipse : .

L’astuce de base est que chaque fois que l’ellipse est utilisée, elle indique au compilateur C++ de générer autant de fois que nécessaire le code qui entoure l’ellipse. Par exemple, les types du template sont reçus avec une ellipse. Dans l’exemple suivant, l’argument VARIA du template représente un nombre quelconque de types.

template <class... VARIA>
struct example
{
   // le code du template serait ici.
};

Par la suite, dans le code du template, les arguments variadiques peuvent être utilisés avec une ellipse. Par exemple, le template variadique présenté ci-haut pourrait avoir une fonction qui recevrait des arguments pour les transmettre à une autre fonction, comme ceci :

// Réception d'un nombre variable d'arguments...
void foo(VARIA... function_arguments)
{
   // ... passation à une autre fonction.
   other_bar_function(function_arguments...);
}

Ces exemples ne font qu’effleurer la surface de ce qui est possible avec modèles variés, mais ils seront suffisants pour notre objectif dans cet article.

Design d’appels dynamiques

Avant de nous lancer dans le design de notre système d’appels dynamique, exposons plus concrètement nos exigences. J’ai dit qu’il devrait imiter la surcharge de fonctions du C++. Qu’est-ce que cela signifie concrètement ? Voici nos exigences :

  • La fonction elle-même est déclarée et référencée par son nom, comme une fonction normale.
  • Le nombre d’arguments de cette fonction peut varier.
  • Chaque surcharge de cette fonction peut avoir un type de valeur de retour différent.
  • Cette fonction peut être surchargée pour n’importe quel type.
  • Une nouvelle surcharge de fonction peut être ajoutée dynamiquement, au moment de l’exécution, pour tout type.

Bien que ces exigences soient suffisantes pour atteindre notre objectif, je désirais quelques ajouts. Le premier ajout est de supporter des arguments de fonction ayant un type fixe. Par exemple, une fonction pour écrire recevrait toujours en argument un std::ostream. Le deuxième est de permettre sélectionner la surcharge d’une fonction sans avoir à passer d’argument à la fonction. Par exemple, ceci permet de spécifier le type de retour de la fonction ou bien d’écrire une fonction ne prenant aucun argument.

Pour supporter tout cela, nous ajoutons deux exigences à la liste :

  • Tous les arguments n’ont pas à jouer un rôle dans la sélection de la fonction.
  • Des types supplémentaires peuvent jouer un rôle dans la sélection de la fonction sans en être un argument.

Le résultat doit ressembler à la surcharge de fonctions normale C++. Par exemple, voici à quoi ressemble un appel à la fonction to_text dans le système d’appels dynamique :

std::wstring resultat = to_text(7);
// resultat == "7"
std::any seven(7);
std::wstring resultat = to_text(seven);
// resultat ==  "7"

Pour parvenir à une telle similitude comparée à la surcharge de fonction, un grand nombre d'éléments complexes travaillent en arrière-scène.

Smooth Operator

Avant de montrer comment les appels dynamique fonctionnent, nous allons voir comment ils se présentent du point de vue du programmeur voulant créer une nouvelle opération.

Pour créer une nouvelle fonction appelée foo, il faut déclarer une classe pour la représenter. Pour notre exemple, nous l'avons nommée foo_op_t et l'avons dérivée de op_t. Cette classe foo_op_t ne sert qu'à identifier notre fonction. Elle peut être entièrement vide! Ensuite, nous pouvons écrire la fonction foo elle-même, le vrai point d'entrée. Cette fonction est très simple: elle ne fait qu'appeler la fonction call<>::op() (pour des valeurs de type concret) ou bien call_any<>::op() (pour des valeurs de type std::any), toutes deux contenue dans foo_op_t, qui fera tout le travail:

struct foo_op_t : op_t<foo_op_t> { /* empty! */ };

inline std::any foo(const std::any& arg_a, const std::any& arg_b)
{
   return foo_op_t::call_any<>::op(arg_a, arg_b);
}

template<class A, class B, class RET>
inline RET foo(const A& arg_a, const A& arg_b)
{
   std::any result = foo_op_t::call<>::op(arg_a, arg_b);
   // Notez: nous pourrions aussi vérifier que le std::any
   //        contient bien un RET, plutôt qu'en être sûr.
   return any_cast<RET>(result);
}

Notez que la classe de base de la nouvelle opération prend l’opération elle-même comme paramètre de template. Il s’agit d’une astuce bien connue dans la programmation de templates. Elle est si connue qu’elle a même un nom : curiously recursive template pattern. Dans notre cas, cette astuce est utilisée pour que le op_t puisse se référer à l’opération spécifique utilisée.

Maintenant, nous pouvons créer des surcharges de la fonction foo. Ceci est fait en appelant make<>::op avec une fonction qui implémente la surcharge. Pour créer une surcharge prenant les types A et B et retournant le type RET, nous appellerions make<>::op<RET, A, B>. Cela enregistre la surcharge dans la classe foo_op_t. Par exemple, implémentons notre foo pour les types int et double, retournant un float :

// Code de la surcharge.
float foo_for_int_and_double(int i, double d)
{
   return float(i + d);
}

// Enregistrement de la surcharge.
foo_op_t::make<>::op<float, int, double>(foo_for_int_and_double);

Bien sûr, nous pourrions raccourcir et simplifier ceci en rédigeant le code dans l’appel à make<>::op lui-même, avec un lambda. C’est même le style que vous je suggère :

foo_op_t::make<>::op<float, int, double>(
   [](int i, double d) -> float
   {
      return float(i + d);
   }
);

Si vous vous demandez pourquoi les appels call<> et make<> ont les sigils de templates, c’est bien sûr parce qu’ils sont des templates variadiques. Les arguments optionnels de template sont les types supplémentaires qui permettent de choisir une surcharge de fonction plus précisément, sans que ces types soient passés en argument à la fonction foo. Nous verrons cela plus en détail plus loin.

Nous sommes maintenant prêts à entrer dans le vif du sujet : le design des appels dynamiques.

C’est le sélecteur

Le premier problème à résoudre est la manière dont chaque surcharge est identifiée au sein d’une famille de fonction. La solution évidente est de l’identifier par les types des arguments et les types supplémentaires optionnels de sélection. C++ fournit les classes std::type_info et std::type_index pour identifier un type. Ce dont nous avons besoin, c’est d’un tuple de ces type_index. Pour ce faire, nous utilisons deux templates : le convertisseur et le sélecteur.

Le convertisseur convertit (eh ben…) n’importe quel type vers le type std::type_index. Écrire ainsi une classe pour implémenter une étape d’un algorithme est une astuce très idiomatique des templates. Ceci permet d’exécuter l’algorithme pendant la compilation. Voici donc le convertisseur, qui convertit tout type A vers les types type_index et std::any :

template <class A>
struct type_converter_t
{
   using type_index = std::type_index;
   using any = std::any;
};

Le sélecteur de type peut alors être écrit comme un template variadique en appliquant le convertisseur à tous les types donnés comme argument et en déclarant un type tuple nommé selector_t avec le résultat. Ce tuple contient les types des arguments de la fonction que l’on est en train de créer, N_ARY, et les types supplémentaires de sélection, EXTRA_SELECTORS, afin d’avoir un sélecteur complet.

template <class... EXTRA_SELECTORS>
struct op_selector_t
{
   template <class... N_ARY>
   struct n_ary_t
   {
      // Le type selector_t est un tuple de type_index.
      using selector_t = std::tuple<
         typename type_converter_t<EXTRA_SELECTORS>::type_index...,
         typename type_converter_t<N_ARY>::type_index...>;
   };
};

Notez comment l’ellipse est appliquée à la ligne :

typename type_converter_t<EXTRA_SELECTORS>::type_index...

La façon dont le langage C++ applique l’ellipse est un peu la magie noire des templates variadiques. Parfois, vous devrez faire plusieurs essais pour trouver ce qui fonctionne et ce qui ne fonctionne pas.

Nous avons maintenant un sélecteur, mais comment l’utiliser ? Pour cela, nous fournissons quelques fonctions. L’objectif est d’avoir une fonction qui construit un sélecteur rempli avec les types concrets. Naturellement, nous appelons notre fonction make :

template <class... EXTRA_SELECTORS>
struct op_selector_t
{
   template <class... N_ARY>
   struct n_ary_t
   {
      template <class A, class B>
      static selector_t make()
      {
         return selector_t(
            std::type_index(typeid(EXTRA_SELECTORS))...,
            std::type_index(typeid(N_ARY))...);
      }
   };
};

Puisque je veux supporter les appels de fonction avec std::any, nous devons fournir une fonction make_any prenant des std::any en arguments. (En tant qu’optimisation, une version avec les sélecteurs supplémentaires déjà convertis en type_index est fournie sous le nom make_extra_any, mais elle n’est pas montrée ici.)

static selector_t make_any(const typename type_converter_t<N_ARY>::any&... args)
{
   return selector_t(
      std::type_index(typeid(EXTRA_SELECTORS))...,
      std::type_index(args.type())...);
}

Plongée mécanique

Enfin, nous pouvons nous plonger dans les détails mécaniques de l’enregistrement et de l’appel des fonctions. La classe de base des opérations est déclarée comme un template prenant l’opération elle-même et la liste des arguments immuables supplémentaires, EXTRA_ARGS, qui auront donc des types fixes. (Rappelez-vous notre précédent exemple d’opération d’écriture, qui reçoit toujours un std::ostream en argument.)

template <class OP, class... EXTRA_ARGS>
struct op_t
{
   // Détails internes décrit ci-bas...
};

Les premiers détails internes que nous verrons sont quelques types utilisés à plusieurs reprises : la classe sélecteur (op_sel_t), le tuple sélecteur (selector_t) et la représentation interne des fonctions (op_func_t).

using op_sel_t = typename op_selector_t<EXTRA_SELECTORS...>::template n_ary_t<N_ARY...>;
using selector_t = typename op_sel_t::selector_t;
using op_func_t = std::function<std::any(EXTRA_ARGS ..., typename type_converter_t<N_ARY>::any...)>;

Ce code montre une partie de la complexité inhérente à la programmation des templates. Il y a plusieurs éléments du code qui seraient normalement totalement superflus. Mais, dans le contexte particulier des templates, ces éléments sont nécessaires. Par exemple, Le typename est nécessaire pour dire au compilateur que ce qui suit vraiment est un type. Cela arrive quand un template fait référence à des éléments d’un autre template. La syntaxe C++ est trop ambiguë pour que le compilateur puisse déduire que nous utilisons un type. Un autre élément très particulier est le mot-clé template se trouve juste avant l’accès à n_ary_t. Cet ajout est nécessaire pour dire au compilateur qu’il s’agit réellement d’un template.

Nous sommes donc prêts à décrire l’ensemble du système, construit à partir de quelques fonctions :

  • Appeler l’opération : call<>::op
  • Créer une nouvelle surcharge : make<>::op
  • Rechercher la surcharge correcte : get_ops

Nous nous attaquerons à chacune d’entre elles dans l’ordre inverse, en partant des tréfonds du design jusqu’à notre but final : appeler une surcharge.

Gardien des merveilles

Le tréfonds du design est la fonction qui détient les surcharges déjà enregistrées. Il y a une raison très importante pour laquelle get_ops doit exister. En effet, les surcharges doivent bien être conservées dans un conteneur, mais que notre classe d’opération est un template. Nous ne pouvons pas garder toutes les surcharges pour toutes les opérations ensemble. Heureusement, en C++, nous avons la garantie qu’une variable statique contenue dans une fonction d’un template est unique pour chaque instanciation du template. Donc, get_ops peut contenir en toute sécurité notre liste de surcharge :

template <class SELECTOR, class OP_FUNC>
static std::map<SELECTOR, OP_FUNC>& get_ops()
{
   static std::map<SELECTOR, OP_FUNC> ops;
   return ops;
}

Le fait que get_ops soit un template pour SELECTOR et pour OP_FUNC permet supporter l’enregistrement de surcharges avec un nombre d’arguments différents.

Création d’opérations

La fonction make<>::op est un template qui prend une surcharge de fonction que vous avez écrite pour des types concrets. Elle enveloppe la surcharge dans la représentation interne de la fonction et l’enregistre. L’enveloppe se charge de convertir les std::any en des types concrets. C’est sans danger, puisque la surcharge pour ces types concrets n’est appelée que lorsque les types correspondent. C’est ici que les types de sélection supplémentaires facultatifs peuvent être fournis en arguments du template, sous le nom EXTRA_SELECTORS.

template <class... EXTRA_SELECTORS>
struct make
{
   template <class RET, class... N_ARY>
   static void op(
      std::function<RET(EXTRA_ARGS... extra_args, N_ARY... args)> a_func)
   {
      // Enveloppe gardée sous la forme d’un lambda qui
      // convertit la représentation interne de la fonction
      // vers la signature réelle de la surcharge.
      op_func_t op(
         [a_func](
            EXTRA_ARGS... extra_args,
            const typename type_converter_t<N_ARY>::any&... args) -> std::any
         {
            // Conversion vers les types concrets.
            return std::any(a_func(extra_args..., *std::any_cast<N_ARY>(&args)...));
         }
      );

      // Enregistrement.
      auto& ops = get_ops<selector_t, op_func_t>();
      ops[op_sel_t::make()] = op;
   }
};

Appels à la pelle

Nous arrivons enfin à la fonction utilisée pour envoyer un appel. Il y a trois versions de la fonction. Les seules différences entre elles sont si les arguments sont déjà convertis en std::any et si les sélecteurs supplémentaires facultatifs sont déjà convertis en std::type_index. Voici ce que la fonction call<>::op doit faire :

  • Créer un sélecteur à partir des types de ses arguments, plus les sélecteurs supplémentaires optionnels.
  • Aller chercher la liste des surcharges disponibles.
  • Trouver la surcharge de fonction à l’aide du sélecteur.
  • Retournez une valeur vide si aucune surcharge ne correspond aux arguments.
  • Appeler la fonction trouvée si une surcharge correspond aux arguments.
template <class... EXTRA_SELECTORS>
struct call
{
   template <class... N_ARY>
   static std::any op(EXTRA_ARGS... extra_args, N_ARY... args)
   {
      // Les surcharges disponibles.
      const auto& ops = get_ops<selector_t, op_func_t>();
      // Trouver une surcharge correspondante.
      const auto pos = ops.find(op_sel_t::make());
      // Résultat vide si auncune surcharge n'est trouvée.
      if (pos == ops.end())
         return std::any();
      // Appel à la bonne surcharge trouvée.
      return pos->second(extra_args..., args...);
   }
};

Point final

Cela complète la description du design du système dynamique d’appels surchargés. Le repo du code source contient de multiples exemples d’opérations avec une suite complète de tests.

Les opérations données en exemple sont :

  • compare, une opération binaire pour comparer deux valeurs.
  • convert, une opération unaire pour convertir une valeur vers autre type. Cet exemple montre une opération avec un argument de sélection supplémentaire, le type final de la conversion.
  • is_compatible, une opération nullaire prenant deux types supplémentaires pour sélectionner la surcharge, et qui vérifie si l’un peut être converti en l’autre.
  • size, une opération unitaire retournant le nombre d’éléments dans un conteneur, ou retournant zéro si aucune surcharge n’a été trouvée.
  • stream, une opération unaire pour écrire une valeur dans un flux de texte. C’est un exemple d’une opération avec un argument immuable, la destination std::ostream.
  • to_text, une opération unitaire de conversion d’une valeur en texte.

Tout le code se trouve dans la bibliothèque any_op qui fait partie de mon repo dak_utility.

Partager l’article :