Pour comprendre cet article, il est préférable de comprendre au préalable la précieuse technique des traits.
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 à des coordonnées Euclidiennes ) et il serait souhaitable d'avoir une solution à la fois générale et efficace à tous les problèmes de ce genre.
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 °F à °C et une autre de conversion de °C à °F comme suit. |
|
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. |
|
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 :
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. |
|
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.
Pour illustrer ce que nous allons chercher à faire, je commencerai par proposer un programme de test possible.
Sachant que 5 degrés Celsius correspond à (approximativement) 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 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 sont en fait des catégories, dans un sens semblable aux catégories d'itérateurs. |
|
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.
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 :
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. Avec C++ 11, la fonction temperature_traits<Celsius>::gel_eau() peut avantageusement être qualifiée constexpr, indiquant ainsi que, bien qu'il s'agisse d'une fonction, la valeur qu'elle retourne est une constante connue à la compilation, ce qui permettra de nouvelles (et fort pertinentes, à mon avis) optimisations. D'ailleurs, cette optimisation sera fréquemment applicable dans les cas de traits comme ceux de std::numeric_limits ou ceux présentés dans cet article. |
|
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. |
|
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. |
|
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. |
|
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 de copie sera implicite, et la précieuse méthode swap() sera banale... Nous pourrions presque l'omettre, si ce n'était du – ici très pertinent – opérateur d'affectation par conversion. |
|
Le premier cas subtil apparaît dans le constructeur de conversion, 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. |
|
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é devient banal, comme à l'habitude, suite à la définition de swap() et des constructeurs de copie et de conversion. |
|
Étant donné la conventionnelle méthode valeur() 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. Avec C++ 20, écrire operator<=> suffirait ici. Comparer des nombres à virgule flottante avec == est malsain. Nous aurions pu utiliser une technique plus raffinée et éliminer, potentiellement, bien des heurts : ../Maths/Assez-proches.html |
|
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.valeur();
}
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. Si vous souhaitez le faire, procédez à partir de temperature_traits<T> et enrichissez ces traits en conséquence.
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. |
|
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).
Un exemple de code client serait celui proposé ici.
int main() {
using namespace std;
Temperature<Kelvin> k = 3;
Temperature<Celsius> c = k;
cout << c << " "
<< temperature_traits<Celsius>::nom() << endl;
k = c;
cout << k << " "
<< temperature_traits<Kelvin>::nom() << endl;
if (k == c)
cout << c << " " << temperature_traits<Celsius>::nom()
<< " == "
<< k << " " << temperature_traits<Kelvin>::nom()
<< endl;
else
cout << c << " " << temperature_traits<Celsius>::nom()
<< " != "
<< k << " " << temperature_traits<Kelvin>::nom()
<< endl;
Temperature<Fahrenheit> f = Temperature<Celsius>(5);
cout << f << " " << temperature_traits<Fahrenheit>::nom() << endl;
}
Remarquez que toutes les conversions sont implicites et efficaces, passant systématiquement par le mécanisme de construction de conversion. 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.
Depuis C++ 11, avec l'avènement de littéraux maison et d'expressions constantes généralisées, il est possible de raffiner encore plus notre solution, de manière à la rendre essentiellement optimale...
Voici donc sans plus tarder une solution complète, qui compile sans problème avec la plupart des compilateurs récents au moment d'écrire ceci.
Nous débuterons par le code client, légèrement adapté pour illustrer notre propos.
J'ai ajusté le code client de l'exemple précédent pour montrer ce que nous allons viser avec cette version du programme :
|
|
Puisque nous devons ultimement comparer des températures sur la base de leurs valeurs, et puisque ces valeurs sont susceptibles d'être représentées par des nombres à virgule flottante, il nous faut une manière propre de réaliser cette comparaison.
J'ai utilisé ici la technique décrite dans ../Maths/Assez-proches.html, en prenant soin d'avoir recours aux expressions constantes généralisées du fait qu'il s'agit (à mon avis) d'un beau cas d'application. |
|
La classe Temperature<T,V> a évidemment besoin elle aussi de quelques retouches.
Tout d'abord, il importe de déclarer le prototype de temperature_cast. |
|
Presque tous les services de la classe Temperature<T,V> sont constexpr. Cela signifie qu'il est, en pratique, possible d'utiliser des Temperature<T> pour différents types de températures, et d'obtenir littéralement (!) les conversions et autres opérations à coût zéro. |
|
Les opérateurs permettant d'exprimer des Temperature<T> sous la forme de littéraux maison, addition au code pour cette version, sont proposés à droite. Remarquez que j'accepte les nombres à virgule flottante et les entiers, donc des expressions comme 0.5_C (tout juste au-dessus du seuil de gel de l'eau) ou 50_F (un peu plus chaud). Dans le cas où un entier est utilisé pour le littéral, je dois appeler le constructeur avec parenthèses, car les accolades sont plus strictes et rejetteraient la conversion implicite d'un unsigned long long à un long double. Dans tous les cas, nos littéraux sont constexpr car ils appellent des constructeurs constexpr. |
|
Enfin, outre le fait que j'ai intégré l'unité de mesure dans l'affichage d'une température, le reste n'a pas vraiment changé. |
|
Nous avons obtenu, au passage, une amélioration significative du code généré, et un gain d'expressivité. Pas si mal!
Quelques liens pour enrichir le propos.
[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.
[2] Une simple constante pourrait ne pas suffire du fait que le type temperature_traits<T>::value_type pourrait ne pas être entier. Cela dit, ce problème aura des conséquences bien moindres avec l'avènement d'expressions constantes généralisées.