Des types plutôt que des constantes

Cet article, sans être très sophistiqué du point de vue du contenu, repose sur des observations mises de l'avant à travers les recherches et réflexions sur la métaprogrammation, telle que mise en pratique par la portion STL de la bibliothèque standard de C++.

Imaginons une classe représentant un système de calcul du temps écoulé et dont chaque instance serait capable de retourner la durée d'un intervalle de temps selon plusieurs seuils de précision distincts (disons en secondes, en millisecondes et en microsecondes).

Imaginons aussi que cette classe soit telle que nous la souhaitions portable au sens où le même code source devrait être utilisable sur toute plateforme sans perte d'efficacité ou de généricité. Ceci n'implique pas que les implémentations soient identiques sur toutes les plateformes mais bien que les interfaces soient identiques (aux constantes près!).

Plusieurs approches sont possibles, et plusieurs sont d'ailleurs tout à fait raisonnables. J'offrirai ci-dessous (et pour chaque cas) une ébauche de solution. Je me limiterai d'ailleurs à une ébauche, du fait que le standard C++ 03 offre de manière portable la fonction std::clock() de <ctime> qui retourne un std::clock_t représentant le nombre de CLOCKS_PER_SEC depuis le lancement d'un programme; il se trouve que CLOCKS_PER_SEC est souvent 1000 ce qui implique que std::clock() soit habituellement précis à la milliseconde près seulement.

Avec C++ 11, de meilleurs outils sont offerts à travers la bibliothèque <chrono>, mais je n'ai pas réécrit l'article puisque ces outils ne sont qu'un exemple, pas le coeur du propos.

Presque toutes les plateformes offrent des stratégies locales et non portables de plus haute précision pour mesurer le temps écoulé dans un programme. Je ne souhaite pas faire une inventaire de telles fonctionnalités mais bien faire la démonstration d'une approche efficace et élégante pour sélectionner l'un de ces mécanismes. Remplacez l'implémentation de ces ébauches par ce qui convient à la plateforme de votre choix.

Approche 0 – Noms distincts de méthodes

Une première approche, simple, est d'exposer une méthode au nom distinct par seuil de précision souhaité. L'exemple de la classe MesurerTemps proposé à droite suit cette approche et expose quatre méthodes pour calculer des intervalles de temps écoulé :

  • La méthode privée intervalle_brut() qui retourne un std::clock_t converti en value_type (type interne et public correspondant à un nombre à virgule flottante – on aurait pu faire de MesurerTemps une classe générique et laisser le code client déterminer le type de value_type, mais je vous laisse le faire si ça vous amuse[0])
  • La méthode publique secondes() qui retourne un value_type représentant le nombre de secondes écoulées entre la construction d'un MesurerTemps (ou le moment de son redémarrage) et l'invocation de sa méthode arreter(), et
  • Les méthodes publiques millisecondes() et microsecondes() qui font de même mais à un niveau de précision plus élevé
#include <ctime>
class MesurerTemps {
public:
   using value_type = double;
   using clock_type = std::clock_t;
private:
   clock_type avant, apres;
   value_type intervalle_brut() const noexcept {
      return static_cast<value_type>(apres_ - avant_);
   }
public:
   clock_type now() const noexcept {
      return std::clock();
   }
   MesurerTemps() noexcept {
      avant = now();
   }
   void arreter() noexcept {
      apres = now();
   }
   void relancer() noexcept {
      avant = now();
   }
   value_type secondes() const noexcept {
      return intervalle_brut() / CLOCKS_PER_SEC;
   }
   value_type millisecondes() const noexcept {
      return secondes() * 1000;
   }
   value_type microsecondes() const noexcept {
      // ...
   }
};

Remarquez que millisecondes() utilise secondes() qui utilise à son tour intervalle_brut(). Contrairement à ce qu'on pourrait penser, ceci n'entraîne pas de coût à l'exécution – ces méthodes sont toutes déclarées à même l'interface de la classe MesurerTemps et le compilateur peut, de manière banale, remplacer les appels par le code appelé (la technique bien connue du Method Inlining).

