Code de grande personne – un trait est_convertible

Cet article est (à mon avis) plutôt costaud. Il met en relief une déficience d'une l'application naïve de l'outil de conversion lexical_cast et propose une stratégie pour la résoudre à l'aide de traits, en particulier un sélecteur de types. Certaines des manoeuvres sont semblables à celle, bien documentée, pour optimiser la fonction std::distance() définie sur des itérateurs. Une version complète des sources du lexical_cast utilisant est_convertible tel que décrit dans le présent article est disponible ici.

Je vous invite à lire et à comprendre les articles mentionnés dans le paragraphe précédent; sans eux, ce qui suit vous semblera probablement obscur – ce qui serait triste parce que le propos et, à mon humble avis, fort amusant.

Notez que, depuis C++ 11, il existe dans <type_traits> un trait std::is_convertible<S,D> qui fait tout ce que nous faisons ici et couvre sans doute des cas obscurs que je ne couvre pas moi-même. Si votre compilateur est suffisamment à jour, préférez les outils standards aux outils maison.

Notez que je ferai usage de Perfect Forwarding dans les exemples ci-dessous.

Examinons le code de base proposé pour la fonction de conversion lexical_cast discutée dans un autre article.

Ce code cherche à convertir une donnée d'un type S (au sens de source) en une donnée d'une type D (au sens de destination) de manière générique, sans connaître a priori la nature de S et de D. La seule supposition est qu'un S puisse être projeté sur un flux, qu'un D puisse être extrait d'un flux et que la forme sérialisée d'un S permette de reconstruire un D.

Le cas type d'une telle conversion est de transformer une std::string en un type primitif et inversement, par écrire des commandes comme :

auto s = "numéro " + lexical_cast<std::string>(12);
#include <sstream>
template <class D, class S>
   D lexical_cast(const S &src) {
      std::stringstream sstr;
      sstr << src;
      D dest;
      sstr >> dest;
      return dest;
   }

Comme me l'a souligné avec justesse mon collègue et ami François Boileau, une version plus robuste de lexical_cast() tiendrait compte du risque d'erreur lors de la conversion d'un S en D, ce qui se manifesterait ici par une incapacité d'extraire un D du std::stringstream où le S a été projeté. Si vous avez moins besoin de rapidité et plus besoin de résilience, envisagez cette version :

#include <sstream>
class erreur_conversion{};
template <class D, class S>
   D lexical_cast(const S &src) {
      std::stringstream sstr;
      sstr << src;
      D dest;
      if (!(sstr >> dest)) throw erreur_conversion{};
      return dest;
   }

La fonction lexical_cast écrite sous cette forme ne devrait pas être utilisée pour réaliser des conversions banales comme convertir un int en float, mais il se peut, en situation de programmation générique, qu'elle soit occasionnellement utilisée à cette fin.

