Implémenter des conversions de référentiels

Il arrive fréquemment, dans un programme, qu'il soit nécessaire d'écrire du code de conversion de format, de type ou de valeur. Un cas particulièrement irritant est celui de la conversion de données d'un référentiel à l'autre.

Ce cas est rencontré, par exemple, lorsqu'un programme doit utiliser à la fois plusieurs outils approchant un même problème à l'aide de structures de données semblables mais différentes (trois systèmes d'axes distincts pour une description 3D, par exemple) ou à l'aide de données de formats connexes à une conversion près (pensez à des distances encodées selon le système impérial et à d'autres encodées selon le système métrique).

Lorsque la conversion est simple, comme dans le cas où chaque format est identique à un autre à un facteur multiplicatif constant près, une solution est de conserver une gamme de constantes multiplicatives globales et d'appliquer les multiplications manuellement. Certaines conversions, en contrepartie, sont moins simples (pensons au passage de coordonnées polaires {n, e, d} à des coordonnées Euclidiennes {x, y, z}) et il serait souhaitable d'avoir une solution à la fois générale et efficace à tous les problèmes de ce genre.

Démonstration par l'exemple—conversion de température

Un exemple type, fréquemment rencontré dans des livres d'introduction à la programmation, est celui de la conversion de températures.

On peut par exemple envisager une fonction de conversion de Fahrenheit à Celsius et une autre de conversion de Celsius à Fahrenheit comme suit.

double FahrenheitACelsius (const double Fahrenheit)
{
   return (Fahrenheit - 32.0) * 5.0 / 9.0;
}
double CelsiusAFahrenheit (const double Celsius)
{
   return 32.0 + 9.0 / 5.0 * Celsius;
}

Le problème se complique si on ajoute d'autres formats dans et vers lesquels convertir.

En effet, en ajoutant la notation en degrés Kelvin, on en arrive à six cas de conversion possibles, avec une implémentation possible sous la forme suivante (je ne répéterai pas les deux premières fonctions par souci d'économie).

Remarquez le passage par un format intermédiaire jouant le rôle de format neutre (ici : la notation en degrés Celsius) pour simplifier le tout et réduire le risque d'erreurs.

double CelsiusAKelvin (const double Celsius)
{
   return Celsius + 273;
}
double KelvinACelsius (const double Kelvin)
{
   return Kelvin - 273;
}
double FahrenheitAKelvin (const double Fahrenheit)
{
   return CelsiusAKelvin(FahrenheitACelsius(Fahrenheit));
}
double KelvinAFahrenheit (const double Kelvin)
{
   return CelsiusAFahrenheit(KelvinACelsius(Kelvin));
}

C'est une tactique commune en telle situation, et qui ne coûte pas vraiment en temps d'exécution puisque, dans la majorité des cas, les opérations mathématiques sur les constantes impliquées dans les conversions seront résolues ou simplifiées à la compilation.

Notez aussi que j'ai omis les conversions d'un format vers lui-même (de Celsius à Celsius, par exemple) qui sont redondantes mais dont nous devrons nous préoccuper si nous souhaitons en arriver à une approche générale et efficace.

Cette approche, bien que pleinement opérationnelle, entraîne en pratique un certain nombre de problèmes :

  • le code client doit être exprimé en termes primitifs (on doit penser en termes de valeurs plutôt qu'en termes symboliques);
  • le code client doit être conscient des conversions à réaliser, donc des fonctions de conversion à invoquer (la représentation primitive étant un nombre à virgule flottante de double précision); ce qui signifie que
  • le code ne peut automatiser les conversions requises. Du code utilisant une stratégie comme celle-ci est implicitement fragile.

Une solution plus OO et un peu plus robuste est de passer par une classe Temperature qui représenterait à l'interne les températures sous une forme neutre (disons en degrés Celsius). Cette classe aurait par exemple des accesseurs et des mutateurs pour des valeurs selon diverses représentations.

Remarquez que Temperature est un simple type valeur et que son constructeur par copie, son affectation et son destructeur par défaut sont tous très convenables.

On pourrait ajouter quelques opérations à Temperature (en particulier les opérateurs relationnels), mais le travail à faire est banal.

class Temperature
{
   double m_Valeur; // neutre
public:
   Temperature ()
      : m_Valeur (0.0)
   {
   }
   double GetCelsius () const
   {
      return m_Valeur;
   }
   double GetKelvin () const
   {
      return m_Valeur + 273;
   }
   double GetFahrenheit () const
   {
     return 32 + 9.0 / 5.0 * m_Valeur;
   }
   void SetCelsius (const double Valeur)
   {
      m_Valeur = Valeur;
   }
   void SetKelvin (const double Valeur)
   {
      m_Valeur = Valeur - 273;
   }
   void SetFahrenheit (const double Valeur)
   {
      m_Valeur = (Valeur - 32) * 5.0 / 9.0;
   }
};

Cette solution est acceptable mais demeure manuelle, du fait que l'interface repose strictement sur des types primitifs et que Temperature se trouve chargée d'un nombre arbitrairement grand d'accesseurs et de mutateurs (deux par format supporté).

Il est possible, avec un peu d'imagination, de faire bien mieux.

Représentation efficace des référentiels et des conversions[1]

int main ()
{
   Temperature<Kelvin> k;
   Temperature<Celsius> c;
   Temperature<Fahrenheit> f;
   f = k = c = 5;
   std::cout << c << ' '
             << k << ' '
             << f << std::endl;
}

Pour illustrer ce que nous allons chercher à faire, je commencerai par proposer un programme de test possible.

Sachant que 5 degrés Celsius correspond à 278 degrés Kelvin et à 41 degrés Fahrenheit, nous souhaiterions que le code proposé à droite affiche 5 278 41.

Comprenons que :

Je prends le pari que cette écriture vous semblera naturelle (du moins, je dois vous avouer qu'elle me semble naturelle). Remarquez que l'acte d'affecter une température à une autre implique une conversion lorsque cela s'avère opportun et qu'une température est un type valeur à part entière.

En gros, notre approche ira comme suit :

Cette approche minimisera le travail à réaliser pour ajouter un type de température au modèle. Notre exemple présentera un système à trois types (Celsius, Kelvin et Fahrenheit).

Les concepts représentant les températures

class Celsius {};
class Fahrenheit {};
class Kelvin {};

Les températures seront, tel qu'annoncé, représentées sous forme de purs concepts. Ainsi, les classes Celsius, Fahrenheit et Kelvin seront toutes trois des classes vides, qui ne font qu'exister.

Ce sera pour nous à la fois une condition nécessaire et suffisante pour mettre en application les techniques auxquelles nous aurons recours.

Ajouter une sorte de température à notre modèle impliquera donc la nommer à l'aide d'une classe conceptuelle (vide) comme celles-ci.

Les traits des températures

Nous décrirons les caractéristiques d'une température donnée sous la base de traits. Pour nos fins, les traits requis seront les suivants :

  • définir le type utilisé pour représenter la valeur selon laquelle la température est encodée (type interne et public value_type);
  • définir une méthode de conversion vers un format neutre (méthode to_neutral()) et une méthode de conversion à partir d'un format neutre (méthode to_local());
  • définir une méthode exposant la valeur du seuil auquel gèle l'eau, pour faciliter la construction par défaut; et
  • à titre utilitaire, offrir une méthode permettant de connaître le nom de la température.

Notre convention sera que la représentation neutre de température sera l'expression en degrés Celsius. Notez que le cas général restera indéfini pour restreindre les risques d'erreur à l'exécution.

template <class T>
   struct temperature_traits;
template <>
   struct temperature_traits<Celsius>
   {
      typedef int value_type;
      static value_type to_neutral (const value_type val)
      {
         return val;
      }
      static value_type to_local (const value_type val)
      {
         return val;
      }
      static std::string nom ()
      {
         static const std::string NOM = "Celsius";
         return NOM;
      }
      static value_type gel_eau()
      {
         static const value_type SEUIL_GEL_EAU = 0;
         return SEUIL_GEL_EAU;
      }
   };

Les traits décrivant la représentation d'une température en degrés Kelvin constitueront une spécialisation du trait générique et respecteront les mêmes règles.

Cette approche réduit fortement la complexité intrinsèque à l'ajout de types de températures puisque chaque type de température a un coût descriptif fixe, peu importe le nombre de températures supportées au total.

template <>
   struct temperature_traits<Kelvin>
   {
      typedef unsigned int value_type;
   private:
      static const value_type DELTA_ZERO_ABSOLU = 273;
   public:
      static value_type to_neutral (const value_type val)
      {
         return val - DELTA_ZERO_ABSOLU;
      }
      static value_type to_local (const value_type val)
      {
         return val + DELTA_ZERO_ABSOLU;
      }
      static std::string nom ()
      {
         static const std::string NOM = "Kelvin";
         return NOM;
      }
      static value_type gel_eau ()
      {
         static const value_type SEUIL_GEL_EAU = 273;
         return SEUIL_GEL_EAU;
      }
   };

Sans surprises, les traits pour la température exprimée en degrés Fahrenheit sont aussi simples que ceux pour les températures exprimées en degrés Celsius ou en degrés Kelvin.

template <>
   struct temperature_traits<Fahrenheit>
   {
      typedef double value_type;
      static value_type to_neutral (const value_type val)
      {
         return (val - 32.0) * 5.0 / 9.0;
      }
      static value_type to_local (const value_type val)
      {
         return 32.0 + 9.0 / 5.0 * val;
      }
      static std::string nom ()
      {
         static const std::string NOM = "Fahrenheit";
         return NOM;
      }
      static value_type gel_eau ()
      {
         static const value_type SEUIL_GEL_EAU = 32.0;
         return SEUIL_GEL_EAU;
      }
   };

La classe générique Temperature

Sans être très complexe, la classe générique Temperature nous demandera un peu de réflexion.

Elle sera générique sur la base d'un type conceptuel de température (par exemple la classe Kelvin) et représentera sa valeur à l'aide du value_type défini par les traits de ce type conceptuel.

template <class T>
   class Temperature
   {
   public:
      typedef typename
         temperature_traits<T>::value_type value_type;
   private:
      value_type m_Valeur;
   public:
      value_type GetValeur () const
      {
         return m_Valeur;
      }

Son constructeur par défaut constituera le seuil du gel de l'eau pour le type de température représenté. Encore une fois, les traits nous seront d'un grand secours ici.

Le constructeur par copie sera banal, comme c'est souvent le cas. Il en sera d'ailleurs de même pour la précieuse méthode swap().

   public:
      Temperature (const value_type Valeur =
                      temperature_traits<T>::gel_eau ())
         : m_Valeur (Valeur)
      {
      }
      Temperature (const Temperature &temp)
         : m_Valeur (temp.GetValeur ())
      {
      }
      void swap (Temperature &temp)
      {
         std::swap (m_Valeur, temp.m_Valeur);
      }

Le premier cas subtil apparaît dans le constructeur de type apparenté, l'une des pièces clés de notre modèle : après tout, nous voulons automatiser un mécanisme de création efficace permettant par exemple la construction d'une température en degrés Kelvin à partir d'une température en degrés Fahrenheit.

À cet effet, examinez attentivement la notation choisie pour réaliser l'implémentation proposée à droite.

   template <class U>
      Temperature (const Temperature<U> &temp)
         : m_Valeur (temperature_cast<T,U> (temp.GetValeur ()))
      {
      }

La valeur d'une température de type T sera celle d'un type T suivant une conversion de température du type U au type T, opération nommée ici un temperature_cast<T,U>. Nous verrons un peu plus bas comment cette fonction sera implémentée.

L'opérateur d'affectation, pour un type apparenté ou non, devient banal, comme à l'habitude, suite à la définition de swap() et des constructeurs de copie et de types apparentés.

      const Temperature & operator= (const Temperature &temp)
      {
         Temperature (temp).swap (*this);
         return *this;
      }
      template <class U>
         const Temperature & operator= (const Temperature<U> &temp)
         {
            Temperature (temp).swap (*this);
            return *this;
         }

Étant donné la conventionnelle méthode GetValeur() et présumant un type value_type ayant des propriétés arithmétiques normales pour un nombre, les opérateurs relationnels vont de soi.

Notez que tous ont été exprimés ici en fonction des opérateurs ==, < et de la négation logique. Ceci pourrait nous permettre de simplifier encore la classe Temperature si nous avions recours à des techniques d'injection et d'enchaînement de parents.

J'ai volontairement omis d'appliquer ces techniques ici dans le but de garder relativement simple ce code que certains qualifieraient déjà d'un peu subtil.

      bool operator== (const Temperature &temp) const
      {
         return GetValeur () == temp.GetValeur ();
      }
      bool operator!= (const Temperature &temp) const
      {
         return ! (*this == temp);
      }
      bool operator< (const Temperature &temp) const
      {
         return GetValeur () < temp.GetValeur ();
      }
      bool operator<= (const Temperature &temp) const
      {
         return !(temp.GetValeur () < GetValeur ());
      }
      bool operator> (const Temperature &temp) const
      {
         return temp.GetValeur () < GetValeur ();
      }
      bool operator>= (const Temperature &temp) const
      {
         return !(GetValeur () < temp.GetValeur ());
      }
   };

Projeter une Temperature sur un flux

L'écriture d'une Temperature sur un flux va de soi si sa valeur est sérialisable.

template <class T>
   std::ostream & operator <<
      (std::ostream &os, const Temperature<T> &temp)
   {
      return os << temp.GetValeur ();
   }

Il pourrait être intéressant d'envisager une projection qui inclurait aussi le nom de l'unité de mesure ou un symbole pour le représenter (p. ex. : F pour Fahrenheit) car cela permettrait dans certains programmes de définir un opérateur d'extraction d'un flux capable de déduire la catégorie de température à consommer.

Conversion générique de températures

L'écriture de l'opération de conversion générique de valeurs de températures est à la fois très complexe et très simple et s'exprime opérationnellement en termes de valeurs et conceptuellement (pour la généricité) en termes de classes conceptuelles.

Pour convertir une température source (type Src) en une température destination (type Dest), nous aurons simplement recours aux traits des deux types impliqués.
template <class Dest, class Src>
   typename temperature_traits<Dest>::value_type
      temperature_cast
         (const typename temperature_traits<Src>::value_type &src)
      {
         return temperature_traits<Dest>::to_local
            (temperature_traits<Src>::to_neutral (src));
      }

Le passage d'une valeur dans le modèle source à une valeur dans le modèle de destination se fait en deux temps, soit le passage de la source au modèle neutre puis le passage du modèle neutre à la destination. Les opérations étant simples et génériques, le code généré par le compilateur sera probablement optimal (présumant des traits bien écrits).

Exemple de code client

Un exemple de code client serait celui proposé à droite.

Remarquez que toutes les conversions sont implicites et efficaces, passant systématiquement par le mécanisme de construction par types apparentés (même l'affectation se fait selon ce mode, reposant sur la paire copie et swap()).

Une conversion manuelle ne demande rien de plus que la création d'une variable temporaire—et vous conviendrez que cette opération ne coûte, avec ce modèle, pratiquement rien.

int main ()
{
   Temperature<Kelvin> k = 3;
   Temperature<Celsius> c = k;
   std::cout << c << " "
             << temperature_traits<Celsius>::nom()
             << std::endl;
   k = c;
   std::cout << k << " "
             << temperature_traits<Kelvin>::nom ()
             << std::endl;
   if (k == c)
      std::cout << c << " "
                << temperature_traits<Celsius>::nom ()
                << " == "
                << k << " "
                << temperature_traits<Kelvin>::nom ()
                << std::endl;
   else
      std::cout << c << " "
                << temperature_traits<Celsius>::nom ()
                << " != "
                << k << " "
                << temperature_traits<Kelvin>::nom ()
                << std::endl;
  Temperature<Fahrenheit> f = Temperature<Celsius>(5);
  std::cout << f << " "
            << temperature_traits<Fahrenheit>::nom ()
            << std::endl;
}

Exercices

EX00—Ajoutez une nouvelle catégorie de température au modèle.

EX01—Ajoutez une méthode de classe nom() à Temperature<T> pour que le nom exposé par les traits de T soient directement accessibles à partir de la classe Temperature<T> ou à partir d'une de ses instances (p. ex. : Temperature<Celsius>::nom() devrait retourner "Celsius").

EX02—Écrivez le meilleur code possible (meilleur au sens de générique, général et efficace) pour savoir si l'eau gèle étant donné une température, quelle qu'elle soit.

EX03—Écrivez le meilleur code possible (meilleur au sens de générique, général et efficace) pour savoir si l'eau bout étant donné une température, quelle qu'elle soit.

EX04—Serait-il utile d'écrire un temperature_cast() générique sur un seul type et qui éviterait toute conversion? Expliquez votre réponse.


[1]Merci à Vincent Echelard et à François Jean : l'idée de cette stratégie m'est venue en bavardant avec ces deux illustres et forts pertinents collègues