Le principal défaut de cette approche est qu'elle est un peu moche sur le plan de l'esthétique. La multiplicité des noms pour une même stratégie demande au code client de mettre en place ses propres mécanismes pour choisir adéquatement les bonnes méthodes à appeler. Ça fonctionne bien, c'est rapide, mais ça ne fait pas vibrer la fibre esthétique des informaticiennes et des informaticiens.

Approche 1 – Une seule méthode, choix dynamique

Une alternative fréquemment rencontrée et un peu plus élégante du point de vue du code client est d'implémenter une seule méthode (un goulot d'étranglement pour les appels) et de suppléer une constante pour dicter la précision désirée.

Cette alternative est toutefois plus coûteuse que la précédente; payer de la performance pour gagner en élégance n'est pas nécessairement souhaitable.

Petite remarque : on pourrait améliorer la performance de ecoule(), mais une forme d'évaluation dynamique et de validation demeurerait et nous empêcherait de revenir au seuil de performance de l'approche précédente qui, elle, reposait sur un processus de sélection de stratégie strictement statique.

#include <ctime>
class MesurerTemps {
public:
   using value_type = double;
   using clock_type = std::clock_t;
private:
   clock_type avant, apres;
   value_type intervalle_brut() const noexcept {
      return static_cast<value_type>(apres - avant);
   }
   value_type secondes() const noexcept {
      return intervalle_brut() / CLOCKS_PER_SEC;
   }
   value_type millisecondes() const noexcept {
      return secondes() * 1000;
   }
   value_type microsecondes() const noexcept {
      // ...
   }
public:
   clock_type now() const noexcept {
      return std::clock();
   }
   MesurerTemps() noexcept {
      avant = now();
   }
   void arreter() noexcept {
      apres = now();
   }
   void relancer() noexcept {
      avant = now();
   }
   class PrecisionInvalide {};
   enum class Precision {
      Secondes,
      Millis,
      Micros
   };
   value_type ecoule(Precision prec) const {
      value_type dt;
      switch (prec) {
      case Precision::Secondes:
         dt = secondes(); break;
      case Precision::Millis:
         dt = millisecondes(); break;
      case Precision::Micros:
         dt = microsecondes(); break;
      default:
         throw PrecisionInvalide();
      }
      return dt;
   }
};

À remarquer dans cette stratégie :

Effet secondaire non négligeable aussi côté performance: l'évaluation dynamique de la précision demandée qui est faite par l'instance de MesurerTemps risque de compenser négativement tout effort d'optimisation fait par le code client.

En effet, si ce dernier applique une technique comme l'optimisation par savoir discret, ses efforts ne lui permettront pas de retrouver un seuil de performance comparable à celui obtenu par l'approche 0 à cause de la lenteur implicite de l'approche 1.

Alternative à éviter, du moins dans la majorité des cas

Une approche envisageable mais qu'il vaut probablement mieux éviter serait de remplacer les valeurs énumérées du type Precision par des constantes qui pourraient être utilisées directement dans les calculs de temps écoulé (pensez à 1 pour les secondes et à 1000 pour les millisecondes dans notre exemple).

Bien que cela soit en apparence susceptible de simplifier la tâche de MesurerTemps, il est surtout probable que cela complique la tâche de la validation des données. Toute constante positive devient alors implicitement valide (à part zéro, probablement) et le contrôle de qualité passe alors pleinement du côté du clode client – l'utilité réelle de MesurerTemps est alors singulièrement réduite.

La portabilité du code souffre alors immédiatement, du moins si les valeurs en question changent selon la plateforme : n'oublions pas que std::clock() n'est utilisé ici qu'à titre d'illustration et qu'il est hautement probable que des mécanismes locaux soient privilégies, à l'interne, dans une implémentation sérieuse de MesurerTemps... d'autant plus que CLOCKS_PER_SEC varie d'une plateforme à l'autre! Au mieux, il faudrait modifier la vocation de la classe MesurerTemps et la ramener à celle, simplifiée, d'offrir une précision brute homogène.