On voudrait donc un mécanisme qui détecterait à la compilation (donc sans le moindre coût à l'exécution), sur la base des types S et D impliqués, laquelle des versions de lexical_cast ci-dessous devrait être privilégiée :

Version générale (passe par un flux) Spécialisation (conversion implicite de S à D) Spécialisation (conversion implicite de S à D), mieux encore
#include <sstream>
template <class D, class S>
   D lexical_cast(const S &src) {
      std::stringstream sstr;
      sstr << src;
      D dest;
      sstr >> dest;
      return dest;
   }
//
// conversion implicite, mais utile seulement s'il
// existe une conversion implicite de S vers D
//
template <class D, class S>
   D lexical_cast(const S &src)
      { return src; }
//
// conversion implicite, mais utile seulement s'il
// existe une conversion implicite de S vers D
//
template <class D, class S>
   D lexical_cast(S &&src)
      { return std::forward<S>(src); }

Je me suis inspiré en partie de Boost et en partie de Loki pour ceci...

Écrire est_convertible

La stratégie que nous utiliserons pour déterminer si un type S (pour Source) st implicitement convertible en un type D (pour Destination), que ce soit une conversion indigène comme celle de int à short ou qu'il s'agisse de quelque chose de plus subtil, reposant par exemple sur un constructeur paramétrique ou sur un opérateur de conversion, est présentée dans le code à droite.

Ce code demande clairement des explications – il y a beaucoup à apprendre de ces quelques lignes.

template <class S, class D>
   class est_convertible {
      using oui = char;
      struct non { char _[3]; };
      static oui test(D);
      static non test(...);
      static S creer();
   public:
      enum { value = sizeof(test(creer()))==sizeof(oui) }
   };

Dans le trait est_convertible, on trouve un certain nombre de déclarations privées :

Notre stratégie utilise le fait que l'opérateur statique sizeof évalue les tailles des types retournés par des sous-programmes sans jamais invoquer ces sous-programmes, et que la mécanique d'inférence de types de C++ est capable de choisir des invocations de sous-programmes sur la base des types impliqués.

Ici, on utilise les valeurs de deux invocations de sizeof() pour voir si S est convertible en D. La méthode creer() serait invoquée (théoriquement) et son résultat (un S) est passé en paramètre à l'une ou l'autre des deux déclinaisons de la méthode test(). Deux cas sont alors possibles :

Enfin, la constante booléenne de classe value sera true si la version de test() choisie retourne un oui, donc s'il existe une conversion implicite de S à D, et sera false sinon. Notez que toutes ces fonctions (les deux méthodes test() et la méthode creer()) ne sont jamais définies et ne sont jamais appelées; la seule inférence faite se base sur les types impliqués, sans égard au code qui serait éventuellement exécuté par l'une ou l'autre.

Depuis C++ 11, il existe une fonction std::declval<T>(), de l'en-tête <utility>. Elle permet d'éliminer la fonction est_convertible<S,D>::creer() dans notre exemple. En effet, le rôle de declval<T>() est de « retourner » un T&&; il faut comprendre que cette fonction ne retourne rien, et qu'elle est destinée à permettre d'évaluer à la compilation des caractéristiques d'un T sans avoir à construire un T, donc sans avoir à en connaître les constructeurs. C'est exactement ce que nous faisions avec creer() ci-dessus, qui « retournait » un S fort hypothétique car nous ne l'implémentions pas et ne l'appelions jamais, ne profitant que de sa signature pour raisonner.

Ainsi, on peut écrire est_convertible<S,D> comme suit, avec un compilateur suffisamment à jour :

#include <utility>
template <class S, class D>
   class est_convertible {
      using oui = char;
      struct non { char _ [3]; };
      static oui test(D);
      static non test(...);
   public:
      enum { value = sizeof(test(std::declval<S>())) == sizeof(oui) };
   };

struct X {};
struct Y { Y(X) {}};
#include <iostream>
int main() {
   using namespace std;
   if (est_convertible<X,Y>::value)
      cout << "On peut construire un Y à partir d'un X" << endl;
   else
      cout << "On ne peut pas construire un Y à partir d'un X" << endl;
   if (est_convertible<Y,X>::value)
      cout << "On peut construire un X à partir d'un Y" << endl;
   else
      cout << "On ne peut pas construire un X à partir d'un Y" << endl;
}

Choisir la bonne déclinaison de lexical_cast

Pour choisir entre deux déclinaisons de lexical_cast, nous aurons recours à une alternative statique. Nous aurons donc deux classes vides, respectivement nommées implicite et serialisation, dont le principal mérite est de ne pas entretenir de relation structurelle l'une avec l'autre. Nous choisirons l'une ou l'autre à l'aide d'une alternative statique, plus loin.

#include <type_traits>
class implicite {};
class serialisation {};

Nous définirons ensuite trois versions de lexical_cast, soit deux versions ayant chacune recours à deux paramètres (qui ne doivent pas être invoquées directement) et une version ne prenant qu'un seul paramètre (celle que nous souhaitons voir utilisée).

L'une des versions à deux paramètres prendra comme second paramètre une instance anonyme de la classe implicite et exprimera les opérations à faire lorsqu'il existe une conversion implicite de S à D.

L'autre version à deux paramètres prendra comme second paramètre une instance anonyme de la classe serialisation et exprimera les opérations à faire lorsqu'il n'existe pas de conversion implicite de S à D.

Enfin, la version à un seul paramètre aura recours au trait est_convertible<S,D>::value pour identifier dans laquelle des deux catégories se trouve la conversion à choisir pour les types S et D impliqués.

Remarquez que le type vide type doit être instancié pour que le compilateur choisisse la bonne déclinaison de lexical_cast à deux paramètres. Le coût de cette stratégie est nul en pratique : une instance d'un type vide est aussi petite que possible pour une plateforme donnée et son élimination par optimisation est banale.

template <class D, class S>
   D lexical_cast(S &&src, implicite)
      { return std::forward<S>(src); }
template <class D, class S>
   D lexical_cast(S &&src, serialisation) {
      std::stringstream sstr;
      sstr << src;
      D dest;
      sstr >> dest;
      return dest;
   }
template <class D, class S>
   D lexical_cast(S &src) {
      return lexical_cast<D>(
         std::forward<S>(src),
         typename std::conditional<
            est_convertible<S,D>::value,
            implicite,
            serialisation
         >::type{}
      );
   }

Un exemple de programme ayant recours à la fonction lexical_cast ainsi définie serait celui proposé à droite.

Le premier appel à lexical_cast passera par la version reposant sur une sérialisation alors que la seconde utilisera la déclinaison qui exploite l'existence d'une conversion implicite entre les types impliqués.

int main() {
   using namespace std;
   int i = 3;
   // conversion par sérialisation
   auto s = lexical_cast<string>(i);
   cout << s << endl;
   // conversion implicite
   auto f = lexical_cast<float>(i);
   cout << f << endl;
} 

Un petit mot en terminant : une solution complète au problème de est_convertible<S,D> est plus complexe que ceci si elle doit considérer en détail la possibilité de convertir un parent en enfant ou un enfant en parent, surtout en situation d'héritage multiple.

Le cas où des pointeurs apparentés ou non apparaissent devrait être traité, de même que les qualificateurs const et volatile et le type void.

L'idée demeure la même, mais une solution complète demande un peu plus de soin, en particulier le soin du détail.


Valid XHTML 1.0 Transitional

CSS Valide !