Approche 2 – Même nom de méthode, choix statique

Ce que je vous propose ici est une solution hybride : un seul nom de méthode pour trois implémentations distinctes (incluant une espèce d'implémentation « par défaut », pourquoi pas?) et une sélection de stratégie qui soit statique plutôt que dynamique.

Examinons le code proposé à droite. Ce qu'il faut remarquer :

  • La classe MesurerTemps n'offre plus de constantes énumérées pour spécifier les précisions possibles. Ne plus avoir de constantes à évaluer à l'exécution est un gain important, éliminant le processus dynamique de prise de décision et de validation des paramètres
  • En retour, la classe MesurerTemps contient trois classes imbriquées, chacune étant vide (donc, sans être de taille zéro car ce serait illégal en C++, chacune représente un type dont les instances sont aussi petites que possible). Chacune de ces classes représente une précision supportée et porte un nom significatif en ce sens (j'ai utilisé les mêmes noms que dans l'approche 1 pour rendre le lien entre les deux aussi évident que possible)
  • La classe MesurerTemps expose une méthode ecoule() par précision supportée (j'en ai ajouté une quatrième par souci de convivialité)
  • Chacune de ces méthodes diffère des autres non pas par son nom mais bien par le nombre de paramètres à lui fournir ou par le type d'au moins un de ses paramètres
  • La version sans paramètres de ecoule() délègue simplement son travail à la version de ecoule() retournant un nombre de millisecondes (choix arbitraire de ma part). Ceci nous donne un exemple d'appel correct à une méthode ecoule() avec paramètres : il suffit d'instancier explicitement la classe représentant la précision souhaitée (dans ce cas, passer une instance anonyme de PrecisionMillis à ecoule())
  • Puisque le choix d'une version ou l'autre des méthodes se fait sur la base des types des paramètres, ce choix est fait à la compilation – il est statique. Le processus de décision dynamique n'est plus nécessaire et les optimisation usuelles d'un compilateur ramènent le niveau de performance de cette approche à celui de l'approche 0
  • Remarquez que les versions prenant un paramètre spécifient le type du paramètre mais ne lui donnent pas de nom. C'est là un geste volontaire : donner un nom à un paramètre suggérerait que la méthode souhaite l'utiliser (et générerait un avertissement sur la plupart des compilateurs) alors que nous n'avons recours à un paramètre que pour faciliter l'aiguillage statique des méthodes. Le compilateur pourra donc à la fois utiliser le type des paramètres pour choisir la bonne méthode à la compilation et éliminer la construction et la destruction de l'objet temporaire généré pour choisir l'une ou l'autre de ces méthodes puisque ce code est, une fois ce choix fait, complètement inutile
#include <ctime>
class MesurerTemps {
public:
   using value_type = double;
   using clock_type = std::clock_t;
private:
   clock_type avant, apres;
   value_type intervalle_brut() const noexcept {
      return static_cast<value_type>(apres - avant);
   }
   value_type secondes() const noexcept {
      return intervalle_brut() / CLOCKS_PER_SEC;
   }
   value_type millisecondes() const noexcept {
      return secondes () * 1000;
   }
   value_type microsecondes() const noexcept {
      // ...
   }
public:
   clock_type now() const noexcept {
      return std::clock();
   }
   MesurerTemps() noexcept {
      avant = now();
   }
   void arreter() noexcept {
      apres = now();
   }
   void relancer() noexcept {
      avant = now();
   }
   class PrecisionSecondes {};
   class PrecisionMillis {};
   class PrecisionMicros {};
   value_type ecoule() const noexcept {
      return ecoule(PrecisionMillis{});
   }
   value_type ecoule(PrecisionSecondes) const noexcept {
      return secondes();
   }
   value_type ecoule(PrecisionMillis) const noexcept {
      return millisecondes();
   }
   value_type ecoule(PrecisionMicros) const noexcept {
      return microsecondes();
   }
};

L'approche 2 est donc à la fois aussi élégante que l'approche 1 pour le code client et aussi efficace que l'approche 0 d'un point de vue performance.

La jonction entre les deux mondes se fait en passant d'un descriptif dynamique (les constantes, évaluées par le programme alors qu'il s'exécute) à un descriptif statique (les types, connus dès la compilation) et par le remplacement de valeurs numériques par l'instanciation de concepts purs (classes vides).

Résultat : un code simple, compact, extrêmement rapide, élégant et qui reste lisible. C'est quand même pas si mal...

Suggestion de compromis

Le très éveillé André Caron m'a envoyé ce petit message :

Ceci aurait plusieurs avantages :

Une idée comme ça : si le besoin se fait vraiment ressentir d'utiliser l'alternative à éviter (utiliser les dénominateurs comme valeurs pour l'énumération), on peut toujours le faire comme ceci (à droite).

  1. Similitude avec les constantes. L'usager aurait l'impression qu'il utilise une constante, ceci est plus naturel pour quelqu'un qui n'est pas habitué aux subtilités de C++
  2. « Performances » similaires à celles obtenues avec l'approche 1;
  3. Une seule et unique méthode à écrire
  4. Beaucoup plus facile à étendre : une ligne dans l'interface, une ligne dans l'implémentation
  5. Ne compromet pas le gain atteint par le raffinement effectué dans l'approche 2
  6. Moins de code à écrire, donc moins épeurant
#include "Incopiable.h"
class MesurerTemps {
   // voir les exemples plus haut
public:
   // voir les exemples plus haut
   class Precision : Incopiable {
      friend class MesurerTemps;
      Precision(int denominateur) : denominateur{denominateur}
      {
      }
      const int denominateur;
   public:
      value_type denominateur() const {
         return denominateur;
      }
   };
   // instanciée dans MesurerTemps.cpp avec 1000
   static const Precision millisecondes;
   // instanciée dans MesurerTemps.cpp avec 100'000
   static const Precision microsecondes;
   // et ainsi de suite pour les précisions souhaitées
   value_type ecoule(const Precision &precision) const {
      return intervalle_brut()/precision.denominateur();
   }
};

Voici donc pour une alternative OO plus près des habitudes de la majorité. Notez cependant que le risque demeure qu'une précision invalide (disons 0) soit soumise par le code client, ce qui implique du traitement d'exceptions avec tout ce que cela comporte (quoique ce ne soit pas vraiment un risque du fait que le constructeur, ici, est privé). Ça reste une technique qui a plus de potentiel que l'approche 1 du fait qu'il est possible de contrôler la validité d'une précision à la construction d'une instance de MesurerTemps::Precision.

Le tout aussi éveillé Félix C. Morency, réagissant à la suggestion d'André (ci-dessus), m'écrit ces quelques mots :

Salut!

Je ne suis pas tout à fait d'accord avec le compromis d'André. En effet, la précision du compteur étant fixée à la construction, nous perdons toute la flexibilité du code (qui est ma foi le but de l'article). De plus, comme mentionné, la division peut entraîner une perte de précision allant de faible à très grande, ce qui est à mon avis non négligeable. De plus, la division sur certains types de processeur est plutôt lente.

Si, par exemple, nous nous situons sur la planète Bidule, que notre intervalle de temps brut contienne la valeur 2000000.0000000000456456 et que notre dénominateur possède la valeur 1.999999999999999900000000000 (car les Biduliens ont un format de temps très particulier et peu pratique), eh bien nous obtiendrons une immense erreur de calcul.

P.-S. : désolé pour le formalisme de l'exemple en Python, mais le résultat est le même en C++ (seulement plus long à écrire).

Python 2.4.4c1 (#2, Oct 11 2006, 19:53:58)
[GCC 4.1.2 20060928 (prerelease) (Ubuntu 4.1.1-13ubuntu5)] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> j = 2000000.0000000000456456 / 1.999999999999999900000000000
>>> j
1000000.0

L'ami André Caron propose cette réplique :

Salut Pat,

J'ai vu que quelqu'un a répondu à mon ajout. C'est drôle parce que ça m'a pris un bout de temps pour comprendre pourquoi il parlait de perte de flexibilité. C'est parce que MesurerTemps::Precision hérite de Incopiable. Ceci implique donc que l'usager doit faire :

MesurerTemps chrono;
chrono.getValeur(MesurerTemps::millisecondes);

...et ne peut pas faire :

MesurerTemps chrono;
MesurerTemps::Precision precision = MesurerTemps::millisecondes;
chrono.getValeur(precision);

...ce qui nous ramène au point de départ où on avait trois méthodes.

Je me permets une petite parenthèse, tout de même, pour rappeler qu'il aurait été possible de déclarer precision comme étant un MesurerTemps::Precision& ou un auto& avec C++ 11, tout de même.

Laissons André poursuivre :

Ce qui est drôle, c'est que ce sur quoi moi j'avais accroché en le lisant est ceci : « Imaginons aussi que cette classe soit telle que nous la souhaitions portable au sens où le même code source devrait être utilisable sur toute plateforme sans perte d'efficacité ou de généricité. Ceci n'implique pas que les implémentations soient identiques sur toutes les plateformes mais bien que les interfaces soient identiques (aux constantes près!). ».

Donc, je propose une nouvelle version qui permet la flexibilité (j'ai juste enlevé le parent Incopiable, on peut bien faire des copies des précisions énumérées, ce sont des objets constants et le constructeur est privé donc seul MesurerTemps ou Precision pourra les instancier avec des nouvelles valeurs. Et puis, juste pour le plaisir, j'ai changé la façon dont MesurerTemps::ecoule() calcule le résultat, ce qui prouve qu'on peut facilement changer l'implémentation sans changer l'interface, ce que moi j'avais perçu comme étant le but de l'article.

class MesurerTemps {
   // voir les exemples plus haut
public:
   // voir les exemples plus haut
   class Precision {
      friend class MesurerTemps;
      Precision(const value_type MultipleSecondes)
         : m_MultipleSecondes{MultipleSecondes}
      {
      }
      const value_type m_MultipleSecondes;
   };
   // instanciée dans MesurerTemps.cpp avec 0.001
   static const Precision millisecondes;
   // instanciée dans MesurerTemps.cpp avec 0.000001
   static const Precision microsecondes;
   // et ainsi de suite pour les précisions souhaitées
   value_type ecoule(const Precision &precision) const {
      return intervalle_brut()*precision.m_MultipleSecondes;
   }
};

Plus récemment, toujours de l'ami André...

Salut Pat,

je viens de réaliser qu'il y a un bogue dans la nouvelle version de la classe MesurerTemps::Precision... Puisque le multiple est constant, les usagers ne pourront pas utiliser l'affectation pour modifier les attributs de cette classe (et cette opération est précisément celle pour laquelle nous avons supprimé le parent Incopiable...)!

Il y a une solution simple à ce problème... La voyez-vous?

Lectures complémentaires

Quelques liens pour enrichir le propos.


// ...
template <class T = double>
   class MesurerTemps
   {
      // ...
   public:
      using value_type = T;
      // ...
   };

[0]Le code pour y arriver est tout simple et peut être vu dans l'encadré à droite :

Le reste du code de la classe demeure inchangé. Évidemment, les attentes face au type T, donc face à MesurerTemps::value_type, devront alors être définies clairement (ici, T doit se comporter comme un type arithmétique au sens usuel du terme).


Valid XHTML 1.0 Transitional

CSS Valide !